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

This reverts commit d0314ab73c.
This commit is contained in:
keyan 2023-07-23 09:16:12 -05:00
parent b31b5ce4a8
commit 18910fa2ed
192 changed files with 9122 additions and 6894 deletions

1
.npmrc
View File

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

View File

@ -1,11 +1,11 @@
import { GraphQLError } from 'graphql'
import { AuthenticationError } from 'apollo-server-micro'
import { inviteSchema, ssValidate } from '../../lib/validate'
export default {
Query: {
invites: async (parent, args, { me, models }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
throw new AuthenticationError('you must be logged in')
}
return await models.invite.findMany({
@ -29,7 +29,7 @@ export default {
Mutation: {
createInvite: async (parent, { gift, limit }, { me, models }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
throw new AuthenticationError('you must be logged in')
}
await ssValidate(inviteSchema, { gift, limit })
@ -40,7 +40,7 @@ export default {
},
revokeInvite: async (parent, { id }, { me, models }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
throw new AuthenticationError('you must be logged in')
}
return await models.invite.update({

View File

@ -1,4 +1,4 @@
import { GraphQLError } from 'graphql'
import { UserInputError, AuthenticationError } from 'apollo-server-micro'
import { ensureProtocol, removeTracking } from '../../lib/url'
import serialize from './serial'
import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
@ -6,8 +6,7 @@ import { getMetadata, metadataRuleSets } from 'page-metadata-parser'
import domino from 'domino'
import {
BOOST_MIN, ITEM_SPAM_INTERVAL,
MAX_TITLE_LENGTH, ITEM_FILTER_THRESHOLD,
DONT_LIKE_THIS_COST, COMMENT_DEPTH_LIMIT, COMMENT_TYPE_QUERY
MAX_TITLE_LENGTH, ITEM_FILTER_THRESHOLD, DONT_LIKE_THIS_COST, COMMENT_DEPTH_LIMIT
} from '../../lib/constants'
import { msatsToSats } from '../../lib/format'
import { parse } from 'tldts'
@ -15,26 +14,6 @@ import uu from 'url-unshort'
import { amountSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, ssValidate } from '../../lib/validate'
import { sendUserNotification } from '../webPush'
import { proxyImages } from './imgproxy'
import { defaultCommentSort } from '../../lib/item'
export async function commentFilterClause (me, models) {
let clause = ` AND ("Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD}`
if (me) {
const user = await models.user.findUnique({ where: { id: me.id } })
// wild west mode has everything
if (user.wildWestMode) {
return ''
}
// always include if it's mine
clause += ` OR "Item"."userId" = ${me.id}`
}
// close the clause
clause += ')'
return clause
}
async function comments (me, models, id, sort) {
let orderBy
@ -74,9 +53,9 @@ export async function getItem (parent, { id }, { me, models }) {
return item
}
function whenClause (when, type) {
let interval = ` AND "${type === 'bookmarks' ? 'Bookmark' : 'Item'}".created_at >= $1 - INTERVAL `
switch (when) {
function topClause (within) {
let interval = ' AND "Item".created_at >= $1 - INTERVAL '
switch (within) {
case 'forever':
interval = ''
break
@ -96,16 +75,14 @@ function whenClause (when, type) {
return interval
}
const orderByClause = async (by, me, models, type) => {
switch (by) {
async function topOrderClause (sort, me, models) {
switch (sort) {
case 'comments':
return 'ORDER BY "Item".ncomments DESC'
return 'ORDER BY ncomments DESC'
case 'sats':
return 'ORDER BY "Item".msats DESC'
case 'votes':
return await topOrderByWeightedSats(me, models)
return 'ORDER BY msats DESC'
default:
return `ORDER BY "${type === 'bookmarks' ? 'Bookmark' : 'Item'}".created_at DESC`
return await topOrderByWeightedSats(me, models)
}
}
@ -135,17 +112,26 @@ export async function joinSatRankView (me, models) {
return 'JOIN sat_rank_tender_view ON "Item".id = sat_rank_tender_view.id'
}
export async function filterClause (me, models, type) {
// if you are explicitly asking for marginal content, don't filter them
if (['outlawed', 'borderland', 'freebies'].includes(type)) {
if (me && ['outlawed', 'borderland'].includes(type)) {
// unless the item is mine
return ` AND "Item"."userId" <> ${me.id} `
export async function commentFilterClause (me, models) {
let clause = ` AND ("Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD}`
if (me) {
const user = await models.user.findUnique({ where: { id: me.id } })
// wild west mode has everything
if (user.wildWestMode) {
return ''
}
return ''
// always include if it's mine
clause += ` OR "Item"."userId" = ${me.id}`
}
// close the clause
clause += ')'
return clause
}
export async function filterClause (me, models) {
// by default don't include freebies unless they have upvotes
let clause = ' AND (NOT "Item".freebie OR "Item"."weightedVotes" - "Item"."weightedDownVotes" > 0'
if (me) {
@ -176,33 +162,20 @@ export async function filterClause (me, models, type) {
return clause
}
function typeClause (type) {
function recentClause (type) {
switch (type) {
case 'links':
return ' AND "Item".url IS NOT NULL AND "Item"."parentId" IS NULL'
return ' AND url IS NOT NULL'
case 'discussions':
return ' AND "Item".url IS NULL AND "Item".bio = false AND "Item"."pollCost" IS NULL AND "Item"."parentId" IS NULL'
return ' AND url IS NULL AND bio = false AND "pollCost" IS NULL'
case 'polls':
return ' AND "Item"."pollCost" IS NOT NULL AND "Item"."parentId" IS NULL'
return ' AND "pollCost" IS NOT NULL'
case 'bios':
return ' AND "Item".bio = true AND "Item"."parentId" IS NULL'
return ' AND bio = true'
case 'bounties':
return ' AND "Item".bounty IS NOT NULL AND "Item"."parentId" IS NULL'
case 'comments':
return ' AND "Item"."parentId" IS NOT NULL'
case 'freebies':
return ' AND "Item".freebie'
case 'outlawed':
return ` AND "Item"."weightedVotes" - "Item"."weightedDownVotes" <= -${ITEM_FILTER_THRESHOLD}`
case 'borderland':
return ' AND "Item"."weightedVotes" - "Item"."weightedDownVotes" < 0 '
case 'all':
case 'bookmarks':
return ''
case 'jobs':
return ' AND "Item"."subName" = \'jobs\''
return ' AND bounty IS NOT NULL'
default:
return ' AND "Item"."parentId" IS NULL'
return ''
}
}
@ -245,28 +218,6 @@ const subClause = (sub, num, table, solo) => {
return sub ? ` ${solo ? 'WHERE' : 'AND'} ${table ? `"${table}".` : ''}"subName" = $${num} ` : ''
}
const relationClause = (type) => {
switch (type) {
case 'comments':
return ' FROM "Item" JOIN "Item" root ON "Item"."rootId" = root.id '
case 'bookmarks':
return ' FROM "Item" JOIN "Bookmark" ON "Bookmark"."itemId" = "Item"."id" '
case 'outlawed':
case 'borderland':
case 'freebies':
case 'all':
return ' FROM "Item" LEFT JOIN "Item" root ON "Item"."rootId" = root.id '
default:
return ' FROM "Item" '
}
}
const subClauseTable = (type) => COMMENT_TYPE_QUERY.includes(type) ? 'root' : 'Item'
const activeOrMine = (me) => {
return me ? ` AND ("Item".status <> 'STOPPED' OR "Item"."userId" = ${me.id}) ` : ' AND "Item".status <> \'STOPPED\' '
}
export default {
Query: {
itemRepetition: async (parent, { parentId }, { me, models }) => {
@ -277,9 +228,62 @@ export default {
return count
},
items: async (parent, { sub, sort, type, cursor, name, when, by, limit = LIMIT }, { me, models }) => {
topItems: async (parent, { sub, cursor, sort, when }, { me, models }) => {
const decodedCursor = decodeCursor(cursor)
let items, user, pins, subFull, table
const subArr = sub ? [sub] : []
const items = await itemQueryWithMeta({
me,
models,
query: `
${SELECT}
FROM "Item"
WHERE "parentId" IS NULL AND "Item".created_at <= $1
AND "pinId" IS NULL AND "deletedAt" IS NULL
${subClause(sub, 3)}
${topClause(when)}
${await filterClause(me, models)}
${await topOrderClause(sort, me, models)}
OFFSET $2
LIMIT ${LIMIT}`,
orderBy: await topOrderClause(sort, me, models)
}, decodedCursor.time, decodedCursor.offset, ...subArr)
return {
cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
items
}
},
topComments: async (parent, { sub, cursor, sort, when }, { me, models }) => {
const decodedCursor = decodeCursor(cursor)
const subArr = sub ? [sub] : []
const comments = await itemQueryWithMeta({
me,
models,
query: `
${SELECT}
FROM "Item"
JOIN "Item" root ON "Item"."rootId" = root.id
WHERE "Item"."parentId" IS NOT NULL
AND "Item".created_at <= $1 AND "Item"."deletedAt" IS NULL
${subClause(sub, 3, 'root')}
${topClause(when)}
${await filterClause(me, models)}
${await topOrderClause(sort, me, models)}
OFFSET $2
LIMIT ${LIMIT}`,
orderBy: await topOrderClause(sort, me, models)
}, decodedCursor.time, decodedCursor.offset, ...subArr)
return {
cursor: comments.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
comments
}
},
items: async (parent, { sub, sort, type, cursor, name, within }, { me, models }) => {
const decodedCursor = decodeCursor(cursor)
let items; let user; let pins; let subFull
const activeOrMine = () => {
return me ? ` AND (status <> 'STOPPED' OR "userId" = ${me.id}) ` : ' AND status <> \'STOPPED\' '
}
// HACK we want to optionally include the subName in the query
// but the query planner doesn't like unused parameters
@ -288,32 +292,29 @@ export default {
switch (sort) {
case 'user':
if (!name) {
throw new GraphQLError('must supply name', { extensions: { code: 'BAD_INPUT' } })
throw new UserInputError('must supply name', { argumentName: 'name' })
}
user = await models.user.findUnique({ where: { name } })
if (!user) {
throw new GraphQLError('no user has that name', { extensions: { code: 'BAD_INPUT' } })
throw new UserInputError('no user has that name', { argumentName: 'name' })
}
table = type === 'bookmarks' ? 'Bookmark' : 'Item'
items = await itemQueryWithMeta({
me,
models,
query: `
${SELECT}
${relationClause(type)}
WHERE "${table}"."userId" = $2 AND "${table}".created_at <= $1
${subClause(sub, 5, subClauseTable(type))}
${activeOrMine(me)}
${await filterClause(me, models, type)}
${typeClause(type)}
${whenClause(when || 'forever', type)}
${await orderByClause(by, me, models, type)}
FROM "Item"
WHERE "userId" = $1 AND "parentId" IS NULL AND created_at <= $2
AND "pinId" IS NULL
${activeOrMine()}
${await filterClause(me, models)}
ORDER BY created_at DESC
OFFSET $3
LIMIT $4`,
orderBy: await orderByClause(by, me, models, type)
}, decodedCursor.time, user.id, decodedCursor.offset, limit, ...subArr)
LIMIT ${LIMIT}`,
orderBy: 'ORDER BY "Item"."createdAt" DESC'
}, user.id, decodedCursor.time, decodedCursor.offset)
break
case 'recent':
items = await itemQueryWithMeta({
@ -321,17 +322,17 @@ export default {
models,
query: `
${SELECT}
${relationClause(type)}
WHERE "Item".created_at <= $1
${subClause(sub, 4, subClauseTable(type))}
${activeOrMine(me)}
${await filterClause(me, models, type)}
${typeClause(type)}
ORDER BY "Item".created_at DESC
FROM "Item"
WHERE "parentId" IS NULL AND created_at <= $1
${subClause(sub, 3)}
${activeOrMine()}
${await filterClause(me, models)}
${recentClause(type)}
ORDER BY created_at DESC
OFFSET $2
LIMIT $3`,
LIMIT ${LIMIT}`,
orderBy: 'ORDER BY "Item"."createdAt" DESC'
}, decodedCursor.time, decodedCursor.offset, limit, ...subArr)
}, decodedCursor.time, decodedCursor.offset, ...subArr)
break
case 'top':
items = await itemQueryWithMeta({
@ -339,18 +340,16 @@ export default {
models,
query: `
${SELECT}
${relationClause(type)}
WHERE "Item".created_at <= $1
AND "Item"."pinId" IS NULL AND "Item"."deletedAt" IS NULL
${subClause(sub, 4, subClauseTable(type))}
${typeClause(type)}
${whenClause(when, type)}
${await filterClause(me, models, type)}
${await orderByClause(by || 'votes', me, models, type)}
FROM "Item"
WHERE "parentId" IS NULL AND "Item".created_at <= $1
AND "pinId" IS NULL AND "deletedAt" IS NULL
${topClause(within)}
${await filterClause(me, models)}
${await topOrderByWeightedSats(me, models)}
OFFSET $2
LIMIT $3`,
orderBy: await orderByClause(by || 'votes', me, models, type)
}, decodedCursor.time, decodedCursor.offset, limit, ...subArr)
LIMIT ${LIMIT}`,
orderBy: await topOrderByWeightedSats(me, models)
}, decodedCursor.time, decodedCursor.offset)
break
default:
// sub so we know the default ranking
@ -373,13 +372,13 @@ export default {
FROM "Item"
WHERE "parentId" IS NULL AND created_at <= $1
AND "pinId" IS NULL
${subClause(sub, 4)}
${subClause(sub, 3)}
AND status IN ('ACTIVE', 'NOSATS')
ORDER BY group_rank, rank
OFFSET $2
LIMIT $3`,
LIMIT ${LIMIT}`,
orderBy: 'ORDER BY group_rank, rank'
}, decodedCursor.time, decodedCursor.offset, limit, ...subArr)
}, decodedCursor.time, decodedCursor.offset, ...subArr)
break
default:
items = await itemQueryWithMeta({
@ -389,12 +388,12 @@ export default {
${SELECT}, rank
FROM "Item"
${await joinSatRankView(me, models)}
${subClause(sub, 3, 'Item', true)}
${subClause(sub, 2, 'Item', true)}
ORDER BY rank ASC
OFFSET $1
LIMIT $2`,
LIMIT ${LIMIT}`,
orderBy: 'ORDER BY rank ASC'
}, decodedCursor.offset, limit, ...subArr)
}, decodedCursor.offset, ...subArr)
if (decodedCursor.offset === 0) {
// get pins for the page and return those separately
@ -420,11 +419,230 @@ export default {
break
}
return {
cursor: items.length === limit ? nextCursorEncoded(decodedCursor) : null,
cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
items,
pins
}
},
allItems: async (parent, { cursor }, { me, models }) => {
const decodedCursor = decodeCursor(cursor)
const items = await itemQueryWithMeta({
me,
models,
query: `
${SELECT}
FROM "Item"
ORDER BY created_at DESC
OFFSET $1
LIMIT ${LIMIT}`,
orderBy: 'ORDER BY "Item"."createdAt" DESC'
}, decodedCursor.offset)
return {
cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
items
}
},
outlawedItems: async (parent, { cursor }, { me, models }) => {
const decodedCursor = decodeCursor(cursor)
const notMine = () => {
return me ? ` AND "userId" <> ${me.id} ` : ''
}
const items = await itemQueryWithMeta({
me,
models,
query: `
${SELECT}
FROM "Item"
WHERE "Item"."weightedVotes" - "Item"."weightedDownVotes" <= -${ITEM_FILTER_THRESHOLD}
${notMine()}
ORDER BY created_at DESC
OFFSET $1
LIMIT ${LIMIT}`,
orderBy: 'ORDER BY "Item"."createdAt" DESC'
}, decodedCursor.offset)
return {
cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
items
}
},
borderlandItems: async (parent, { cursor }, { me, models }) => {
const decodedCursor = decodeCursor(cursor)
const notMine = () => {
return me ? ` AND "userId" <> ${me.id} ` : ''
}
const items = await itemQueryWithMeta({
me,
models,
query: `
${SELECT}
FROM "Item"
WHERE "Item"."weightedVotes" - "Item"."weightedDownVotes" < 0
AND "Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD}
${notMine()}
ORDER BY created_at DESC
OFFSET $1
LIMIT ${LIMIT}`,
orderBy: 'ORDER BY "Item"."createdAt" DESC'
}, decodedCursor.offset)
return {
cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
items
}
},
freebieItems: async (parent, { cursor }, { me, models }) => {
const decodedCursor = decodeCursor(cursor)
const items = await itemQueryWithMeta({
me,
models,
query: `
${SELECT}
FROM "Item"
WHERE "Item".freebie
ORDER BY created_at DESC
OFFSET $1
LIMIT ${LIMIT}`,
orderBy: 'ORDER BY "Item"."createdAt" DESC'
}, decodedCursor.offset)
return {
cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
items
}
},
getBountiesByUserName: async (parent, { name, cursor, limit }, { me, models }) => {
const decodedCursor = decodeCursor(cursor)
const user = await models.user.findUnique({ where: { name } })
if (!user) {
throw new UserInputError('user not found', {
argumentName: 'name'
})
}
const items = await itemQueryWithMeta({
me,
models,
query: `
${SELECT}
FROM "Item"
WHERE "userId" = $1
AND "bounty" IS NOT NULL
ORDER BY created_at DESC
OFFSET $2
LIMIT $3`,
orderBy: 'ORDER BY "Item"."createdAt" DESC'
}, user.id, decodedCursor.offset, limit || LIMIT)
return {
cursor: items.length === (limit || LIMIT) ? nextCursorEncoded(decodedCursor) : null,
items
}
},
moreFlatComments: async (parent, { sub, cursor, name, sort, within }, { me, models }) => {
const decodedCursor = decodeCursor(cursor)
// HACK we want to optionally include the subName in the query
// but the query planner doesn't like unused parameters
const subArr = sub ? [sub] : []
let comments, user
switch (sort) {
case 'recent':
comments = await itemQueryWithMeta({
me,
models,
query: `
${SELECT}
FROM "Item"
JOIN "Item" root ON "Item"."rootId" = root.id
WHERE "Item"."parentId" IS NOT NULL AND "Item".created_at <= $1
${subClause(sub, 3, 'root')}
${await filterClause(me, models)}
ORDER BY "Item".created_at DESC
OFFSET $2
LIMIT ${LIMIT}`,
orderBy: 'ORDER BY "Item"."createdAt" DESC'
}, decodedCursor.time, decodedCursor.offset, ...subArr)
break
case 'user':
if (!name) {
throw new UserInputError('must supply name', { argumentName: 'name' })
}
user = await models.user.findUnique({ where: { name } })
if (!user) {
throw new UserInputError('no user has that name', { argumentName: 'name' })
}
comments = await itemQueryWithMeta({
me,
models,
query: `
${SELECT}
FROM "Item"
WHERE "userId" = $1 AND "parentId" IS NOT NULL
AND created_at <= $2
${await filterClause(me, models)}
ORDER BY created_at DESC
OFFSET $3
LIMIT ${LIMIT}`,
orderBy: 'ORDER BY "Item"."createdAt" DESC'
}, user.id, decodedCursor.time, decodedCursor.offset)
break
case 'top':
comments = await itemQueryWithMeta({
me,
models,
query: `
${SELECT}
FROM "Item"
WHERE "Item"."parentId" IS NOT NULL AND"Item"."deletedAt" IS NULL
AND "Item".created_at <= $1
${topClause(within)}
${await filterClause(me, models)}
${await topOrderByWeightedSats(me, models)}
OFFSET $2
LIMIT ${LIMIT}`,
orderBy: await topOrderByWeightedSats(me, models)
}, decodedCursor.time, decodedCursor.offset)
break
default:
throw new UserInputError('invalid sort type', { argumentName: 'sort' })
}
return {
cursor: comments.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
comments
}
},
moreBookmarks: async (parent, { cursor, name }, { me, models }) => {
const decodedCursor = decodeCursor(cursor)
const user = await models.user.findUnique({ where: { name } })
if (!user) {
throw new UserInputError('no user has that name', { argumentName: 'name' })
}
const items = await itemQueryWithMeta({
me,
models,
query: `
${SELECT}, "Bookmark".created_at as "bookmarkCreatedAt"
FROM "Item"
JOIN "Bookmark" ON "Bookmark"."itemId" = "Item"."id" AND "Bookmark"."userId" = $1
AND "Bookmark".created_at <= $2
ORDER BY "Bookmark".created_at DESC
OFFSET $3
LIMIT ${LIMIT}`,
orderBy: 'ORDER BY "bookmarkCreatedAt" DESC'
}, user.id, decodedCursor.time, decodedCursor.offset)
return {
cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
items
}
},
item: getItem,
pageTitleAndUnshorted: async (parent, { url }, { models }) => {
const res = {}
@ -540,7 +758,7 @@ export default {
deleteItem: async (parent, { id }, { me, models }) => {
const old = await models.item.findUnique({ where: { id: Number(id) } })
if (Number(old.userId) !== Number(me?.id)) {
throw new GraphQLError('item does not belong to you', { extensions: { code: 'FORBIDDEN' } })
throw new AuthenticationError('item does not belong to you')
}
const data = { deletedAt: new Date() }
@ -595,9 +813,9 @@ export default {
}
},
upsertPoll: async (parent, { id, ...data }, { me, models }) => {
const { forward, sub, boost, title, text, options } = data
const { sub, forward, boost, title, text, options } = data
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
throw new AuthenticationError('you must be logged in')
}
const optionCount = id
@ -614,14 +832,14 @@ export default {
if (forward) {
fwdUser = await models.user.findUnique({ where: { name: forward } })
if (!fwdUser) {
throw new GraphQLError('forward user does not exist', { extensions: { code: 'BAD_INPUT' } })
throw new UserInputError('forward user does not exist', { argumentName: 'forward' })
}
}
if (id) {
const old = await models.item.findUnique({ where: { id: Number(id) } })
if (Number(old.userId) !== Number(me?.id)) {
throw new GraphQLError('item does not belong to you', { extensions: { code: 'FORBIDDEN' } })
throw new AuthenticationError('item does not belong to you')
}
const [item] = await serialize(models,
models.$queryRaw(`${SELECT} FROM update_poll($1, $2, $3, $4, $5, $6, $7) AS "Item"`,
@ -642,13 +860,13 @@ export default {
},
upsertJob: async (parent, { id, ...data }, { me, models }) => {
if (!me) {
throw new GraphQLError('you must be logged in to create job', { extensions: { code: 'FORBIDDEN' } })
throw new AuthenticationError('you must be logged in to create job')
}
const { sub, title, company, location, remote, text, url, maxBid, status, logo } = data
const fullSub = await models.sub.findUnique({ where: { name: sub } })
if (!fullSub) {
throw new GraphQLError('not a valid sub', { extensions: { code: 'BAD_INPUT' } })
throw new UserInputError('not a valid sub', { argumentName: 'sub' })
}
await ssValidate(jobSchema, data, models)
@ -658,7 +876,7 @@ export default {
if (id) {
const old = await models.item.findUnique({ where: { id: Number(id) } })
if (Number(old.userId) !== Number(me?.id)) {
throw new GraphQLError('item does not belong to you', { extensions: { code: 'FORBIDDEN' } })
throw new AuthenticationError('item does not belong to you')
}
([item] = await serialize(models,
models.$queryRaw(
@ -701,7 +919,7 @@ export default {
},
pollVote: async (parent, { id }, { me, models }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
throw new AuthenticationError('you must be logged in')
}
await serialize(models,
@ -713,7 +931,7 @@ export default {
act: async (parent, { id, sats }, { me, models }) => {
// need to make sure we are logged in
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
throw new AuthenticationError('you must be logged in')
}
await ssValidate(amountSchema, { amount: sats })
@ -724,7 +942,7 @@ export default {
FROM "Item"
WHERE id = $1 AND "userId" = $2`, Number(id), me.id)
if (item) {
throw new GraphQLError('cannot zap your self', { extensions: { code: 'BAD_INPUT' } })
throw new UserInputError('cannot zap your self')
}
const [{ item_act: vote }] = await serialize(models, models.$queryRaw`SELECT item_act(${Number(id)}, ${me.id}, 'TIP', ${Number(sats)})`)
@ -746,7 +964,7 @@ export default {
dontLikeThis: async (parent, { id }, { me, models }) => {
// need to make sure we are logged in
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
throw new AuthenticationError('you must be logged in')
}
// disallow self down votes
@ -755,7 +973,7 @@ export default {
FROM "Item"
WHERE id = $1 AND "userId" = $2`, Number(id), me.id)
if (item) {
throw new GraphQLError('cannot downvote your self', { extensions: { code: 'BAD_INPUT' } })
throw new UserInputError('cannot downvote your self')
}
await serialize(models, models.$queryRaw`SELECT item_act(${Number(id)}, ${me.id}, 'DONT_LIKE_THIS', ${DONT_LIKE_THIS_COST})`)
@ -774,11 +992,11 @@ export default {
return item.subName === 'jobs'
},
sub: async (item, args, { models }) => {
if (!item.subName && !item.root) {
if (!item.subName) {
return null
}
return await models.sub.findUnique({ where: { name: item.subName || item.root?.subName } })
return await models.sub.findUnique({ where: { name: item.subName } })
},
position: async (item, args, { models }) => {
if (!item.pinId) {
@ -852,8 +1070,7 @@ export default {
if (item.comments) {
return item.comments
}
return comments(me, models, item.id, defaultCommentSort(item.pinId, item.bioId, item.createdAt))
return comments(me, models, item.id, item.pinId ? 'recent' : 'hot')
},
wvotes: async (item) => {
return item.weightedVotes - item.weightedDownVotes
@ -1009,28 +1226,28 @@ export const updateItem = async (parent, { id, data: { sub, title, url, text, bo
// update iff this item belongs to me
const old = await models.item.findUnique({ where: { id: Number(id) } })
if (Number(old.userId) !== Number(me?.id)) {
throw new GraphQLError('item does not belong to you', { extensions: { code: 'FORBIDDEN' } })
throw new AuthenticationError('item does not belong to you')
}
// if it's not the FAQ, not their bio, and older than 10 minutes
const user = await models.user.findUnique({ where: { id: me.id } })
if (![349, 76894, 78763, 81862].includes(old.id) && user.bioId !== id && Date.now() > new Date(old.createdAt).getTime() + 10 * 60000) {
throw new GraphQLError('item can no longer be editted', { extensions: { code: 'BAD_INPUT' } })
throw new UserInputError('item can no longer be editted')
}
if (boost && boost < BOOST_MIN) {
throw new GraphQLError(`boost must be at least ${BOOST_MIN}`, { extensions: { code: 'BAD_INPUT' } })
throw new UserInputError(`boost must be at least ${BOOST_MIN}`, { argumentName: 'boost' })
}
if (!old.parentId && title.length > MAX_TITLE_LENGTH) {
throw new GraphQLError('title too long', { extensions: { code: 'BAD_INPUT' } })
throw new UserInputError('title too long')
}
let fwdUser
if (forward) {
fwdUser = await models.user.findUnique({ where: { name: forward } })
if (!fwdUser) {
throw new GraphQLError('forward user does not exist', { extensions: { code: 'BAD_INPUT' } })
throw new UserInputError('forward user does not exist', { argumentName: 'forward' })
}
}
@ -1050,22 +1267,22 @@ export const updateItem = async (parent, { id, data: { sub, title, url, text, bo
const createItem = async (parent, { sub, title, url, text, boost, forward, bounty, parentId }, { me, models }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
throw new AuthenticationError('you must be logged in')
}
if (boost && boost < BOOST_MIN) {
throw new GraphQLError(`boost must be at least ${BOOST_MIN}`, { extensions: { code: 'BAD_INPUT' } })
throw new UserInputError(`boost must be at least ${BOOST_MIN}`, { argumentName: 'boost' })
}
if (!parentId && title.length > MAX_TITLE_LENGTH) {
throw new GraphQLError('title too long', { extensions: { code: 'BAD_INPUT' } })
throw new UserInputError('title too long')
}
let fwdUser
if (forward) {
fwdUser = await models.user.findUnique({ where: { name: forward } })
if (!fwdUser) {
throw new GraphQLError('forward user does not exist', { extensions: { code: 'BAD_INPUT' } })
throw new UserInputError('forward user does not exist', { argumentName: 'forward' })
}
}

View File

@ -1,6 +1,6 @@
import { randomBytes } from 'crypto'
import { bech32 } from 'bech32'
import { GraphQLError } from 'graphql'
import { AuthenticationError } from 'apollo-server-micro'
function encodedUrl (iurl, tag, k1) {
const url = new URL(iurl)
@ -30,7 +30,7 @@ export default {
},
createWith: async (parent, args, { me, models }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
throw new AuthenticationError('you must be logged in')
}
return await models.lnWith.create({ data: { k1: k1(), userId: me.id } })

View File

@ -1,4 +1,4 @@
import { GraphQLError } from 'graphql'
import { UserInputError } from 'apollo-server-micro'
export default {
Query: {
@ -11,7 +11,7 @@ export default {
Mutation: {
createMessage: async (parent, { text }, { me, models }) => {
if (!text) {
throw new GraphQLError('Must have text', { extensions: { code: 'BAD_INPUT' } })
throw new UserInputError('Must have text', { argumentName: 'text' })
}
return await models.message.create({

View File

@ -1,4 +1,4 @@
import { GraphQLError } from 'graphql'
import { AuthenticationError, UserInputError } from 'apollo-server-micro'
import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
import { getItem, filterClause } from './item'
import { getInvoice } from './wallet'
@ -10,7 +10,7 @@ export default {
notifications: async (parent, { cursor, inc }, { me, models }) => {
const decodedCursor = decodeCursor(cursor)
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
throw new AuthenticationError('you must be logged in')
}
const meFull = await models.user.findUnique({ where: { id: me.id } })
@ -228,7 +228,7 @@ export default {
Mutation: {
savePushSubscription: async (parent, { endpoint, p256dh, auth, oldEndpoint }, { me, models }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
throw new AuthenticationError('you must be logged in')
}
await ssValidate(pushSubscriptionSchema, { endpoint, p256dh, auth })
@ -250,12 +250,14 @@ export default {
},
deletePushSubscription: async (parent, { endpoint }, { me, models }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
throw new AuthenticationError('you must be logged in')
}
const subscription = await models.pushSubscription.findFirst({ where: { endpoint, userId: Number(me.id) } })
if (!subscription) {
throw new GraphQLError('endpoint not found', { extensions: { code: 'BAD_INPUT' } })
throw new UserInputError('endpoint not found', {
argumentName: 'endpoint'
})
}
await models.pushSubscription.delete({ where: { id: subscription.id } })
return subscription

View File

@ -1,11 +1,11 @@
import { GraphQLError } from 'graphql'
import { AuthenticationError } from 'apollo-server-micro'
import { withClause, intervalClause, timeUnit } from './growth'
export default {
Query: {
referrals: async (parent, { when }, { models, me }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
throw new AuthenticationError('you must be logged in')
}
const [{ totalSats }] = await models.$queryRaw(`

View File

@ -1,4 +1,4 @@
import { GraphQLError } from 'graphql'
import { AuthenticationError } from 'apollo-server-micro'
import { amountSchema, ssValidate } from '../../lib/validate'
import serialize from './serial'
@ -36,7 +36,7 @@ export default {
Mutation: {
donateToRewards: async (parent, { sats }, { me, models }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
throw new AuthenticationError('you must be logged in')
}
await ssValidate(amountSchema, { amount: sats })

View File

@ -79,7 +79,7 @@ export default {
items
}
},
search: async (parent, { q: query, sub, cursor, sort, what, when }, { me, models, search }) => {
search: async (parent, { q: query, cursor, sort, what, when }, { me, models, search }) => {
const decodedCursor = decodeCursor(cursor)
let sitems
@ -105,7 +105,8 @@ export default {
const queryArr = query.trim().split(/\s+/)
const url = queryArr.find(word => word.startsWith('url:'))
const nym = queryArr.find(word => word.startsWith('nym:'))
const exclude = [url, nym]
const sub = queryArr.find(word => word.startsWith('~'))
const exclude = [url, nym, sub]
query = queryArr.filter(word => !exclude.includes(word)).join(' ')
if (url) {
@ -117,7 +118,7 @@ export default {
}
if (sub) {
whatArr.push({ match: { 'sub.name': sub } })
whatArr.push({ match: { 'sub.name': sub.slice(1).toLowerCase() } })
}
const sortArr = []
@ -246,7 +247,7 @@ export default {
highlight: {
fields: {
title: { number_of_fragments: 0, pre_tags: [':high['], post_tags: [']'] },
text: { number_of_fragments: 5, order: 'score', pre_tags: [':high['], post_tags: [']'] }
text: { number_of_fragments: 0, pre_tags: [':high['], post_tags: [']'] }
}
}
}
@ -265,7 +266,7 @@ export default {
const item = await getItem(parent, { id: e._source.id }, { me, models })
item.searchTitle = (e.highlight?.title && e.highlight.title[0]) || item.title
item.searchText = (e.highlight?.text && e.highlight.text.join(' `...` ')) || undefined
item.searchText = (e.highlight?.text && e.highlight.text[0]) || item.text
return item
})

View File

@ -1,4 +1,4 @@
const { GraphQLError } = require('graphql')
const { UserInputError } = require('apollo-server-micro')
const retry = require('async-retry')
async function serialize (models, call) {
@ -12,7 +12,7 @@ async function serialize (models, call) {
} catch (error) {
console.log(error)
if (error.message.includes('SN_INSUFFICIENT_FUNDS')) {
bail(new GraphQLError('insufficient funds', { extensions: { code: 'BAD_INPUT' } }))
bail(new UserInputError('insufficient funds'))
}
if (error.message.includes('SN_NOT_SERIALIZABLE')) {
bail(new Error('wallet balance transaction is not serializable'))

View File

@ -1,8 +1,6 @@
export default {
Query: {
sub: async (parent, { name }, { models, me }) => {
if (!name) return null
if (me && name === 'jobs') {
models.user.update({
where: {

View File

@ -1,4 +1,4 @@
import { GraphQLError } from 'graphql'
import { AuthenticationError, UserInputError } from 'apollo-server-micro'
import AWS from 'aws-sdk'
import { IMAGE_PIXELS_MAX, UPLOAD_SIZE_MAX, UPLOAD_TYPES_ALLOW } from '../../lib/constants'
@ -12,19 +12,19 @@ export default {
Mutation: {
getSignedPOST: async (parent, { type, size, width, height }, { models, me }) => {
if (!me) {
throw new GraphQLError('you must be logged in to get a signed url', { extensions: { code: 'FORBIDDEN' } })
throw new AuthenticationError('you must be logged in to get a signed url')
}
if (UPLOAD_TYPES_ALLOW.indexOf(type) === -1) {
throw new GraphQLError(`image must be ${UPLOAD_TYPES_ALLOW.map(t => t.replace('image/', '')).join(', ')}`, { extensions: { code: 'BAD_INPUT' } })
throw new UserInputError(`image must be ${UPLOAD_TYPES_ALLOW.map(t => t.replace('image/', '')).join(', ')}`)
}
if (size > UPLOAD_SIZE_MAX) {
throw new GraphQLError(`image must be less than ${UPLOAD_SIZE_MAX} bytes`, { extensions: { code: 'BAD_INPUT' } })
throw new UserInputError(`image must be less than ${UPLOAD_SIZE_MAX} bytes`)
}
if (width * height > IMAGE_PIXELS_MAX) {
throw new GraphQLError(`image must be less than ${IMAGE_PIXELS_MAX} pixels`, { extensions: { code: 'BAD_INPUT' } })
throw new UserInputError(`image must be less than ${IMAGE_PIXELS_MAX} pixels`)
}
// create upload record

View File

@ -1,10 +1,9 @@
import { GraphQLError } from 'graphql'
import { AuthenticationError, UserInputError } from 'apollo-server-errors'
import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
import { msatsToSats } from '../../lib/format'
import { bioSchema, emailSchema, settingsSchema, ssValidate, userSchema } from '../../lib/validate'
import { createMentions, getItem, SELECT, updateItem, filterClause } from './item'
import serialize from './serial'
import { dayPivot } from '../../lib/time'
export function within (table, within) {
let interval = ' AND "' + table + '".created_at >= $1 - INTERVAL '
@ -54,13 +53,13 @@ export function viewWithin (table, within) {
export function withinDate (within) {
switch (within) {
case 'day':
return dayPivot(new Date(), -1)
return new Date(new Date().setDate(new Date().getDate() - 1))
case 'week':
return dayPivot(new Date(), -7)
return new Date(new Date().setDate(new Date().getDate() - 7))
case 'month':
return dayPivot(new Date(), -30)
return new Date(new Date().setDate(new Date().getDate() - 30))
case 'year':
return dayPivot(new Date(), -365)
return new Date(new Date().setDate(new Date().getDate() - 365))
default:
return new Date(0)
}
@ -98,7 +97,7 @@ export default {
},
settings: async (parent, args, { models, me }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
throw new AuthenticationError('you must be logged in')
}
return await models.user.findUnique({ where: { id: me.id } })
@ -110,7 +109,7 @@ export default {
await models.user.findMany(),
nameAvailable: async (parent, { name }, { models, me }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
throw new AuthenticationError('you must be logged in')
}
const user = await models.user.findUnique({ where: { id: me.id } })
@ -121,7 +120,7 @@ export default {
const decodedCursor = decodeCursor(cursor)
const users = await models.$queryRaw(`
SELECT users.*, floor(sum(msats_spent)/1000) as spent,
sum(posts) as nposts, sum(comments) as ncomments, sum(referrals) as referrals,
sum(posts) as nitems, sum(comments) as ncomments, sum(referrals) as referrals,
floor(sum(msats_stacked)/1000) as stacked
FROM users
LEFT JOIN user_stats_days on users.id = user_stats_days.id
@ -135,15 +134,15 @@ export default {
users
}
},
topUsers: async (parent, { cursor, when, by }, { models, me }) => {
topUsers: async (parent, { cursor, when, sort }, { models, me }) => {
const decodedCursor = decodeCursor(cursor)
let users
if (when !== 'day') {
let column
switch (by) {
switch (sort) {
case 'spent': column = 'spent'; break
case 'posts': column = 'nposts'; break
case 'posts': column = 'nitems'; break
case 'comments': column = 'ncomments'; break
case 'referrals': column = 'referrals'; break
default: column = 'stacked'; break
@ -152,7 +151,7 @@ export default {
users = await models.$queryRaw(`
WITH u AS (
SELECT users.*, floor(sum(msats_spent)/1000) as spent,
sum(posts) as nposts, sum(comments) as ncomments, sum(referrals) as referrals,
sum(posts) as nitems, sum(comments) as ncomments, sum(referrals) as referrals,
floor(sum(msats_stacked)/1000) as stacked
FROM user_stats_days
JOIN users on users.id = user_stats_days.id
@ -171,7 +170,7 @@ export default {
}
}
if (by === 'spent') {
if (sort === 'spent') {
users = await models.$queryRaw(`
SELECT users.*, sum(sats_spent) as spent
FROM
@ -191,19 +190,19 @@ export default {
ORDER BY spent DESC NULLS LAST, users.created_at DESC
OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
} else if (by === 'posts') {
} else if (sort === 'posts') {
users = await models.$queryRaw(`
SELECT users.*, count(*) as nposts
SELECT users.*, count(*) as nitems
FROM users
JOIN "Item" on "Item"."userId" = users.id
WHERE "Item".created_at <= $1 AND "Item"."parentId" IS NULL
AND NOT users."hideFromTopUsers"
${within('Item', when)}
GROUP BY users.id
ORDER BY nposts DESC NULLS LAST, users.created_at DESC
ORDER BY nitems DESC NULLS LAST, users.created_at DESC
OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
} else if (by === 'comments') {
} else if (sort === 'comments') {
users = await models.$queryRaw(`
SELECT users.*, count(*) as ncomments
FROM users
@ -215,7 +214,7 @@ export default {
ORDER BY ncomments DESC NULLS LAST, users.created_at DESC
OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
} else if (by === 'referrals') {
} else if (sort === 'referrals') {
users = await models.$queryRaw(`
SELECT users.*, count(*) as referrals
FROM users
@ -428,24 +427,23 @@ export default {
Mutation: {
setName: async (parent, data, { me, models }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
throw new AuthenticationError('you must be logged in')
}
await ssValidate(userSchema, data, models)
try {
await models.user.update({ where: { id: me.id }, data })
return data.name
} catch (error) {
if (error.code === 'P2002') {
throw new GraphQLError('name taken', { extensions: { code: 'BAD_INPUT' } })
throw new UserInputError('name taken')
}
throw error
}
},
setSettings: async (parent, { nostrRelays, ...data }, { me, models }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
throw new AuthenticationError('you must be logged in')
}
await ssValidate(settingsSchema, { nostrRelays, ...data })
@ -471,7 +469,7 @@ export default {
},
setWalkthrough: async (parent, { upvotePopover, tipPopover }, { me, models }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
throw new AuthenticationError('you must be logged in')
}
await models.user.update({ where: { id: me.id }, data: { upvotePopover, tipPopover } })
@ -480,7 +478,7 @@ export default {
},
setPhoto: async (parent, { photoId }, { me, models }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
throw new AuthenticationError('you must be logged in')
}
await models.user.update({
@ -492,7 +490,7 @@ export default {
},
upsertBio: async (parent, { bio }, { me, models }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
throw new AuthenticationError('you must be logged in')
}
await ssValidate(bioSchema, { bio })
@ -512,7 +510,7 @@ export default {
},
unlinkAuth: async (parent, { authType }, { models, me }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
throw new AuthenticationError('you must be logged in')
}
let user
@ -520,7 +518,7 @@ export default {
user = await models.user.findUnique({ where: { id: me.id } })
const account = await models.account.findFirst({ where: { userId: me.id, providerId: authType } })
if (!account) {
throw new GraphQLError('no such account', { extensions: { code: 'BAD_INPUT' } })
throw new UserInputError('no such account')
}
await models.account.delete({ where: { id: account.id } })
} else if (authType === 'lightning') {
@ -530,14 +528,14 @@ export default {
} else if (authType === 'email') {
user = await models.user.update({ where: { id: me.id }, data: { email: null, emailVerified: null } })
} else {
throw new GraphQLError('no such account', { extensions: { code: 'BAD_INPUT' } })
throw new UserInputError('no such account')
}
return await authMethods(user, undefined, { models, me })
},
linkUnverifiedEmail: async (parent, { email }, { models, me }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
throw new AuthenticationError('you must be logged in')
}
await ssValidate(emailSchema, { email })
@ -549,7 +547,7 @@ export default {
})
} catch (error) {
if (error.code === 'P2002') {
throw new GraphQLError('email taken', { extensions: { code: 'BAD_INPUT' } })
throw new UserInputError('email taken')
}
throw error
}
@ -583,20 +581,6 @@ export default {
return user.nitems
}
return await models.item.count({
where: {
userId: user.id,
createdAt: {
gte: withinDate(when)
}
}
})
},
nposts: async (user, { when }, { models }) => {
if (typeof user.nposts === 'number') {
return user.nposts
}
return await models.item.count({
where: {
userId: user.id,

View File

@ -1,5 +1,5 @@
import { createInvoice, decodePaymentRequest, payViaPaymentRequest } from 'ln-service'
import { GraphQLError } from 'graphql'
import { UserInputError, AuthenticationError } from 'apollo-server-micro'
import serialize from './serial'
import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
import lnpr from 'bolt11'
@ -10,7 +10,7 @@ import { amountSchema, lnAddrSchema, ssValidate, withdrawlSchema } from '../../l
export async function getInvoice (parent, { id }, { me, models }) {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
throw new AuthenticationError('you must be logged in')
}
const inv = await models.invoice.findUnique({
@ -23,7 +23,7 @@ export async function getInvoice (parent, { id }, { me, models }) {
})
if (inv.user.id !== me.id) {
throw new GraphQLError('not ur invoice', { extensions: { code: 'FORBIDDEN' } })
throw new AuthenticationError('not ur invoice')
}
return inv
@ -34,7 +34,7 @@ export default {
invoice: getInvoice,
withdrawl: async (parent, { id }, { me, models, lnd }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
throw new AuthenticationError('you must be logged in')
}
const wdrwl = await models.withdrawl.findUnique({
@ -47,7 +47,7 @@ export default {
})
if (wdrwl.user.id !== me.id) {
throw new GraphQLError('not ur withdrawal', { extensions: { code: 'FORBIDDEN' } })
throw new AuthenticationError('not ur withdrawal')
}
return wdrwl
@ -58,7 +58,7 @@ export default {
walletHistory: async (parent, { cursor, inc }, { me, models, lnd }) => {
const decodedCursor = decodeCursor(cursor)
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
throw new AuthenticationError('you must be logged in')
}
const include = new Set(inc?.split(','))
@ -191,7 +191,7 @@ export default {
Mutation: {
createInvoice: async (parent, { amount }, { me, models, lnd }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
throw new AuthenticationError('you must be logged in')
}
await ssValidate(amountSchema, { amount })
@ -239,7 +239,9 @@ export default {
const milliamount = amount * 1000
// check that amount is within min and max sendable
if (milliamount < res1.minSendable || milliamount > res1.maxSendable) {
throw new GraphQLError(`amount must be >= ${res1.minSendable / 1000} and <= ${res1.maxSendable / 1000}`, { extensions: { code: 'BAD_INPUT' } })
throw new UserInputError(
`amount must be >= ${res1.minSendable / 1000} and <= ${res1.maxSendable / 1000}`,
{ argumentName: 'amount' })
}
const callback = new URL(res1.callback)
@ -309,11 +311,11 @@ async function createWithdrawal (parent, { invoice, maxFee }, { me, models, lnd
decoded = await decodePaymentRequest({ lnd, request: invoice })
} catch (error) {
console.log(error)
throw new GraphQLError('could not decode invoice', { extensions: { code: 'BAD_INPUT' } })
throw new UserInputError('could not decode invoice')
}
if (!decoded.mtokens || BigInt(decoded.mtokens) <= 0) {
throw new GraphQLError('your invoice must specify an amount', { extensions: { code: 'BAD_INPUT' } })
throw new UserInputError('your invoice must specify an amount')
}
const msatsFee = Number(maxFee) * 1000

View File

@ -31,38 +31,16 @@ export default async function getSSRApolloClient (req, me = null) {
slashtags
}
}),
cache: new InMemoryCache({
freezeResults: true
}),
assumeImmutableResults: true,
defaultOptions: {
watchQuery: {
fetchPolicy: 'cache-only',
nextFetchPolicy: 'cache-only',
canonizeResults: true,
ssr: true
},
query: {
fetchPolicy: 'cache-first',
nextFetchPolicy: 'cache-only',
canonizeResults: true,
ssr: true
}
}
cache: new InMemoryCache()
})
await client.clearStore()
return client
}
export function getGetServerSideProps (queryOrFunc, variablesOrFunc = null, notFoundFunc, requireVar) {
export function getGetServerSideProps (query, variables = null, notFoundFunc, requireVar) {
return async function ({ req, query: params }) {
const { nodata, ...realParams } = params
// we want to use client-side cache
if (nodata) return { props: { } }
const variables = typeof variablesOrFunc === 'function' ? variablesOrFunc(realParams) : variablesOrFunc
const vars = { ...realParams, ...variables }
const query = typeof queryOrFunc === 'function' ? queryOrFunc(vars) : queryOrFunc
const client = await getSSRApolloClient(req)
const { data: { me } } = await client.query({
@ -74,6 +52,20 @@ export function getGetServerSideProps (queryOrFunc, variablesOrFunc = null, notF
query: PRICE, variables: { fiatCurrency: me?.fiatCurrency }
})
// we want to use client-side cache
if (nodata && query) {
return {
props: {
me,
price,
apollo: {
query: print(query),
variables: vars
}
}
}
}
if (requireVar && !vars[requireVar]) {
return {
notFound: true
@ -99,7 +91,7 @@ export function getGetServerSideProps (queryOrFunc, variablesOrFunc = null, notF
throw err
}
if (error || !data || (notFoundFunc && notFoundFunc(data, vars))) {
if (error || !data || (notFoundFunc && notFoundFunc(data))) {
return {
notFound: true
}
@ -118,7 +110,7 @@ export function getGetServerSideProps (queryOrFunc, variablesOrFunc = null, notF
...props,
me,
price,
ssrData: data
data
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,16 +1,25 @@
import { gql } from 'graphql-tag'
import { gql } from 'apollo-server-micro'
export default gql`
extend type Query {
items(sub: String, sort: String, type: String, cursor: String, name: String, when: String, by: String, limit: Int): Items
items(sub: String, sort: String, type: String, cursor: String, name: String, within: String): Items
moreFlatComments(sub: String, sort: String!, cursor: String, name: String, within: String): Comments
moreBookmarks(cursor: String, name: String!): Items
item(id: ID!): Item
comments(id: ID!, sort: String): [Item!]!
pageTitleAndUnshorted(url: String!): TitleUnshorted
dupes(url: String!): [Item!]
related(cursor: String, title: String, id: ID, minMatch: String, limit: Int): Items
search(q: String, sub: String, cursor: String, what: String, sort: String, when: String): Items
allItems(cursor: String): Items
getBountiesByUserName(name: String!, cursor: String, , limit: Int): Items
search(q: String, cursor: String, what: String, sort: String, when: String): Items
auctionPosition(sub: String, id: ID, bid: Int!): Int!
itemRepetition(parentId: ID): Int!
outlawedItems(cursor: String): Items
borderlandItems(cursor: String): Items
freebieItems(cursor: String): Items
topItems(cursor: String, sub: String, sort: String, when: String): Items
topComments(cursor: String, sub: String, sort: String, when: String): Comments
}
type TitleUnshorted {

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import { gql } from 'graphql-tag'
import { gql } from 'apollo-server-micro'
export default gql`
extend type Query {
@ -11,39 +11,33 @@ export default gql`
}
type Votification {
id: ID!
earnedSats: Int!
item: Item!
sortTime: String!
}
type Reply {
id: ID!
item: Item!
sortTime: String!
}
type Mention {
id: ID!
mention: Boolean!
item: Item!
sortTime: String!
}
type Invitification {
id: ID!
invite: Invite!
sortTime: String!
}
type JobChanged {
id: ID!
item: Item!
sortTime: String!
}
type EarnSources {
id: ID!
posts: Int!
comments: Int!
tipPosts: Int!
@ -51,27 +45,24 @@ export default gql`
}
type Streak {
id: ID!
sortTime: String!
days: Int
id: ID!
}
type Earn {
id: ID!
earnedSats: Int!
sortTime: String!
sources: EarnSources
}
type InvoicePaid {
id: ID!
earnedSats: Int!
invoice: Invoice!
sortTime: String!
}
type Referral {
id: ID!
sortTime: String!
}

View File

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

View File

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

View File

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

View File

@ -1,8 +1,8 @@
import { gql } from 'graphql-tag'
import { gql } from 'apollo-server-micro'
export default gql`
extend type Query {
sub(name: String): Sub
sub(name: String!): Sub
subLatestPost(name: String!): String
}

View File

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

View File

@ -1,4 +1,4 @@
import { gql } from 'graphql-tag'
import { gql } from 'apollo-server-micro'
export default gql`
extend type Query {
@ -7,7 +7,7 @@ export default gql`
user(name: String!): User
users: [User!]
nameAvailable(name: String!): Boolean!
topUsers(cursor: String, when: String, by: String): Users
topUsers(cursor: String, when: String, sort: String): Users
topCowboys(cursor: String): Users
searchUsers(q: String!, limit: Int, similarity: Float): [User!]!
hasNewNotes: Boolean!
@ -19,7 +19,7 @@ export default gql`
}
extend type Mutation {
setName(name: String!): String
setName(name: String!): Boolean
setSettings(tipDefault: Int!, turboTipping: Boolean!, fiatCurrency: String!, noteItemSats: Boolean!,
noteEarning: Boolean!, noteAllDescendants: Boolean!, noteMentions: Boolean!, noteDeposits: Boolean!,
noteInvites: Boolean!, noteJobIndicator: Boolean!, noteCowboyHat: Boolean!, hideInvoiceDesc: Boolean!,
@ -45,7 +45,6 @@ export default gql`
createdAt: String!
name: String
nitems(when: String): Int!
nposts(when: String): Int!
ncomments(when: String): Int!
nbookmarks(when: String): Int!
stacked(when: String): Int!

View File

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

View File

@ -1,72 +1,74 @@
import { useRef, useState } from 'react'
import AvatarEditor from 'react-avatar-editor'
import { Button, Form as BootstrapForm } from 'react-bootstrap'
import { Button, Modal, Form as BootstrapForm } from 'react-bootstrap'
import Upload from './upload'
import EditImage from '../svgs/image-edit-fill.svg'
import Moon from '../svgs/moon-fill.svg'
import { useShowModal } from './modal'
export default function Avatar ({ onSuccess }) {
const [uploading, setUploading] = useState()
const [editProps, setEditProps] = useState()
const ref = useRef()
const [scale, setScale] = useState(1)
const showModal = useShowModal()
const Body = ({ onClose, file, upload }) => {
return (
<div className='text-right mt-1 p-4'>
<AvatarEditor
ref={ref} width={200} height={200}
image={file}
scale={scale}
style={{
width: '100%',
height: 'auto'
}}
/>
<BootstrapForm.Group controlId='formBasicRange'>
<BootstrapForm.Control
type='range' onChange={e => setScale(parseFloat(e.target.value))}
min={1} max={2} step='0.05'
defaultValue={scale} custom
/>
</BootstrapForm.Group>
<Button onClick={() => {
ref.current.getImageScaledToCanvas().toBlob(blob => {
if (blob) {
upload(blob)
onClose()
}
}, 'image/jpeg')
}}
>save
</Button>
</div>
)
}
return (
<Upload
as={({ onClick }) =>
<div className='position-absolute p-1 bg-dark pointer' onClick={onClick} style={{ bottom: '0', right: '0' }}>
{uploading
? <Moon className='fill-white spin' />
: <EditImage className='fill-white' />}
</div>}
onError={e => {
console.log(e)
setUploading(false)
}}
onSelect={(file, upload) => {
showModal(onClose => <Body onClose={onClose} file={file} upload={upload} />)
}}
onSuccess={async key => {
onSuccess && onSuccess(key)
setUploading(false)
}}
onStarted={() => {
setUploading(true)
}}
/>
<>
<Modal
show={!!editProps}
onHide={() => setEditProps(null)}
>
<div className='modal-close' onClick={() => setEditProps(null)}>X</div>
<Modal.Body className='text-right mt-1 p-4'>
<AvatarEditor
ref={ref} width={200} height={200}
image={editProps?.file}
scale={scale}
style={{
width: '100%',
height: 'auto'
}}
/>
<BootstrapForm.Group controlId='formBasicRange'>
<BootstrapForm.Control
type='range' onChange={e => setScale(parseFloat(e.target.value))}
min={1} max={2} step='0.05'
defaultValue={scale} custom
/>
</BootstrapForm.Group>
<Button onClick={() => {
ref.current.getImageScaledToCanvas().toBlob(blob => {
if (blob) {
editProps.upload(blob)
setEditProps(null)
}
}, 'image/jpeg')
}}
>save
</Button>
</Modal.Body>
</Modal>
<Upload
as={({ onClick }) =>
<div className='position-absolute p-1 bg-dark pointer' onClick={onClick} style={{ bottom: '0', right: '0' }}>
{uploading
? <Moon className='fill-white spin' />
: <EditImage className='fill-white' />}
</div>}
onError={e => {
console.log(e)
setUploading(false)
}}
onSelect={(file, upload) => {
setEditProps({ file, upload })
}}
onSuccess={async key => {
onSuccess && onSuccess(key)
setUploading(false)
}}
onStarted={() => {
setUploading(true)
}}
/>
</>
)
}

View File

@ -1,5 +1,5 @@
import { useMutation } from '@apollo/client'
import { gql } from 'graphql-tag'
import { gql } from 'apollo-server-micro'
import { Dropdown } from 'react-bootstrap'
export default function BookmarkDropdownItem ({ item: { id, meBookmark } }) {

View File

@ -1,6 +1,7 @@
import { Form, Input, MarkdownInput, SubmitButton } from '../components/form'
import { useRouter } from 'next/router'
import { gql, useApolloClient, useMutation } from '@apollo/client'
import TextareaAutosize from 'react-textarea-autosize'
import Countdown from './countdown'
import AdvPostForm, { AdvPostInitial } from './adv-post-form'
import FeeButton, { EditFeeButton } from './fee-button'
@ -99,6 +100,7 @@ export function BountyForm ({
</>
}
name='text'
as={TextareaAutosize}
minRows={6}
hint={
editThreshold

View File

@ -1,6 +1,7 @@
import { Form, MarkdownInput, SubmitButton } from '../components/form'
import { gql, useMutation } from '@apollo/client'
import styles from './reply.module.css'
import TextareaAutosize from 'react-textarea-autosize'
import { EditFeeButton } from './fee-button'
import { Button } from 'react-bootstrap'
import Delete from './delete'
@ -46,6 +47,7 @@ export default function CommentEdit ({ comment, editThreshold, onSuccess, onCanc
>
<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, useMemo, useRef, useState } from 'react'
import { useEffect, 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}`} className='text-reset'>
parent
<Link href={`/items/${item.parentId}`} passHref>
<a className='text-reset'>parent</a>
</Link>
</>
)
@ -38,12 +38,12 @@ function Parent ({ item, rootText }) {
<>
{Number(root.id) !== Number(item.parentId) && <ParentFrag />}
<span> \ </span>
<Link href={`/items/${root.id}`} className='text-reset'>
{rootText || 'on:'} {root?.title}
<Link href={`/items/${root.id}`} passHref>
<a className='text-reset'>{rootText || 'on:'} {root?.title}</a>
</Link>
{root.subName &&
<Link href={`/~${root.subName}`}>
{' '}<Badge className={itemStyles.newComment} variant={null}>{root.subName}</Badge>
<a>{' '}<Badge className={itemStyles.newComment} variant={null}>{root.subName}</Badge></a>
</Link>}
</>
)
@ -54,42 +54,32 @@ const truncateString = (string = '', maxLength = 140) =>
? `${string.substring(0, maxLength)} […]`
: string
export function CommentFlat ({ item, rank, ...props }) {
export function CommentFlat ({ item, ...props }) {
const router = useRouter()
const [href, as] = useMemo(() => {
if (item.path.split('.').length > COMMENT_DEPTH_LIMIT + 1) {
return [{
pathname: '/items/[id]',
query: { id: item.parentId, commentId: item.id }
}, `/items/${item.parentId}`]
} else {
return [{
pathname: '/items/[id]',
query: { id: item.root.id, commentId: item.id }
}, `/items/${item.root.id}`]
}
}, [item?.id])
return (
<>
{rank
? (
<div className={`${itemStyles.rank} pt-2 align-self-start`}>
{rank}
</div>)
: <div />}
<div
className='clickToContext py-2'
onClick={e => {
if (ignoreClick(e)) return
router.push(href, as)
}}
>
<RootProvider root={item.root}>
<Comment item={item} {...props} />
</RootProvider>
</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}`)
}
}}
>
<RootProvider root={item.root}>
<Comment item={item} {...props} />
</RootProvider>
</div>
)
}
@ -193,26 +183,24 @@ export default function Comment ({
)}
</div>
</div>
{collapse !== 'yep' && (
bottomedOut
? <DepthLimit item={item} />
: (
<div className={`${styles.children}`}>
{!noReply &&
<Reply depth={depth + 1} item={item} replyOpen={replyOpen}>
{root.bounty && !bountyPaid && <PayBounty item={item} />}
</Reply>}
{children}
<div className={`${styles.comments} ml-sm-1 ml-md-3`}>
{item.comments && !noComments
? item.comments.map((item) => (
<Comment depth={depth + 1} key={item.id} item={item} />
))
: null}
</div>
{bottomedOut
? <DepthLimit item={item} />
: (
<div className={`${styles.children}`}>
{!noReply &&
<Reply depth={depth + 1} item={item} replyOpen={replyOpen}>
{root.bounty && !bountyPaid && <PayBounty item={item} />}
</Reply>}
{children}
<div className={`${styles.comments} ml-sm-1 ml-md-3`}>
{item.comments && !noComments
? item.comments.map((item) => (
<Comment depth={depth + 1} key={item.id} item={item} />
))
: null}
</div>
)
)}
</div>
)}
</div>
)
}
@ -220,8 +208,8 @@ export default function Comment ({
function DepthLimit ({ item }) {
if (item.ncomments > 0) {
return (
<Link href={`/items/${item.id}`} className='d-block p-3 font-weight-bold text-muted w-100 text-center'>
view replies
<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>
)
}

View File

@ -0,0 +1,42 @@
import { useQuery } from '@apollo/client'
import { MORE_FLAT_COMMENTS } from '../fragments/comments'
import { CommentFlat, CommentSkeleton } from './comment'
import MoreFooter from './more-footer'
export default function CommentsFlat ({ variables, query, destructureData, comments, cursor, ...props }) {
const { data, fetchMore } = useQuery(query || MORE_FLAT_COMMENTS, {
variables
})
if (!data && !comments) {
return <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,15 +1,14 @@
import { gql, useApolloClient, useLazyQuery } from '@apollo/client'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import Comment, { CommentSkeleton } from './comment'
import styles from './header.module.css'
import { Nav, Navbar } from 'react-bootstrap'
import { COMMENTS_QUERY } from '../fragments/items'
import { COMMENTS } from '../fragments/comments'
import { abbrNum } from '../lib/format'
import { defaultCommentSort } from '../lib/item'
export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats }) {
const [sort, setSort] = useState(defaultCommentSort(pinned, bio, parentCreatedAt))
export function CommentsHeader ({ handleSort, pinned, commentSats }) {
const [sort, setSort] = useState(pinned ? 'recent' : 'hot')
const getHandleClick = sort => {
return () => {
@ -61,12 +60,19 @@ export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, comm
)
}
export default function Comments ({ parentId, pinned, bio, parentCreatedAt, commentSats, comments, ...props }) {
export default function Comments ({ parentId, pinned, commentSats, comments, ...props }) {
const client = useApolloClient()
useEffect(() => {
const hash = window.location.hash
if (hash) {
try {
document.querySelector(hash).scrollIntoView({ behavior: 'smooth' })
} catch {}
}
}, [typeof window !== 'undefined' && window.location.hash])
const [loading, setLoading] = useState()
const [getComments] = useLazyQuery(COMMENTS_QUERY, {
fetchPolicy: 'cache-first',
fetchPolicy: 'network-only',
onCompleted: data => {
client.writeFragment({
id: `Item:${parentId}`,
@ -91,8 +97,7 @@ export default function Comments ({ parentId, pinned, bio, parentCreatedAt, comm
<>
{comments.length
? <CommentsHeader
commentSats={commentSats} parentCreatedAt={parentCreatedAt}
pinned={pinned} bio={bio} handleSort={sort => {
commentSats={commentSats} pinned={pinned} 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' suppressHydrationWarning> {props.formatted.minutes}:{props.formatted.seconds}</span>}
renderer={props => <span className='text-monospace'> {props.formatted.minutes}:{props.formatted.seconds}</span>}
onComplete={onComplete}
/>
</span>

View File

@ -1,17 +0,0 @@
import { useEffect, useState } from 'react'
import { getTheme, listenForThemeChange, setTheme } from '../public/dark'
export default function useDarkMode () {
const [dark, setDark] = useState()
useEffect(() => {
const { user, dark } = getTheme()
setDark({ user, dark })
listenForThemeChange(setDark)
}, [])
return [dark?.dark, () => {
setTheme(!dark.dark)
setDark({ user: true, dark: !dark.dark })
}]
}

View File

@ -1,5 +1,5 @@
import { useMutation } from '@apollo/client'
import { gql } from 'graphql-tag'
import { gql } from 'apollo-server-micro'
import { useState } from 'react'
import { Alert, Button, Dropdown } from 'react-bootstrap'
import { useShowModal } from './modal'

View File

@ -1,6 +1,7 @@
import { Form, Input, MarkdownInput, SubmitButton } from '../components/form'
import { useRouter } from 'next/router'
import { gql, useApolloClient, useLazyQuery, useMutation } from '@apollo/client'
import TextareaAutosize from 'react-textarea-autosize'
import Countdown from './countdown'
import AdvPostForm, { AdvPostInitial } from './adv-post-form'
import FeeButton, { EditFeeButton } from './fee-button'
@ -35,14 +36,16 @@ export function DiscussionForm ({
)
const [getRelated, { data: relatedData }] = useLazyQuery(gql`
${ITEM_FIELDS}
query related($title: String!) {
related(title: $title, minMatch: "75%", limit: 3) {
items {
...ItemFields
}
${ITEM_FIELDS}
query related($title: String!) {
related(title: $title, minMatch: "75%", limit: 3) {
items {
...ItemFields
}
}`)
}
}`, {
fetchPolicy: 'network-only'
})
const related = relatedData?.related?.items || []
@ -93,6 +96,7 @@ export function DiscussionForm ({
topLevel
label={<>{textLabel} <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 { StaticLayout } from './layout'
import LayoutStatic from './layout-static'
import styles from '../styles/404.module.css'
class ErrorBoundary extends Component {
@ -25,10 +25,10 @@ class ErrorBoundary extends Component {
if (this.state.hasError) {
// You can render any custom fallback UI
return (
<StaticLayout>
<Image width='500' height='375' src={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/floating.gif`} fluid />
<LayoutStatic>
<Image width='500' height='375' src='/floating.gif' fluid />
<h1 className={styles.fourZeroFour} style={{ fontSize: '48px' }}>something went wrong</h1>
</StaticLayout>
</LayoutStatic>
)
}

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, nextFetchPolicy: 'cache-and-network' })
const { data } = useQuery(query, { pollInterval: 1000 })
const repetition = data?.itemRepetition || 0
const formik = useFormikContext()
const boost = formik?.values?.boost || 0

View File

@ -10,12 +10,14 @@ const REWARDS = gql`
}`
export default function Rewards () {
const { data } = useQuery(REWARDS, { pollInterval: 60000, nextFetchPolicy: 'cache-and-network' })
const { data } = useQuery(REWARDS, { pollInterval: 60000, fetchPolicy: 'cache-and-network' })
const total = data?.expectedRewards?.total
return (
<Link href='/rewards' className='nav-link p-0 p-0 d-inline-flex'>
{total ? <span><RewardLine total={total} /></span> : 'rewards'}
<Link href='/rewards' passHref>
<a className='nav-link p-0 p-0 d-inline-flex'>
{total ? <span><RewardLine total={total} /></span> : 'rewards'}
</a>
</Link>
)
}

View File

@ -1,9 +1,12 @@
import { useQuery } from '@apollo/client'
import gql from 'graphql-tag'
import { Container, OverlayTrigger, Popover } from 'react-bootstrap'
import { CopyInput } from './form'
import styles from './footer.module.css'
import Texas from '../svgs/texas.svg'
import Github from '../svgs/github-fill.svg'
import Link from 'next/link'
import useDarkMode from 'use-dark-mode'
import Sun from '../svgs/sun-fill.svg'
import Moon from '../svgs/moon-fill.svg'
import No from '../svgs/no.svg'
@ -11,7 +14,70 @@ import Bolt from '../svgs/bolt.svg'
import Amboss from '../svgs/amboss.svg'
import { useEffect, useState } from 'react'
import Rewards from './footer-rewards'
import useDarkMode from './dark-mode'
// XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
// if you update this you need to update /public/darkmode
// XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
const COLORS = {
light: {
body: '#f5f5f7',
color: '#212529',
navbarVariant: 'light',
navLink: 'rgba(0, 0, 0, 0.55)',
navLinkFocus: 'rgba(0, 0, 0, 0.7)',
navLinkActive: 'rgba(0, 0, 0, 0.9)',
borderColor: '#ced4da',
inputBg: '#ffffff',
inputDisabledBg: '#e9ecef',
dropdownItemColor: 'rgba(0, 0, 0, 0.7)',
dropdownItemColorHover: 'rgba(0, 0, 0, 0.9)',
commentBg: 'rgba(0, 0, 0, 0.03)',
clickToContextColor: 'rgba(0, 0, 0, 0.07)',
brandColor: 'rgba(0, 0, 0, 0.9)',
grey: '#707070',
link: '#007cbe',
toolbarActive: 'rgba(0, 0, 0, 0.10)',
toolbarHover: 'rgba(0, 0, 0, 0.20)',
toolbar: '#ffffff',
quoteBar: 'rgb(206, 208, 212)',
quoteColor: 'rgb(101, 103, 107)',
linkHover: '#004a72',
linkVisited: '#537587'
},
dark: {
body: '#000000',
inputBg: '#000000',
inputDisabledBg: '#000000',
navLink: 'rgba(255, 255, 255, 0.55)',
navLinkFocus: 'rgba(255, 255, 255, 0.75)',
navLinkActive: 'rgba(255, 255, 255, 0.9)',
borderColor: 'rgba(255, 255, 255, 0.5)',
dropdownItemColor: 'rgba(255, 255, 255, 0.7)',
dropdownItemColorHover: 'rgba(255, 255, 255, 0.9)',
commentBg: 'rgba(255, 255, 255, 0.04)',
clickToContextColor: 'rgba(255, 255, 255, 0.2)',
color: '#f8f9fa',
brandColor: 'var(--primary)',
grey: '#969696',
link: '#2e99d1',
toolbarActive: 'rgba(255, 255, 255, 0.10)',
toolbarHover: 'rgba(255, 255, 255, 0.20)',
toolbar: '#3e3f3f',
quoteBar: 'rgb(158, 159, 163)',
quoteColor: 'rgb(141, 144, 150)',
linkHover: '#007cbe',
linkVisited: '#56798E'
}
}
const handleThemeChange = (dark) => {
const root = window.document.documentElement
const colors = COLORS[dark ? 'dark' : 'light']
Object.entries(colors).forEach(([varName, value]) => {
const cssVarName = `--theme-${varName}`
root.style.setProperty(cssVarName, value)
})
}
const RssPopover = (
<Popover>
@ -113,19 +179,33 @@ const AnalyticsPopover = (
visitors
</a>
<span className='mx-2 text-muted'> \ </span>
<Link href='/stackers/day' className='nav-link p-0 d-inline-flex'>
stackers
<Link href='/stackers/day' passHref>
<a className='nav-link p-0 d-inline-flex'>
stackers
</a>
</Link>
</Popover.Content>
</Popover>
)
export default function Footer ({ links = true }) {
const [darkMode, darkModeToggle] = useDarkMode()
export default function Footer ({ noLinks }) {
const query = gql`
{
connectAddress
}
`
const { data } = useQuery(query, { fetchPolicy: 'cache-first' })
const darkMode = useDarkMode(false, {
// set this so it doesn't try to use classes
onChange: handleThemeChange
})
const [mounted, setMounted] = useState()
const [lightning, setLightning] = useState(undefined)
useEffect(() => {
setMounted(true)
setLightning(localStorage.getItem('lnAnimate') || 'yes')
}, [])
@ -139,7 +219,7 @@ export default function Footer ({ links = true }) {
}
}
const DarkModeIcon = darkMode ? Sun : Moon
const DarkModeIcon = darkMode.value ? Sun : Moon
const LnIcon = lightning === 'yes' ? No : Bolt
const version = process.env.NEXT_PUBLIC_COMMIT_HASH
@ -147,12 +227,13 @@ export default function Footer ({ links = true }) {
return (
<footer>
<Container className='mb-3 mt-4'>
{links &&
{!noLinks &&
<>
<div className='mb-1'>
<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>
{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>}
<div className='mb-0' style={{ fontWeight: 500 }}>
<Rewards />
</div>
@ -182,28 +263,38 @@ export default function Footer ({ links = true }) {
</OverlayTrigger>
</div>
<div className='mb-2' style={{ fontWeight: 500 }}>
<Link href='/faq' className='nav-link p-0 p-0 d-inline-flex'>
faq
<Link href='/faq' passHref>
<a className='nav-link p-0 p-0 d-inline-flex'>
faq
</a>
</Link>
<span className='mx-2 text-muted'> \ </span>
<Link href='/guide' className='nav-link p-0 p-0 d-inline-flex'>
guide
<Link href='/guide' passHref>
<a className='nav-link p-0 p-0 d-inline-flex'>
guide
</a>
</Link>
<span className='mx-2 text-muted'> \ </span>
<Link href='/story' className='nav-link p-0 p-0 d-inline-flex'>
story
<Link href='/story' passHref>
<a className='nav-link p-0 p-0 d-inline-flex'>
story
</a>
</Link>
<span className='mx-2 text-muted'> \ </span>
<Link href='/changes' className='nav-link p-0 p-0 d-inline-flex'>
changes
<Link href='/changes' passHref>
<a className='nav-link p-0 p-0 d-inline-flex'>
changes
</a>
</Link>
<span className='mx-2 text-muted'> \ </span>
<Link href='/privacy' className='nav-link p-0 p-0 d-inline-flex'>
privacy
<Link href='/privacy' passHref>
<a className='nav-link p-0 p-0 d-inline-flex'>
privacy
</a>
</Link>
</div>
</>}
{process.env.NEXT_PUBLIC_LND_CONNECT_ADDRESS &&
{data &&
<div
className={`text-small mx-auto mb-2 ${styles.connect}`}
>
@ -213,7 +304,7 @@ export default function Footer ({ links = true }) {
groupClassName='mb-0 w-100'
readOnly
noForm
placeholder={process.env.NEXT_PUBLIC_LND_CONNECT_ADDRESS}
placeholder={data.connectAddress}
/>
<a
href='https://amboss.space/node/03cc1d0932bb99b0697f5b5e5961b83ab7fd66f1efc4c9f5c7bad66c1bcbe78f02'
@ -229,14 +320,14 @@ export default function Footer ({ links = true }) {
made in Austin<Texas className='ml-1' width={20} height={20} />
<span className='ml-1'>by</span>
<span>
<Link href='/k00b' className='ml-1'>
@k00b
<Link href='/k00b' passHref>
<a className='ml-1'>@k00b</a>
</Link>
<Link href='/kr' className='ml-1'>
@kr
<Link href='/kr' passHref>
<a className='ml-1'>@kr</a>
</Link>
<Link href='/ekzyis' className='ml-1'>
@ekzyis
<Link href='/ekzyis' passHref>
<a className='ml-1'>@ekzyis</a>
</Link>
</span>
</small>

View File

@ -15,7 +15,6 @@ 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
@ -83,9 +82,6 @@ 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])
@ -115,53 +111,48 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH
<Markdown width={18} height={18} />
</a>
</Nav>
{tab === 'write'
? (
<div>
<InputInner
{...props} onChange={(formik, e) => {
if (onChange) onChange(formik, e)
if (setHasImgLink) {
setHasImgLink(mdHas(e.target.value, ['link', 'image']))
}
}}
innerRef={innerRef}
onKeyDown={(e) => {
const metaOrCtrl = e.metaKey || e.ctrlKey
if (metaOrCtrl) {
if (e.key === 'k') {
// some browsers use CTRL+K to focus search bar so we have to prevent that behavior
e.preventDefault()
insertMarkdownLinkFormatting(innerRef.current, helpers.setValue, setSelectionRange)
}
if (e.key === 'b') {
// some browsers use CTRL+B to open bookmarks so we have to prevent that behavior
e.preventDefault()
insertMarkdownBoldFormatting(innerRef.current, helpers.setValue, setSelectionRange)
}
if (e.key === 'i') {
// some browsers might use CTRL+I to do something else so prevent that behavior too
e.preventDefault()
insertMarkdownItalicFormatting(innerRef.current, helpers.setValue, setSelectionRange)
}
if (e.key === 'Tab' && e.altKey) {
e.preventDefault()
insertMarkdownTabFormatting(innerRef.current, helpers.setValue, setSelectionRange)
}
}
if (onKeyDown) onKeyDown(e)
}}
/>
</div>)
: (
<div className='form-group'>
<div className={`${styles.text} form-control`}>
<Text topLevel={topLevel} noFragments onlyImgProxy={false}>{meta.value}</Text>
</div>
</div>
)}
<div className={tab !== 'write' ? 'd-none' : ''}>
<InputInner
{...props} onChange={(formik, e) => {
if (onChange) onChange(formik, e)
if (setHasImgLink) {
setHasImgLink(mdHas(e.target.value, ['link', 'image']))
}
}}
innerRef={innerRef}
onKeyDown={(e) => {
const metaOrCtrl = e.metaKey || e.ctrlKey
if (metaOrCtrl) {
if (e.key === 'k') {
// some browsers use CTRL+K to focus search bar so we have to prevent that behavior
e.preventDefault()
insertMarkdownLinkFormatting(innerRef.current, helpers.setValue, setSelectionRange)
}
if (e.key === 'b') {
// some browsers use CTRL+B to open bookmarks so we have to prevent that behavior
e.preventDefault()
insertMarkdownBoldFormatting(innerRef.current, helpers.setValue, setSelectionRange)
}
if (e.key === 'i') {
// some browsers might use CTRL+I to do something else so prevent that behavior too
e.preventDefault()
insertMarkdownItalicFormatting(innerRef.current, helpers.setValue, setSelectionRange)
}
if (e.key === 'Tab' && e.altKey) {
e.preventDefault()
insertMarkdownTabFormatting(innerRef.current, helpers.setValue, setSelectionRange)
}
}
if (onKeyDown) onKeyDown(e)
}}
/>
</div>
<div className={tab !== 'preview' ? 'd-none' : 'form-group'}>
<div className={`${styles.text} form-control`}>
{tab === 'preview' && <Text topLevel={topLevel} noFragments onlyImgProxy={false}>{meta.value}</Text>}
</div>
</div>
</div>
</FormGroup>
)
@ -309,6 +300,7 @@ function InputInner ({
export function InputUserSuggest ({ label, groupClassName, ...props }) {
const [getSuggestions] = useLazyQuery(USER_SEARCH, {
fetchPolicy: 'network-only',
onCompleted: data => {
setSuggestions({ array: data.searchUsers, index: 0 })
}
@ -484,17 +476,10 @@ export function Form ({
)
}
export function Select ({ label, items, groupClassName, onChange, noForm, overrideValue, ...props }) {
const [field, meta, helpers] = noForm ? [{}, {}, {}] : useField(props)
export function Select ({ label, items, groupClassName, onChange, noForm, ...props }) {
const [field, meta] = 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 { useCallback, useEffect, useState } from 'react'
import { useEffect, useState } from 'react'
import { randInRange } from '../lib/rand'
import { abbrNum } from '../lib/format'
import NoteIcon from '../svgs/notification-4-fill.svg'
import { useQuery } from '@apollo/client'
import { useQuery, gql } 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,257 +42,291 @@ function Back () {
return null
}
function NotificationBell () {
const { data } = useQuery(HAS_NOTIFICATIONS, {
pollInterval: 30000,
nextFetchPolicy: 'cache-and-network'
})
return (
<>
<Head>
<link rel='shortcut icon' href={data?.hasNewNotes ? '/favicon-notify.png' : '/favicon.png'} />
</Head>
<Link href='/notifications' passHref legacyBehavior>
<Nav.Link eventKey='notifications' className='pl-0 position-relative'>
<NoteIcon height={22} width={22} className='theme' />
{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} />
</Nav.Link>
}
alignRight
>
<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'>
<span className='invisible'>{' '}</span>
</div>}
</NavDropdown.Item>
</Link>
<Link href={'/' + me.name + '/bookmarks'} passHref legacyBehavior>
<NavDropdown.Item active={me.name + '/bookmarks' === dropNavKey}>bookmarks</NavDropdown.Item>
</Link>
<Link href='/wallet' passHref legacyBehavior>
<NavDropdown.Item eventKey='wallet'>wallet</NavDropdown.Item>
</Link>
<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 legacyBehavior>
<NavDropdown.Item eventKey='referrals'>referrals</NavDropdown.Item>
</Link>
<NavDropdown.Divider />
<div className='d-flex align-items-center'>
<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.bioId &&
<span className='position-absolute p-1 bg-secondary' style={{ top: '5px', right: '0px' }}>
<span className='invisible'>{' '}</span>
</span>}
</div>
<Nav.Item>
<Link href='/wallet' passHref legacyBehavior>
<Nav.Link eventKey='wallet' className='text-success px-0 text-nowrap'><WalletSummary me={me} /></Nav.Link>
</Link>
</Nav.Item>
</div>
)
}
function LurkerCorner ({ path }) {
const router = useRouter()
const strike = useLightning()
useEffect(() => {
if (!localStorage.getItem('striked')) {
const to = setTimeout(() => {
strike()
localStorage.setItem('striked', 'yep')
}, randInRange(3000, 10000))
return () => clearTimeout(to)
}
}, [])
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
className='align-items-center px-3 py-1 mr-2'
id='signup'
style={{ borderWidth: '2px' }}
variant='outline-grey-darkmode'
onClick={() => handleLogin('/login')}
>
login
</Button>
<Button
className='align-items-center pl-2 py-1 pr-3'
style={{ borderWidth: '2px' }}
id='login'
onClick={() => handleLogin('/signup')}
>
<LightningIcon
width={17}
height={17}
className='mr-1'
/>sign up
</Button>
</div>
}
function NavItems ({ className, sub, prefix }) {
const router = useRouter()
sub ||= 'home'
return (
<>
<Nav.Item className={className}>
<Form
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 legacyBehavior>
<Nav.Link eventKey='' className={styles.navLink}>hot</Nav.Link>
</Link>
</Nav.Item>
<Nav.Item className={className}>
<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 legacyBehavior>
<Nav.Link eventKey='top' className={styles.navLink}>top</Nav.Link>
</Link>
</Nav.Item>}
</>
)
}
function PostItem ({ className, prefix }) {
const me = useMe()
if (!me) return null
return (
<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 [fired, setFired] = useState()
const [topNavKey, setTopNavKey] = useState('')
const [dropNavKey, setDropNavKey] = useState('')
const [prefix, setPrefix] = useState('')
const [path, setPath] = useState('')
const me = useMe()
return (
<Container as='header' className='px-0'>
<Navbar className='pb-0 pb-lg-2'>
<Nav
className={styles.navbarNav}
activeKey={topNavKey}
>
<div className='d-flex align-items-center'>
<Back />
<Link href='/' passHref legacyBehavior>
<Navbar.Brand className={`${styles.brand} d-flex`}>
SN
</Navbar.Brand>
</Link>
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
}
`, {
pollInterval: 30000,
fetchPolicy: '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'} />
</Head>
<Link href='/notifications' passHref>
<Nav.Link eventKey='notifications' className='pl-0 position-relative'>
<NoteIcon height={22} width={22} className='theme' />
{hasNewNotes?.hasNewNotes &&
<span className={styles.notification}>
<span className='invisible'>{' '}</span>
</span>}
</Nav.Link>
</Link>
<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} />
</Nav.Link>
} alignRight
>
<Link href={'/' + me?.name} passHref>
<NavDropdown.Item active={me?.name === dropNavKey}>
profile
{me && !me.bioId &&
<div className='p-1 d-inline-block bg-secondary ml-1'>
<span className='invisible'>{' '}</span>
</div>}
</NavDropdown.Item>
</Link>
<Link href={'/' + me?.name + '/bookmarks'} passHref>
<NavDropdown.Item active={me?.name + '/bookmarks' === dropNavKey}>bookmarks</NavDropdown.Item>
</Link>
<Link href='/wallet' passHref>
<NavDropdown.Item eventKey='wallet'>wallet</NavDropdown.Item>
</Link>
<Link href='/satistics?inc=invoice,withdrawal,stacked,spent' passHref>
<NavDropdown.Item eventKey='satistics'>satistics</NavDropdown.Item>
</Link>
<NavDropdown.Divider />
<Link href='/referrals/month' passHref>
<NavDropdown.Item eventKey='referrals'>referrals</NavDropdown.Item>
</Link>
<NavDropdown.Divider />
<div className='d-flex align-items-center'>
<Link href='/settings' passHref>
<NavDropdown.Item eventKey='settings'>settings</NavDropdown.Item>
</Link>
</div>
<NavDropdown.Divider />
<NavDropdown.Item onClick={() => signOut({ callbackUrl: '/' })}>logout</NavDropdown.Item>
</NavDropdown>
{me && !me.bioId &&
<span className='position-absolute p-1 bg-secondary' style={{ top: '5px', right: '0px' }}>
<span className='invisible'>{' '}</span>
</span>}
</div>
<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>
{me &&
<Nav.Item>
<Link href='/wallet' passHref>
<Nav.Link eventKey='wallet' className='text-success px-0 text-nowrap'><WalletSummary me={me} /></Nav.Link>
</Link>
</Nav.Item>}
</div>
)
} else {
if (!fired) {
const strike = useLightning()
useEffect(() => {
let isMounted = true
if (!localStorage.getItem('striked')) {
setTimeout(() => {
if (isMounted) {
strike()
localStorage.setItem('striked', 'yep')
setFired(true)
}
}, randInRange(3000, 10000))
}
return () => { isMounted = false }
}, [])
}
return path !== '/login' && path !== '/signup' && !path.startsWith('/invites') &&
<div className='ml-auto'>
<Button
className='align-items-center px-3 py-1 mr-2'
id='signup'
style={{ borderWidth: '2px' }}
variant='outline-grey-darkmode'
onClick={async () => await router.push({ pathname: '/login', query: { callbackUrl: window.location.origin + router.asPath } })}
>
login
</Button>
<Button
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 } })}
>
<LightningIcon
width={17}
height={17}
className='mr-1'
/>sign up
</Button>
</div>
}
}
// const showJobIndicator = sub !== 'jobs' && (!me || me.noteJobIndicator) &&
// (!lastCheckedJobs || lastCheckedJobs < subLatestPost?.subLatestPost)
const NavItems = ({ className }) => {
return (
<>
<Nav.Item className={className}>
<Form
initial={{
sub: sub || 'home'
}}
>
<Select
groupClassName='mb-0'
onChange={(formik, e) => router.push(e.target.value === 'home' ? '/' : `/~${e.target.value}`)}
name='sub'
size='sm'
items={['home', ...SUBS]}
/>
</Form>
</Nav.Item>
<Nav.Item className={className}>
<Link href={prefix + '/'} passHref>
<Nav.Link eventKey='' className={styles.navLink}>hot</Nav.Link>
</Link>
<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>
{me ? <StackerCorner dropNavKey={dropNavKey} /> : <LurkerCorner path={path} />}
</Nav>
</Navbar>
<Navbar className='pt-0 pb-2 d-lg-none'>
<Nav
className={`${styles.navbarNav}`}
activeKey={topNavKey}
>
<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>
</Nav.Item>
<Nav.Item className={className}>
<Link href={prefix + '/recent'} passHref>
<Nav.Link eventKey='recent' className={styles.navLink}>recent</Nav.Link>
</Link>
<PostItem className='mr-0 pr-0' prefix={prefix} />
</Nav>
</Navbar>
</Container>
</Nav.Item>
{sub !== 'jobs' &&
<Nav.Item className={className}>
<Link href={prefix + '/top/posts/day'} passHref>
<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
}
return (
<>
<Container className='px-0'>
<Navbar className='pb-0 pb-lg-2'>
<Nav
className={styles.navbarNav}
activeKey={topNavKey}
>
<div className='d-flex align-items-center'>
<Back />
<Link href='/' passHref>
<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>
<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>
</Link>
<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 />
</Nav>
</Navbar>
<Navbar className='pt-0 pb-2 d-lg-none'>
<Nav
className={`${styles.navbarNav}`}
activeKey={topNavKey}
>
<NavItems className='mr-1' />
<Link href='/search' passHref>
<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' />
</Nav>
</Navbar>
</Container>
</>
)
}
export function HeaderStatic () {
return (
<Container as='header' className='px-sm-0'>
<Container 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 legacyBehavior>
<Link href='/' passHref>
<Navbar.Brand className={`${styles.brand}`}>
SN
</Navbar.Brand>
</Link>
<Link href='/search' passHref legacyBehavior>
<Link href='/search' passHref>
<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,16 +1,28 @@
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 showModal = useShowModal()
const [info, setInfo] = useState()
return (
<InfoIcon
width={18} height={18} className={`${iconClassName} pointer ml-1`}
onClick={(e) => {
e.preventDefault()
showModal(onClose => children)
}}
/>
<>
<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)
}}
/>
</>
)
}

View File

@ -5,13 +5,12 @@ 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 './dark-mode'
import useDarkMode from 'use-dark-mode'
import { useEffect, useState } from 'react'
import Poll from './poll'
import { commentsViewed } from '../lib/new-comments'
@ -62,7 +61,7 @@ function TweetSkeleton () {
}
function ItemEmbed ({ item }) {
const [darkMode] = useDarkMode()
const darkMode = useDarkMode()
const [overflowing, setOverflowing] = useState(false)
const [show, setShow] = useState(false)
@ -70,7 +69,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 ? 'dark' : 'light' }} placeholder={<TweetSkeleton />} onLoad={() => setOverflowing(true)} />
<TwitterTweetEmbed tweetId={twitter.groups.id} options={{ width: '550px', theme: darkMode.value ? 'dark' : 'light' }} placeholder={<TweetSkeleton />} onLoad={() => setOverflowing(true)} />
{overflowing && !show &&
<Button size='lg' variant='info' className={styles.twitterShowFull} onClick={() => setShow(true)}>
show full tweet
@ -105,8 +104,8 @@ function FwdUser ({ user }) {
return (
<div className={styles.other}>
100% of zaps are forwarded to{' '}
<Link href={`/${user.name}`}>
@{user.name}
<Link href={`/${user.name}`} passHref>
<a>@{user.name}</a>
</Link>
</div>
)
@ -120,11 +119,10 @@ function TopLevelItem ({ item, noReply, ...props }) {
item={item}
full
right={
!noReply &&
<>
<Share item={item} />
<Toc text={item.text} />
</>
<>
<Share item={item} />
<Toc text={item.text} />
</>
}
belowTitle={item.fwdUser && <FwdUser user={item.fwdUser} />}
{...props}
@ -160,37 +158,26 @@ function ItemText ({ item }) {
return <Text topLevel nofollow={item.sats + item.boost < NOFOLLOW_LIMIT}>{item.searchText || item.text}</Text>
}
export default function ItemFull ({ item, bio, rank, ...props }) {
export default function ItemFull ({ item, bio, ...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} />
: (
<div className='mt-1'>{
<RootProvider root={item.root || item}>
{item.parentId
? <Comment topLevel item={item} replyOpen includeParent noComments {...props} />
: (
<div className='mt-1'>{
bio
? <BioItem item={item} {...props} />
: <TopLevelItem item={item} {...props} />
}
</div>)}
{item.comments &&
<div className={styles.comments}>
<Comments
parentId={item.id} parentCreatedAt={item.createdAt}
pinned={item.position} bio={bio} commentSats={item.commentSats} comments={item.comments}
/>
</div>}
</RootProvider>
</>
</div>)}
{item.comments &&
<div className={styles.comments}>
<Comments parentId={item.id} pinned={item.position} commentSats={item.commentSats} comments={item.comments} />
</div>}
</RootProvider>
)
}

View File

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

View File

@ -21,18 +21,22 @@ export default function ItemJob ({ item, toc, rank, children }) {
</div>)
: <div />}
<div className={`${styles.item}`}>
<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}
/>
<Link href={`/items/${item.id}`} passHref>
<a>
<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}`} className={`${styles.title} text-reset mr-2`}>
{item.searchTitle
? <SearchTitle title={item.searchTitle} />
: (
<>{item.title}</>)}
<Link href={`/items/${item.id}`} passHref>
<a className={`${styles.title} text-reset mr-2`}>
{item.searchTitle
? <SearchTitle title={item.searchTitle} />
: (
<>{item.title}</>)}
</a>
</Link>
</div>
<div className={`${styles.other}`}>
@ -48,12 +52,14 @@ export default function ItemJob ({ item, toc, rank, children }) {
<wbr />
<span> \ </span>
<span>
<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} />
<Link href={`/${item.user.name}`} passHref>
<a 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}`} title={item.createdAt} className='text-reset'>
{timeSince(new Date(item.createdAt))}
<Link href={`/items/${item.id}`} passHref>
<a title={item.createdAt} className='text-reset'>{timeSince(new Date(item.createdAt))}</a>
</Link>
</span>
{item.mine &&
@ -61,8 +67,10 @@ export default function ItemJob ({ item, toc, rank, children }) {
<>
<wbr />
<span> \ </span>
<Link href={`/items/${item.id}/edit`} className='text-reset'>
edit
<Link href={`/items/${item.id}/edit`} passHref>
<a 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, siblingComments }) {
export default function Item ({ item, rank, belowTitle, right, full, children }) {
const titleRef = useRef()
const [pendingSats, setPendingSats] = useState(0)
@ -33,22 +33,24 @@ export default function Item ({ item, rank, belowTitle, right, full, children, s
{rank}
</div>)
: <div />}
<div className={`${styles.item} ${siblingComments ? 'pt-2' : ''}`}>
<div className={styles.item}>
{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}`} 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 &&
<span className={styles.icon}>
<ActionTooltip notForm overlayText={`${abbrNum(item.bounty)} ${item.bountyPaidTo?.length ? 'sats paid' : 'sats bounty'}`}>
<BountyIcon className={`${styles.bountyIcon} ${item.bountyPaidTo?.length ? 'fill-success' : 'fill-grey'}`} height={16} width={16} />
</ActionTooltip>
</span>}
{image && <span className={styles.icon}><ImageIcon className='fill-grey ml-2' height={16} width={16} /></span>}
<Link href={`/items/${item.id}`} passHref>
<a 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 &&
<span className={styles.icon}>
<ActionTooltip notForm overlayText={`${abbrNum(item.bounty)} ${item.bountyPaidTo?.length ? 'sats paid' : 'sats bounty'}`}>
<BountyIcon className={`${styles.bountyIcon} ${item.bountyPaidTo?.length ? 'fill-success' : 'fill-grey'}`} height={16} width={16} />
</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: .5rem;
padding-bottom: .45rem;
}
.item .companyImage {
@ -120,7 +120,7 @@ a.link:visited {
.rank {
font-weight: 600;
margin-top: .25rem;
margin-top: 4px;
display: flex;
color: var(--theme-grey);
font-size: 90%;

34
components/items-mixed.js Normal file
View File

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

View File

@ -1,4 +1,5 @@
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'
@ -132,6 +133,7 @@ export default function JobForm ({ item, sub }) {
topLevel
label='description'
name='text'
as={TextareaAutosize}
minRows={6}
required
/>
@ -168,7 +170,7 @@ function PromoteJob ({ item, sub, storageKeyPrefix }) {
query AuctionPosition($id: ID, $bid: Int!) {
auctionPosition(sub: "${item?.subName || sub?.name}", id: $id, bid: $bid)
}`,
{ fetchPolicy: 'cache-and-network' })
{ fetchPolicy: 'network-only' })
const position = data?.auctionPosition
useEffect(() => {
@ -258,7 +260,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' className='text-reset text-underline'>fund your wallet</Link> or reduce bid to continue promoting your job</Alert>}
<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>}
<StatusComp />
</div>
</div>

View File

@ -0,0 +1,14 @@
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

@ -0,0 +1,15 @@
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,60 +1,29 @@
import Header, { HeaderStatic } from './header'
import Header from './header'
import Container from 'react-bootstrap/Container'
import { LightningProvider } from './lightning'
import Footer from './footer'
import Seo, { SeoSearch } from './seo'
import Seo from './seo'
import Search from './search'
import styles from './layout.module.css'
export default function Layout ({
sub, contain = true, footer = true, footerLinks = true,
containClassName = '', seo = true, item, user, children
sub, noContain, noFooter, noFooterLinks,
containClassName, noSeo, children, search
}) {
return (
<>
{seo && <Seo sub={sub} item={item} user={user} />}
<Header sub={sub} />
{contain
? (
<Container as='main' className={`px-sm-0 ${containClassName}`}>
{children}
</Container>
)
: children}
{footer && <Footer links={footerLinks} />}
{!noSeo && <Seo sub={sub} />}
<LightningProvider>
<Header sub={sub} />
{noContain
? children
: (
<Container className={`px-sm-0 ${containClassName || ''}`}>
{children}
</Container>
)}
{!noFooter && <Footer noLinks={noFooterLinks} />}
{!noContain && search && <Search sub={sub} />}
</LightningProvider>
</>
)
}
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, nextFetchPolicy: 'cache-and-network' })
const { data } = useQuery(query, { pollInterval: 1000 })
if (data && data.lnAuth.pubkey) {
signIn(encodedUrl ? 'lightning' : 'slashtags', { ...data.lnAuth, callbackUrl })

View File

@ -1,37 +1,32 @@
import React, { useRef, useEffect, useContext } from 'react'
import { randInRange } from '../lib/rand'
export const LightningContext = React.createContext(() => {})
export const LightningContext = React.createContext({
bolts: 0,
strike: () => {}
})
export class LightningProvider extends React.Component {
state = {
bolts: []
}
strike = () => {
const should = localStorage.getItem('lnAnimate') || 'yes'
if (should === 'yes') {
this.setState(state => {
return {
bolts: [...state.bolts, <Lightning key={state.bolts.length} onDone={() => this.unstrike(state.bolts.length)} />]
}
})
bolts: 0,
strike: (repeat) => {
const should = localStorage.getItem('lnAnimate') || 'yes'
if (should === 'yes') {
this.setState(state => {
return {
...this.state,
bolts: this.state.bolts + 1
}
})
}
}
}
unstrike = (index) => {
this.setState(state => {
const bolts = [...state.bolts]
bolts[index] = null
return { bolts }
})
}
render () {
const { props: { children } } = this
const { state, props: { children } } = this
return (
<LightningContext.Provider value={this.strike}>
{this.state.bolts}
<LightningContext.Provider value={state}>
{new Array(this.state.bolts).fill(null).map((_, i) => <Lightning key={i} />)}
{children}
</LightningContext.Provider>
)
@ -40,33 +35,31 @@ export class LightningProvider extends React.Component {
export const LightningConsumer = LightningContext.Consumer
export function useLightning () {
return useContext(LightningContext)
const { strike } = useContext(LightningContext)
return strike
}
export function Lightning ({ onDone }) {
export function Lightning () {
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
canvas.bolt = new Bolt(context, {
const 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,
onDone
branches: 20
})
canvas.bolt.draw()
bolt.draw()
}, [])
return <canvas className='position-fixed' ref={canvasRef} style={{ zIndex: 100, pointerEvents: 'none' }} />
return <canvas className='position-fixed' ref={canvasRef} style={{ zIndex: 0, pointerEvents: 'none' }} />
}
function Bolt (ctx, options) {
@ -86,6 +79,12 @@ 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
@ -93,7 +92,6 @@ 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])
@ -112,6 +110,9 @@ 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]],
@ -145,7 +146,6 @@ 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,25 +29,30 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
title
unshorted
}
}`)
const [getDupes, { data: dupesData, loading: dupesLoading }] = useLazyQuery(gql`
${ITEM_FIELDS}
query Dupes($url: String!) {
dupes(url: $url) {
...ItemFields
}
}`, {
fetchPolicy: 'network-only'
})
const [getDupes, { data: dupesData, loading: dupesLoading }] = useLazyQuery(gql`
${ITEM_FIELDS}
query Dupes($url: String!) {
dupes(url: $url) {
...ItemFields
}
}`, {
fetchPolicy: 'network-only',
onCompleted: () => setPostDisabled(false)
})
const [getRelated, { data: relatedData }] = useLazyQuery(gql`
${ITEM_FIELDS}
query related($title: String!) {
related(title: $title, minMatch: "75%", limit: 3) {
items {
...ItemFields
}
${ITEM_FIELDS}
query related($title: String!) {
related(title: $title, minMatch: "75%", limit: 3) {
items {
...ItemFields
}
}`)
}
}`, {
fetchPolicy: 'network-only'
})
const related = []
for (const item of relatedData?.related?.items || []) {

View File

@ -15,15 +15,14 @@ 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, nextFetchPolicy: 'cache-and-network' })
const { data } = useQuery(ME, { pollInterval: 1000, fetchPolicy: 'cache-and-network' })
const contextValue = {
me: data?.me || me

View File

@ -0,0 +1,21 @@
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,8 +1,7 @@
import { Button } from 'react-bootstrap'
import { useState } from 'react'
import Link from 'next/link'
export default function MoreFooter ({ cursor, count, fetchMore, Skeleton, noMoreText = 'GENESIS' }) {
export default function MoreFooter ({ cursor, fetchMore, Skeleton, noMoreText }) {
const [loading, setLoading] = useState(false)
if (loading) {
@ -29,24 +28,9 @@ export default function MoreFooter ({ cursor, count, fetchMore, Skeleton, noMore
)
} else {
Footer = () => (
<div className='text-muted' style={{ fontFamily: 'lightning', fontSize: '2rem', opacity: '0.75' }}>{count === 0 ? 'EMPTY' : noMoreText}</div>
<div className='text-muted' style={{ fontFamily: 'lightning', fontSize: '2rem', opacity: '0.75' }}>{noMoreText || 'GENESIS'}</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,9 +1,10 @@
import { useState, useEffect, useMemo } from 'react'
import { useApolloClient, useQuery } from '@apollo/client'
import { useState, useEffect } from 'react'
import { useQuery } from '@apollo/client'
import Comment, { CommentSkeleton } from './comment'
import Item from './item'
import ItemJob from './item-job'
import { HAS_NOTIFICATIONS, NOTIFICATIONS } from '../fragments/notifications'
import { NOTIFICATIONS } from '../fragments/notifications'
import { useRouter } from 'next/router'
import MoreFooter from './more-footer'
import Invite from './invite'
import { ignoreClick } from '../lib/clicks'
@ -19,7 +20,6 @@ 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,47 +37,39 @@ function Notification ({ n }) {
return null
}
function NotificationLayout ({ children, href, as }) {
const router = useRouter()
function NotificationLayout ({ children, onClick }) {
return (
<div
className='clickToContext'
onClick={(e) => !ignoreClick(e) && router.push(href, as)}
className='clickToContext' onClick={(e) => {
if (ignoreClick(e)) return
onClick?.(e)
}}
>
{children}
</div>
)
}
const defaultOnClick = n => {
const defaultOnClick = (n, router) => () => {
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]
return {
href: {
pathname: '/items/[id]',
query: { id: rootId, commentId: n.item.id }
},
as: `/items/${rootId}`
}
router.push({
pathname: '/items/[id]',
query: { id: rootId, commentId: n.item.id }
}, `/items/${rootId}`)
} else {
return {
href: {
pathname: '/items/[id]',
query: { id: n.item.root.id, commentId: n.item.id }
},
as: `/items/${n.item.root.id}`
}
router.push({
pathname: '/items/[id]',
query: { id: n.item.root.id, commentId: n.item.id }
}, `/items/${n.item.root.id}`)
}
} else {
return {
href: {
pathname: '/items/[id]',
query: { id: n.item.id }
},
as: `/items/${n.item.id}`
}
router.push({
pathname: '/items/[id]',
query: { id: n.item.id }
}, `/items/${n.item.id}`)
}
}
@ -122,30 +114,33 @@ function Streak ({ n }) {
function EarnNotification ({ n }) {
return (
<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'>
you stacked {n.earnedSats} sats in rewards<small className='text-muted ml-1'>{timeSince(new Date(n.sortTime))}</small>
</div>
{n.sources &&
<div style={{ fontSize: '80%', color: 'var(--theme-grey)' }}>
{n.sources.posts > 0 && <span>{n.sources.posts} sats for top posts</span>}
{n.sources.comments > 0 && <span>{n.sources.posts > 0 && ' \\ '}{n.sources.comments} sats for top comments</span>}
{n.sources.tipPosts > 0 && <span>{(n.sources.comments > 0 || n.sources.posts > 0) && ' \\ '}{n.sources.tipPosts} sats for zapping top posts early</span>}
{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'>jobs</Link>, boosts, posting fees, and donations. You can see the daily rewards pool and make a donation <Link href='/rewards'>here</Link>.
<NotificationLayout>
<div className='d-flex'>
<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'>
you stacked {n.earnedSats} sats in rewards<small className='text-muted ml-1'>{timeSince(new Date(n.sortTime))}</small>
</div>
{n.sources &&
<div style={{ fontSize: '80%', color: 'var(--theme-grey)' }}>
{n.sources.posts > 0 && <span>{n.sources.posts} sats for top posts</span>}
{n.sources.comments > 0 && <span>{n.sources.posts > 0 && ' \\ '}{n.sources.comments} sats for top comments</span>}
{n.sources.tipPosts > 0 && <span>{(n.sources.comments > 0 || n.sources.posts > 0) && ' \\ '}{n.sources.tipPosts} sats for zapping top posts early</span>}
{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>.
</div>
</div>
</div>
</div>
</NotificationLayout>
)
}
function Invitification ({ n }) {
const router = useRouter()
return (
<NotificationLayout href='/invites'>
<NotificationLayout onClick={() => router.push('/invites')}>
<small className='font-weight-bold text-secondary ml-2'>
your invite has been redeemed by {n.invite.invitees.length} stackers
</small>
@ -162,8 +157,9 @@ function Invitification ({ n }) {
}
function InvoicePaid ({ n }) {
const router = useRouter()
return (
<NotificationLayout href={`/invoices/${n.invoice.id}`}>
<NotificationLayout onClick={() => router.push(`/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>
@ -176,7 +172,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' className='text-reset'>referral links</Link>
someone joined via one of your <Link href='/referrals/month' passHref><a className='text-reset'>referral links</a></Link>
<small className='text-muted ml-1'>{timeSince(new Date(n.sortTime))}</small>
</small>
</NotificationLayout>
@ -184,8 +180,9 @@ function Referral ({ n }) {
}
function Votification ({ n }) {
const router = useRouter()
return (
<NotificationLayout {...defaultOnClick(n)}>
<NotificationLayout onClick={defaultOnClick(n, router)}>
<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>
@ -205,8 +202,9 @@ function Votification ({ n }) {
}
function Mention ({ n }) {
const router = useRouter()
return (
<NotificationLayout {...defaultOnClick(n)}>
<NotificationLayout onClick={defaultOnClick(n, router)}>
<small className='font-weight-bold text-info ml-2'>
you were mentioned in
</small>
@ -225,8 +223,9 @@ function Mention ({ n }) {
}
function JobChanged ({ n }) {
const router = useRouter()
return (
<NotificationLayout {...defaultOnClick(n)}>
<NotificationLayout onClick={defaultOnClick(n, router)}>
<small className={`font-weight-bold text-${n.item.status === 'ACTIVE' ? 'success' : 'boost'} ml-1`}>
{n.item.status === 'ACTIVE'
? 'your job is active again'
@ -240,8 +239,9 @@ function JobChanged ({ n }) {
}
function Reply ({ n }) {
const router = useRouter()
return (
<NotificationLayout {...defaultOnClick(n)} rootText='replying on:'>
<NotificationLayout onClick={defaultOnClick(n, router)} rootText='replying on:'>
<div className='py-2'>
{n.item.title
? <Item item={n.item} />
@ -274,6 +274,8 @@ function NotificationAlert () {
}
}, [sw])
if (!supported) return null
const close = () => {
localStorage.setItem('hideNotifyPrompt', 'yep')
setShowAlert(false)
@ -303,7 +305,7 @@ function NotificationAlert () {
</Alert>
)
: (
<Form className={`d-flex justify-content-end ${supported ? 'visible' : 'invisible'}`} initial={{ pushNotify: hasSubscription }}>
<Form className='d-flex justify-content-end' initial={{ pushNotify: hasSubscription }}>
<Checkbox
name='pushNotify' label={<span className='text-muted'>push notifications</span>}
groupClassName={`${styles.subFormGroup} mb-1 mr-sm-3 mr-0`}
@ -316,48 +318,34 @@ function NotificationAlert () {
)
}
export default function Notifications ({ ssrData }) {
const { data, fetchMore } = useQuery(NOTIFICATIONS)
const client = useApolloClient()
export default function Notifications ({ notifications, earn, cursor, lastChecked, variables }) {
const { data, fetchMore } = useQuery(NOTIFICATIONS, { variables })
useEffect(() => {
client.writeQuery({
query: HAS_NOTIFICATIONS,
data: {
hasNewNotes: false
}
})
}, [client])
if (data) {
({ notifications: { notifications, earn, cursor } } = data)
}
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) => {
const [fresh, old] =
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={n.__typename + n.id + n.sortTime} />
<Notification n={n} key={i} />
))}
</div>
{old.map((n, i) => (
<Notification n={n} key={n.__typename + n.id + n.sortTime} />
<Notification n={n} key={i} />
))}
<MoreFooter cursor={cursor} count={notifications?.length} fetchMore={fetchMore} Skeleton={CommentsFlatSkeleton} noMoreText='NO MORE' />
<MoreFooter cursor={cursor} fetchMore={fetchMore} Skeleton={CommentsFlatSkeleton} />
</>
)
}
@ -366,10 +354,9 @@ function CommentsFlatSkeleton () {
const comments = new Array(21).fill(null)
return (
<div>
{comments.map((_, i) => (
<CommentSkeleton key={i} skeletonChildren={0} />
))}
<div>{comments.map((_, i) => (
<CommentSkeleton key={i} skeletonChildren={0} />
))}
</div>
)
}

View File

@ -1,9 +0,0 @@
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,26 +1,44 @@
import { useQuery } from '@apollo/client'
import AccordianItem from './accordian-item'
import Items from './items'
import { NavigateFooter } from './more-footer'
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'
const LIMIT = 5
export default function PastBounties ({ children, item }) {
const emptyItems = new Array(5).fill(null)
export default function PastBounties ({ item }) {
const variables = {
name: item.user.name,
sort: 'user',
type: 'bounties',
limit: LIMIT
const { data, loading } = useQuery(BOUNTY_ITEMS_BY_USER_NAME, {
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)
}
return (
<AccordianItem
header={<div className='font-weight-bold'>{item.user.name}'s bounties</div>}
body={
<Items
variables={variables}
Footer={props => <NavigateFooter {...props} href={`/${item.user.name}/bounties`} text='view all past bounties' />}
filter={i => i.id !== item.id}
/>
<>
<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>}
</>
}
/>
)

View File

@ -2,6 +2,7 @@ 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'
@ -60,7 +61,7 @@ export default function PayBounty ({ children, item }) {
}
)
const handlePayBounty = async onComplete => {
const handlePayBounty = async () => {
try {
await act({
variables: { id: item.id, sats: root.bounty },
@ -71,7 +72,6 @@ export default function PayBounty ({ children, item }) {
}
}
})
onComplete()
} catch (error) {
if (error.toString().includes('insufficient funds')) {
showModal(onClose => {
@ -92,24 +92,22 @@ export default function PayBounty ({ children, item }) {
notForm
overlayText={`${root.bounty} sats`}
>
<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(onClose)}>
pay <small>{abbrNum(root.bounty)} sats</small>
</Button>
</div>
</>
))
}}
<ModalButton
clicker={
<div className={styles.pay}>
pay bounty
</div>
}
>
pay bounty
</div>
<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()}>
pay <small>{abbrNum(root.bounty)} sats</small>
</Button>
</div>
</ModalButton>
</ActionTooltip>
)
}

View File

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

View File

@ -1,12 +1,14 @@
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 ? `/~${sub}` : ''
const prefix = sub?.name ? `/~${sub.name}` : ''
const items = ITEM_TYPES(sub)
const items = ['posts', 'bounties', 'comments', 'links', 'discussions', 'polls']
if (!sub?.name) {
items.push('bios')
}
return (
<Form

View File

@ -1,24 +1,38 @@
import { useQuery } from '@apollo/client'
import Link from 'next/link'
import { RELATED_ITEMS } from '../fragments/items'
import AccordianItem from './accordian-item'
import Items from './items'
import { NavigateFooter } from './more-footer'
const LIMIT = 5
import Item, { ItemSkeleton } from './item'
import styles from './items.module.css'
export default function Related ({ title, itemId }) {
const variables = { title, id: itemId, limit: LIMIT }
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)
}
return (
<AccordianItem
header={<div className='font-weight-bold'>related</div>}
body={
<Items
query={RELATED_ITEMS}
variables={variables}
destructureData={data => data.related}
Footer={props => <NavigateFooter {...props} href={`/items/${itemId}/related`} text='view all related items' />}
/>
}
<>
<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>}
</>
}
/>
)
}

View File

@ -3,6 +3,7 @@ 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'
@ -12,8 +13,8 @@ import Info from './info'
export function ReplyOnAnotherPage ({ parentId }) {
return (
<Link href={`/items/${parentId}`} className={`${styles.replyButtons} text-muted`}>
reply on another page
<Link href={`/items/${parentId}`}>
<a className={`${styles.replyButtons} text-muted`}>reply on another page</a>
</Link>
)
}
@ -109,41 +110,41 @@ export default function Reply ({ item, onSuccess, replyOpen, children, placehold
{/* HACK if we need more items, we should probably do a comment toolbar */}
{children}
</div>)}
{reply &&
<div className={styles.reply}>
<Form
initial={{
text: ''
}}
schema={commentSchema}
onSubmit={async (values, { resetForm }) => {
const { error } = await createComment({ variables: { ...values, parentId } })
if (error) {
throw new Error({ message: error.toString() })
}
resetForm({ text: '' })
setReply(replyOpen || false)
}}
storageKeyPrefix={'reply-' + parentId}
>
<MarkdownInput
name='text'
minRows={6}
autoFocus={!replyOpen}
required
placeholder={placeholder}
hint={me?.sats < 1 && <FreebieDialog />}
innerRef={replyInput}
/>
{reply &&
<div className='mt-1'>
<FeeButton
baseFee={1} parentId={parentId} text='reply'
ChildButton={SubmitButton} variant='secondary' alwaysShow
/>
</div>}
</Form>
</div>}
<div className={reply ? `${styles.reply}` : 'd-none'}>
<Form
initial={{
text: ''
}}
schema={commentSchema}
onSubmit={async (values, { resetForm }) => {
const { error } = await createComment({ variables: { ...values, parentId } })
if (error) {
throw new Error({ message: error.toString() })
}
resetForm({ text: '' })
setReply(replyOpen || false)
}}
storageKeyPrefix={'reply-' + parentId}
>
<MarkdownInput
name='text'
as={TextareaAutosize}
minRows={6}
autoFocus={!replyOpen}
required
placeholder={placeholder}
hint={me?.sats < 1 && <FreebieDialog />}
innerRef={replyInput}
/>
{reply &&
<div className='mt-1'>
<FeeButton
baseFee={1} parentId={parentId} text='reply'
ChildButton={SubmitButton} variant='secondary' alwaysShow
/>
</div>}
</Form>
</div>
</div>
)
}

View File

@ -0,0 +1,51 @@
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,9 +34,6 @@ export default function Search ({ sub }) {
await router.push({
pathname: '/stackers/search',
query: { q, what: 'stackers' }
}, {
pathname: '/stackers/search',
query: { q }
})
return
}
@ -53,7 +50,6 @@ 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}`}>
@ -64,7 +60,7 @@ export default function Search ({ sub }) {
className={styles.formActive}
initial={{
q: router.query.q || '',
what: what || '',
what: router.query.what || '',
sort: router.query.sort || '',
when: router.query.when || ''
}}
@ -79,7 +75,7 @@ export default function Search ({ sub }) {
size='sm'
items={['all', 'posts', 'comments', 'stackers']}
/>
{what !== 'stackers' &&
{router.query.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.nposts} posts, ${user.ncomments} comments]`
desc = `@${user.name} has [${user.stacked} stacked, ${user.nitems} posts, ${user.ncomments} comments]`
}
return (

View File

@ -2,9 +2,8 @@ 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 ({ ignorePreference }) {
export default function Snl () {
const [show, setShow] = useState()
const { data } = useQuery(gql`{ snl }`, {
fetchPolicy: 'cache-and-network'
@ -12,12 +11,14 @@ export default function Snl ({ ignorePreference }) {
useEffect(() => {
const dismissed = localStorage.getItem('snl')
if (!ignorePreference && dismissed && dismissed > new Date(dismissed) < dayPivot(new Date(), -6)) {
if (dismissed && dismissed > new Date(dismissed) < new Date(new Date().setDate(new Date().getDate() - 6))) {
return
}
setShow(data?.snl)
}, [data, ignorePreference])
if (data?.snl) {
setShow(true)
}
}, [data])
if (!show) return null

View File

@ -1,5 +1,5 @@
import { useMutation } from '@apollo/client'
import { gql } from 'graphql-tag'
import { gql } from 'apollo-server-micro'
import { Dropdown } from 'react-bootstrap'
export default function SubscribeDropdownItem ({ item: { id, meSubscription } }) {

View File

@ -40,7 +40,7 @@ export default function Toc ({ text }) {
style={{
marginLeft: `${(v.depth - 1) * 5}px`
}}
href={`#${v.slug}`} key={v.slug}
key={v.slug} href={`#${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, memo } from 'react'
import React, { useRef, useEffect, useState } from 'react'
import GithubSlugger from 'github-slugger'
import LinkIcon from '../svgs/link.svg'
import Thumb from '../svgs/thumb-up-fill.svg'
@ -16,7 +16,6 @@ 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) => {
@ -42,7 +41,7 @@ function Heading ({ h, slugger, noFragments, topLevel, children, node, ...props
const Icon = copied ? Thumb : LinkIcon
return (
<span className={styles.heading}>
<div className={styles.heading}>
{React.createElement(h, { id, ...props }, children)}
{!noFragments && topLevel &&
<a className={`${styles.headingLink} ${copied ? styles.copied : ''}`} href={`#${id}`}>
@ -59,7 +58,7 @@ function Heading ({ h, slugger, noFragments, topLevel, children, node, ...props
className='fill-grey'
/>
</a>}
</span>
</div>
)
}
@ -69,8 +68,7 @@ const CACHE_STATES = {
IS_ERROR: 'IS_ERROR'
}
// this is one of the slowest components to render
export default memo(function Text ({ topLevel, noFragments, nofollow, onlyImgProxy, children }) {
export default function Text ({ topLevel, noFragments, nofollow, onlyImgProxy, children }) {
// all the reactStringReplace calls are to facilitate search highlighting
const slugger = new GithubSlugger()
onlyImgProxy = onlyImgProxy ?? true
@ -123,10 +121,9 @@ export default memo(function Text ({ topLevel, noFragments, nofollow, onlyImgPro
h5: (props) => HeadingWrapper({ h: topLevel ? 'h5' : 'h6', ...props }),
h6: (props) => HeadingWrapper({ h: 'h6', ...props }),
table: ({ node, ...props }) =>
<span className='table-responsive'>
<div className='table-responsive'>
<table className='table table-bordered table-sm' {...props} />
</span>,
p: ({ children, ...props }) => <div className={styles.p} {...props}>{children}</div>,
</div>,
code ({ node, inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '')
return !inline
@ -182,10 +179,13 @@ export default memo(function Text ({ topLevel, noFragments, nofollow, onlyImgPro
</ReactMarkdown>
</div>
)
})
}
export function ZoomableImage ({ src, topLevel, ...props }) {
const [err, setErr] = useState()
if (!src) {
return null
}
const defaultMediaStyle = {
maxHeight: topLevel ? '75vh' : '25vh',
cursor: 'zoom-in'
@ -195,17 +195,17 @@ export function ZoomableImage ({ src, topLevel, ...props }) {
const [mediaStyle, setMediaStyle] = useState(defaultMediaStyle)
useEffect(() => {
setMediaStyle(defaultMediaStyle)
setErr(null)
}, [src])
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>
)
const handleClick = () => {
if (mediaStyle.cursor === 'zoom-in') {
setMediaStyle({
width: '100%',
cursor: 'zoom-out'
})
} else {
setMediaStyle(defaultMediaStyle)
}
}
return (
@ -213,17 +213,7 @@ export function ZoomableImage ({ src, topLevel, ...props }) {
className={topLevel ? styles.topLevel : undefined}
style={mediaStyle}
src={src}
onClick={() => {
if (mediaStyle.cursor === 'zoom-in') {
setMediaStyle({
width: '100%',
cursor: 'zoom-out'
})
} else {
setMediaStyle(defaultMediaStyle)
}
}}
onError={() => setErr(true)}
onClick={handleClick}
{...props}
/>
)

View File

@ -15,7 +15,6 @@
position: relative;
margin-left: -22px;
padding-left: 22px;
display: block;
}
.headingLink {
@ -24,7 +23,6 @@
left: 0px;
top: 0px;
height: 100%;
width: 44px;
}
.headingLink.copied {
@ -43,7 +41,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,6 +1,8 @@
import { useRouter } from 'next/router'
import { Form, Select } from './form'
import { ITEM_SORTS, USER_SORTS, WHENS } from '../lib/constants'
const USER_SORTS = ['stacked', 'spent', 'comments', 'posts', 'referrals']
const ITEM_SORTS = ['votes', 'comments', 'sats']
export default function TopHeader ({ sub, cat }) {
const router = useRouter()
@ -17,11 +19,11 @@ export default function TopHeader ({ sub, cat }) {
const prefix = sub ? `/~${sub}` : ''
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
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
}
}
@ -37,7 +39,7 @@ export default function TopHeader ({ sub, cat }) {
className='mr-auto'
initial={{
what: cat,
by: router.query.by || '',
sort: router.query.sort || '',
when: router.query.when || ''
}}
onSubmit={top}
@ -56,8 +58,8 @@ export default function TopHeader ({ sub, cat }) {
by
<Select
groupClassName='mx-2 mb-0'
onChange={(formik, e) => top({ ...formik?.values, by: e.target.value })}
name='by'
onChange={(formik, e) => top({ ...formik?.values, sort: e.target.value })}
name='sort'
size='sm'
items={cat === 'stackers' ? USER_SORTS : ITEM_SORTS}
/>
@ -67,7 +69,7 @@ export default function TopHeader ({ sub, cat }) {
onChange={(formik, e) => top({ ...formik?.values, when: e.target.value })}
name='when'
size='sm'
items={WHENS}
items={['day', 'week', 'month', 'year', 'forever']}
/>
</>}

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 { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { 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 = useCallback((yes) => {
const setVoteShow = (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 = useCallback((yes) => {
const setTipShow = (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,20 +161,18 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
}
if (pendingSats > 0) {
timerRef.current = setTimeout(async (sats) => {
timerRef.current = setTimeout(async (pendingSats) => {
try {
timerRef.current && setPendingSats(0)
await act({
variables: { id: item.id, sats },
variables: { id: item.id, sats: pendingSats },
optimisticResponse: {
act: {
sats
sats: pendingSats
}
}
})
} catch (error) {
if (!timerRef.current) return
if (error.toString().includes('insufficient funds')) {
showModal(onClose => {
return <FundError onClose={onClose} />
@ -183,14 +181,14 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
}
throw new Error({ message: error.toString() })
}
}, 500, pendingSats)
}, 1000, pendingSats)
}
return async () => {
return () => {
clearTimeout(timerRef.current)
timerRef.current = null
}
}, [pendingSats, act, item, showModal, setPendingSats])
}, [item, pendingSats, act, setPendingSats, showModal])
const disabled = useMemo(() => {
return item?.mine || (me && Number(me.id) === item?.fwdUserId) || item?.deletedAt
@ -215,7 +213,7 @@ export default function UpVote ({ item, className, pendingSats, setPendingSats }
return (
<LightningConsumer>
{(strike) =>
{({ strike }) =>
<div ref={ref} className='upvoteParent'>
<LongPressable
onLongPress={

View File

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

View File

@ -10,38 +10,20 @@ 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!) {
@ -60,139 +42,138 @@ function HeaderPhoto ({ user, isMe }) {
}
)
return (
<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'
className={styles.userimg}
/>
{isMe &&
<Avatar onSuccess={async photoId => {
const { error } = await setPhoto({ variables: { photoId } })
if (error) {
console.log(error)
}
}}
/>}
</div>
)
}
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={{
name: user.name
}}
validateImmediately
onSubmit={async ({ name }) => {
if (name === user.name) {
setEditting(false)
return
}
const { error } = await setName({ variables: { name } })
if (error) {
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 }
}, undefined, { shallow: true })
}}
>
<div className='d-flex align-items-center mb-2'>
<Input
prepend=<InputGroup.Text>@</InputGroup.Text>
name='name'
autoFocus
groupClassName={styles.usernameForm}
showValid
/>
<SubmitButton variant='link' onClick={() => setEditting(true)}>save</SubmitButton>
</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 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'>
<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} />
<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>
<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}`} 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 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'
className={styles.userimg}
/>
{isMe &&
<Avatar onSuccess={async photoId => {
const { error } = await setPhoto({ variables: { photoId } })
if (error) {
console.log(error)
}
}}
/>}
</div>
<div className='ml-0 ml-sm-3 mt-3 mt-sm-0 justify-content-center align-self-sm-center'>
{editting
? (
<Form
schema={schema}
initial={{
name: user.name
}}
validateImmediately
onSubmit={async ({ name }) => {
if (name === user.name) {
setEditting(false)
return
}
const { error } = await setName({ variables: { name } })
if (error) {
throw new Error({ message: error.toString() })
}
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)
}}
>
<div className='d-flex align-items-center mb-2'>
<Input
prepend=<InputGroup.Text>@</InputGroup.Text>
name='name'
autoFocus
groupClassName={styles.usernameForm}
showValid
/>
<SubmitButton variant='link' onClick={() => setEditting(true)}>save</SubmitButton>
</div>
</Form>
)
: (
<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>
)}
<Satistics user={user} />
<ModalButton
clicker={
<Button className='font-weight-bold ml-0'>
<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>
: <span>never</span>}
</small>
<small className='text-muted d-flex-inline'>longest cowboy streak: {user.maxStreak !== null ? user.maxStreak : 'none'}</small>
</div>
</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,21 +4,16 @@
}
.nav {
margin: 1rem 0;
justify-content: start;
font-size: 110%;
margin-top: 1rem;
justify-content: space-between;
}
.nav :global .nav-link {
.nav div:first-child a {
padding-left: 0;
}
.nav :global .nav-item:not(:first-child) {
margin-left: 1rem;
}
.nav :global .active {
border-bottom: 2px solid var(--primary);
.nav div:last-child a {
padding-right: 0;
}
.userimg {

View File

@ -4,20 +4,18 @@ 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, useMemo, useState } from 'react'
import { useQuery } from '@apollo/client'
import MoreFooter from './more-footer'
import { useEffect, useState } from 'react'
// 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`} className='text-reset'>
{abbrNum(user.nposts)} posts
<Link href={`/${user.name}/posts`} passHref>
<a className='text-reset'>{abbrNum(user.nitems)} posts</a>
</Link>)
const Comments = ({ user }) => (
<Link href={`/${user.name}/comments`} className='text-reset'>
{abbrNum(user.ncomments)} comments
<Link href={`/${user.name}/comments`} passHref>
<a className='text-reset'>{abbrNum(user.ncomments)} comments</a>
</Link>)
const Referrals = ({ user }) => (<span>{abbrNum(user.referrals)} referrals</span>)
const Seperator = () => (<span> \ </span>)
@ -35,52 +33,40 @@ function seperate (arr, seperator) {
return arr.flatMap((x, i) => i < arr.length - 1 ? [x, seperator] : [x])
}
export default function UserList ({ ssrData, query, variables, destructureData }) {
const { data, fetchMore } = useQuery(query, { variables })
export default function UserList ({ users, sort }) {
const [statComps, setStatComps] = useState(seperate(STAT_COMPONENTS, Seperator))
useEffect(() => {
if (variables.by) {
if (sort) {
// shift the stat we are sorting by to the front
const comps = [...STAT_COMPONENTS]
setStatComps(seperate([...comps.splice(STAT_POS[variables.by], 1), ...comps], Seperator))
setStatComps(seperate([...comps.splice(STAT_POS[sort], 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 => (
<div className={`${styles.item} mb-2`} key={user.name}>
<Link href={`/${user.name}`}>
<> {users.map(user => (
<div className={`${styles.item} mb-2`} key={user.name}>
<Link href={`/${user.name}`} passHref>
<a>
<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`}
/>
</Link>
<div className={styles.hunk}>
<Link href={`/${user.name}`} className={`${styles.title} d-inline-flex align-items-center text-reset`}>
</a>
</Link>
<div className={styles.hunk}>
<Link href={`/${user.name}`} passHref>
<a 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} />
</Link>
<div className={styles.other}>
{statComps.map((Comp, i) => <Comp key={i} user={user} />)}
</div>
</a>
</Link>
<div className={styles.other}>
{statComps.map((Comp, i) => <Comp key={i} user={user} />)}
</div>
</div>
))}
<MoreFooter cursor={cursor} count={users?.length} fetchMore={fetchMore} Skeleton={UsersSkeleton} noMoreText='NO MORE' />
</div>
))}
</>
)
}
@ -92,8 +78,7 @@ export function UsersSkeleton () {
<div>{users.map((_, i) => (
<div className={`${styles.item} ${styles.skeleton} mb-2`} key={i}>
<Image
src={`${process.env.NEXT_PUBLIC_ASSET_PREFIX}/clouds.jpeg`}
width='32' height='32'
src='/clouds.jpeg' width='32' height='32'
className={`${userStyles.userimg} clouds mr-2`}
/>
<div className={styles.hunk}>

View File

@ -31,23 +31,57 @@ export const COMMENT_FIELDS = gql`
}
`
export const COMMENTS_ITEM_EXT_FIELDS = gql`
fragment CommentItemExtFields on Item {
text
root {
id
title
bounty
bountyPaidTo
subName
user {
name
streak
hideCowboyHat
id
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
root {
id
title
bounty
bountyPaidTo
subName
user {
name
streak
hideCowboyHat
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,6 +91,72 @@ 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 {
@ -151,6 +217,31 @@ 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,8 +2,6 @@ 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}
@ -15,7 +13,6 @@ export const NOTIFICATIONS = gql`
notifications {
__typename
... on Mention {
id
sortTime
mention
item {
@ -24,7 +21,6 @@ export const NOTIFICATIONS = gql`
}
}
... on Votification {
id
sortTime
earnedSats
item {
@ -38,7 +34,6 @@ export const NOTIFICATIONS = gql`
days
}
... on Earn {
id
sortTime
earnedSats
sources {
@ -49,11 +44,9 @@ export const NOTIFICATIONS = gql`
}
}
... on Referral {
id
sortTime
}
... on Reply {
id
sortTime
item {
...ItemFullFields
@ -61,21 +54,18 @@ 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, ITEM_FULL_FIELDS } from './items'
import { COMMENTS_ITEM_EXT_FIELDS } from './comments'
import { ITEM_FIELDS } from './items'
import { COMMENT_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,43 +22,119 @@ export const SUB = gql`
export const SUB_ITEMS = gql`
${SUB_FIELDS}
${ITEM_FIELDS}
${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) {
query SubItems($sub: String!, $sort: String, $type: String) {
sub(name: $sub) {
...SubFields
}
items(sub: $sub, sort: $sort, cursor: $cursor, type: $type, name: $name, when: $when, by: $by, limit: $limit) {
items(sub: $sub, sort: $sort, type: $type) {
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_FULL_FIELDS}
query SubSearch($sub: String, $q: String, $cursor: String, $sort: String, $what: String, $when: String) {
${ITEM_FIELDS}
query SubSearch($sub: String!, $q: String, $cursor: String) {
sub(name: $sub) {
...SubFields
}
search(sub: $sub, q: $q, cursor: $cursor, sort: $sort, what: $what, when: $when) {
search(q: $q, cursor: $cursor) {
cursor
items {
...ItemFullFields
...ItemFields
text
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 { COMMENTS_ITEM_EXT_FIELDS } from './comments'
import { ITEM_FIELDS, ITEM_WITH_COMMENTS } from './items'
import { COMMENT_FIELDS } from './comments'
import { ITEM_FIELDS, ITEM_FULL_FIELDS, ITEM_WITH_COMMENTS } from './items'
export const ME = gql`
{
@ -116,27 +116,35 @@ gql`
stacked
spent
ncomments
nposts
nitems
referrals
}
}`
export const USER_FIELDS = gql`
${ITEM_FIELDS}
fragment UserFields on User {
id
createdAt
name
streak
maxStreak
hideCowboyHat
nitems
ncomments
nbookmarks
stacked
since
sats
photoId
bio {
...ItemFields
text
}
}`
export const TOP_USERS = gql`
query TopUsers($cursor: String, $when: String, $by: String) {
topUsers(cursor: $cursor, when: $when, by: $by) {
query TopUsers($cursor: String, $when: String, $sort: String) {
topUsers(cursor: $cursor, when: $when, sort: $sort) {
users {
name
streak
@ -145,7 +153,7 @@ export const TOP_USERS = gql`
stacked(when: $when)
spent(when: $when)
ncomments(when: $when)
nposts(when: $when)
nitems(when: $when)
referrals(when: $when)
}
cursor
@ -164,7 +172,7 @@ export const TOP_COWBOYS = gql`
stacked(when: "forever")
spent(when: "forever")
ncomments(when: "forever")
nposts(when: "forever")
nitems(when: "forever")
referrals(when: "forever")
}
cursor
@ -178,25 +186,74 @@ export const USER_FULL = gql`
query User($name: String!) {
user(name: $name) {
...UserFields
since
bio {
...ItemWithComments
}
}
}`
export const USER_WITH_ITEMS = gql`
export const USER_WITH_COMMENTS = gql`
${USER_FIELDS}
${ITEM_FIELDS}
${COMMENTS_ITEM_EXT_FIELDS}
query UserWithItems($name: String!, $sub: String, $cursor: String, $type: String, $when: String, $by: String, $limit: Int, $includeComments: Boolean = false) {
${COMMENT_FIELDS}
query UserWithComments($name: String!) {
user(name: $name) {
...UserFields
since
}
items(sub: $sub, sort: "user", cursor: $cursor, type: $type, name: $name, when: $when, by: $by, limit: $limit) {
moreFlatComments(sort: "user", name: $name) {
cursor
items {
...ItemFields
...CommentItemExtFields @include(if: $includeComments)
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`
${USER_FIELDS}
${ITEM_FIELDS}
query UserWithPosts($name: String!) {
user(name: $name) {
...UserFields
since
}
items(sort: "user", name: $name) {
cursor
items {
...ItemFields
}
pins {
...ItemFields
}
}
}`

461
lexical/nodes/image.js Normal file
View File

@ -0,0 +1,461 @@
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

@ -0,0 +1,34 @@
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

@ -0,0 +1,248 @@
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

@ -0,0 +1,129 @@
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

@ -0,0 +1,232 @@
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