Compare commits
27 Commits
34bfa89e74
...
2bc441a618
Author | SHA1 | Date | |
---|---|---|---|
|
2bc441a618 | ||
|
0f553b1aef | ||
|
8329da1f56 | ||
|
b1f850ee0e | ||
|
8a19fc0905 | ||
|
286f53f2b3 | ||
|
cbcae1d128 | ||
|
967b5b74fb | ||
|
93713b33df | ||
|
569d0448c2 | ||
|
35be035850 | ||
|
09f9efa189 | ||
|
79ed07ae74 | ||
|
23c51df283 | ||
|
1dcb6461c7 | ||
|
2775b49ce7 | ||
|
ea97fbf4a4 | ||
|
d8fe698963 | ||
|
061d3f220d | ||
|
c90eb055c7 | ||
|
7b667821d2 | ||
|
b4c120ab39 | ||
|
e3571af1e1 | ||
|
2597eb56f3 | ||
|
d454bbdb72 | ||
|
86b857b8d4 | ||
|
e9aa268996 |
@ -1,8 +1,8 @@
|
|||||||
export default {
|
export default {
|
||||||
Query: {
|
Query: {
|
||||||
snl: async (parent, _, { models }) => {
|
snl: async (parent, _, { models }) => {
|
||||||
const { live } = await models.snl.findFirst()
|
const snl = await models.snl.findFirst()
|
||||||
return live
|
return !!snl?.live
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Mutation: {
|
Mutation: {
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { ANON_USER_ID, AWS_S3_URL_REGEXP } from '@/lib/constants'
|
import { USER_ID, AWS_S3_URL_REGEXP } from '@/lib/constants'
|
||||||
import { msatsToSats } from '@/lib/format'
|
import { msatsToSats } from '@/lib/format'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@ -17,7 +17,7 @@ export function uploadIdsFromText (text, { models }) {
|
|||||||
export async function imageFeesInfo (s3Keys, { models, me }) {
|
export async function imageFeesInfo (s3Keys, { models, me }) {
|
||||||
// returns info object in this format:
|
// returns info object in this format:
|
||||||
// { bytes24h: int, bytesUnpaid: int, nUnpaid: int, imageFeeMsats: BigInt }
|
// { bytes24h: int, bytesUnpaid: int, nUnpaid: int, imageFeeMsats: BigInt }
|
||||||
const [info] = await models.$queryRawUnsafe('SELECT * FROM image_fees_info($1::INTEGER, $2::INTEGER[])', me ? me.id : ANON_USER_ID, s3Keys)
|
const [info] = await models.$queryRawUnsafe('SELECT * FROM image_fees_info($1::INTEGER, $2::INTEGER[])', me ? me.id : USER_ID.anon, s3Keys)
|
||||||
const imageFee = msatsToSats(info.imageFeeMsats)
|
const imageFee = msatsToSats(info.imageFeeMsats)
|
||||||
const totalFeesMsats = info.nUnpaid * Number(info.imageFeeMsats)
|
const totalFeesMsats = info.nUnpaid * Number(info.imageFeeMsats)
|
||||||
const totalFees = msatsToSats(totalFeesMsats)
|
const totalFees = msatsToSats(totalFeesMsats)
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { GraphQLError } from 'graphql'
|
import { GraphQLError } from 'graphql'
|
||||||
import { ensureProtocol, removeTracking, stripTrailingSlash } from '@/lib/url'
|
import { ensureProtocol, parseInternalLinks, removeTracking, stripTrailingSlash } from '@/lib/url'
|
||||||
import serialize from './serial'
|
import serialize from './serial'
|
||||||
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
|
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
|
||||||
import { getMetadata, metadataRuleSets } from 'page-metadata-parser'
|
import { getMetadata, metadataRuleSets } from 'page-metadata-parser'
|
||||||
@ -8,14 +8,14 @@ import domino from 'domino'
|
|||||||
import {
|
import {
|
||||||
ITEM_SPAM_INTERVAL, ITEM_FILTER_THRESHOLD,
|
ITEM_SPAM_INTERVAL, ITEM_FILTER_THRESHOLD,
|
||||||
COMMENT_DEPTH_LIMIT, COMMENT_TYPE_QUERY,
|
COMMENT_DEPTH_LIMIT, COMMENT_TYPE_QUERY,
|
||||||
ANON_USER_ID, ANON_ITEM_SPAM_INTERVAL, POLL_COST,
|
USER_ID, ANON_ITEM_SPAM_INTERVAL, POLL_COST,
|
||||||
ITEM_ALLOW_EDITS, GLOBAL_SEED, ANON_FEE_MULTIPLIER, NOFOLLOW_LIMIT, UNKNOWN_LINK_REL
|
ITEM_ALLOW_EDITS, GLOBAL_SEED, ANON_FEE_MULTIPLIER, NOFOLLOW_LIMIT, UNKNOWN_LINK_REL, SN_USER_IDS
|
||||||
} from '@/lib/constants'
|
} from '@/lib/constants'
|
||||||
import { msatsToSats } from '@/lib/format'
|
import { msatsToSats } from '@/lib/format'
|
||||||
import { parse } from 'tldts'
|
import { parse } from 'tldts'
|
||||||
import uu from 'url-unshort'
|
import uu from 'url-unshort'
|
||||||
import { actSchema, advSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, ssValidate } from '@/lib/validate'
|
import { actSchema, advSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, ssValidate } from '@/lib/validate'
|
||||||
import { notifyItemParents, notifyUserSubscribers, notifyZapped, notifyTerritorySubscribers, notifyMention } from '@/lib/webPush'
|
import { notifyItemParents, notifyUserSubscribers, notifyZapped, notifyTerritorySubscribers, notifyMention, notifyItemMention } from '@/lib/webPush'
|
||||||
import { defaultCommentSort, isJob, deleteItemByAuthor, getDeleteCommand, hasDeleteCommand, getReminderCommand, hasReminderCommand } from '@/lib/item'
|
import { defaultCommentSort, isJob, deleteItemByAuthor, getDeleteCommand, hasDeleteCommand, getReminderCommand, hasReminderCommand } from '@/lib/item'
|
||||||
import { datePivot, whenRange } from '@/lib/time'
|
import { datePivot, whenRange } from '@/lib/time'
|
||||||
import { imageFeesInfo, uploadIdsFromText } from './image'
|
import { imageFeesInfo, uploadIdsFromText } from './image'
|
||||||
@ -825,7 +825,7 @@ export default {
|
|||||||
|
|
||||||
await serialize(
|
await serialize(
|
||||||
models.$queryRawUnsafe(`${SELECT} FROM poll_vote($1::INTEGER, $2::INTEGER) AS "Item"`, Number(id), Number(me.id)),
|
models.$queryRawUnsafe(`${SELECT} FROM poll_vote($1::INTEGER, $2::INTEGER) AS "Item"`, Number(id), Number(me.id)),
|
||||||
{ models, lnd, me, hash, hmac }
|
{ models, lnd, me, hash, hmac, verifyPayment: !!hash || !me }
|
||||||
)
|
)
|
||||||
|
|
||||||
return id
|
return id
|
||||||
@ -859,7 +859,7 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (idempotent) {
|
if (me && idempotent) {
|
||||||
await serialize(
|
await serialize(
|
||||||
models.$queryRaw`
|
models.$queryRaw`
|
||||||
SELECT
|
SELECT
|
||||||
@ -869,15 +869,15 @@ export default {
|
|||||||
WHERE act IN ('TIP', 'FEE')
|
WHERE act IN ('TIP', 'FEE')
|
||||||
AND "itemId" = ${Number(id)}::INTEGER
|
AND "itemId" = ${Number(id)}::INTEGER
|
||||||
AND "userId" = ${me.id}::INTEGER)::INTEGER)`,
|
AND "userId" = ${me.id}::INTEGER)::INTEGER)`,
|
||||||
{ models, lnd, hash, hmac }
|
{ models, lnd, hash, hmac, verifyPayment: !!hash }
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
await serialize(
|
await serialize(
|
||||||
models.$queryRaw`
|
models.$queryRaw`
|
||||||
SELECT
|
SELECT
|
||||||
item_act(${Number(id)}::INTEGER,
|
item_act(${Number(id)}::INTEGER,
|
||||||
${me?.id || ANON_USER_ID}::INTEGER, ${act}::"ItemActType", ${Number(sats)}::INTEGER)`,
|
${me?.id || USER_ID.anon}::INTEGER, ${act}::"ItemActType", ${Number(sats)}::INTEGER)`,
|
||||||
{ models, lnd, me, hash, hmac, fee: sats }
|
{ models, lnd, me, hash, hmac, fee: sats, verifyPayment: !!hash || !me }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1004,8 +1004,7 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const options = await models.$queryRaw`
|
const options = await models.$queryRaw`
|
||||||
SELECT "PollOption".id, option, count("PollVote"."userId")::INTEGER as count,
|
SELECT "PollOption".id, option, count("PollVote".id)::INTEGER as count
|
||||||
coalesce(bool_or("PollVote"."userId" = ${me?.id}), 'f') as "meVoted"
|
|
||||||
FROM "PollOption"
|
FROM "PollOption"
|
||||||
LEFT JOIN "PollVote" on "PollVote"."pollOptionId" = "PollOption".id
|
LEFT JOIN "PollVote" on "PollVote"."pollOptionId" = "PollOption".id
|
||||||
WHERE "PollOption"."itemId" = ${item.id}
|
WHERE "PollOption"."itemId" = ${item.id}
|
||||||
@ -1013,9 +1012,16 @@ export default {
|
|||||||
ORDER BY "PollOption".id ASC
|
ORDER BY "PollOption".id ASC
|
||||||
`
|
`
|
||||||
|
|
||||||
|
const meVoted = await models.pollBlindVote.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: me?.id,
|
||||||
|
itemId: item.id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const poll = {}
|
const poll = {}
|
||||||
poll.options = options
|
poll.options = options
|
||||||
poll.meVoted = options.some(o => o.meVoted)
|
poll.meVoted = !!meVoted
|
||||||
poll.count = options.reduce((t, o) => t + o.count, 0)
|
poll.count = options.reduce((t, o) => t + o.count, 0)
|
||||||
|
|
||||||
return poll
|
return poll
|
||||||
@ -1157,7 +1163,7 @@ export default {
|
|||||||
return parent.otsHash
|
return parent.otsHash
|
||||||
},
|
},
|
||||||
deleteScheduledAt: async (item, args, { me, models }) => {
|
deleteScheduledAt: async (item, args, { me, models }) => {
|
||||||
const meId = me?.id ?? ANON_USER_ID
|
const meId = me?.id ?? USER_ID.anon
|
||||||
if (meId !== item.userId) {
|
if (meId !== item.userId) {
|
||||||
// Only query for deleteScheduledAt for your own items to keep DB queries minimized
|
// Only query for deleteScheduledAt for your own items to keep DB queries minimized
|
||||||
return null
|
return null
|
||||||
@ -1166,8 +1172,8 @@ export default {
|
|||||||
return deleteJobs[0]?.startafter ?? null
|
return deleteJobs[0]?.startafter ?? null
|
||||||
},
|
},
|
||||||
reminderScheduledAt: async (item, args, { me, models }) => {
|
reminderScheduledAt: async (item, args, { me, models }) => {
|
||||||
const meId = me?.id ?? ANON_USER_ID
|
const meId = me?.id ?? USER_ID.anon
|
||||||
if (meId !== item.userId || meId === ANON_USER_ID) {
|
if (meId !== item.userId || meId === USER_ID.anon) {
|
||||||
// don't show reminders on an item if it isn't yours
|
// don't show reminders on an item if it isn't yours
|
||||||
// don't support reminders for ANON
|
// don't support reminders for ANON
|
||||||
return null
|
return null
|
||||||
@ -1179,6 +1185,7 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const namePattern = /\B@[\w_]+/gi
|
const namePattern = /\B@[\w_]+/gi
|
||||||
|
const refPattern = new RegExp(`${process.env.NEXT_PUBLIC_URL}/items/\\d+.*`, 'gi')
|
||||||
|
|
||||||
export const createMentions = async (item, models) => {
|
export const createMentions = async (item, models) => {
|
||||||
// if we miss a mention, in the rare circumstance there's some kind of
|
// if we miss a mention, in the rare circumstance there's some kind of
|
||||||
@ -1188,9 +1195,25 @@ export const createMentions = async (item, models) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// user mentions
|
||||||
try {
|
try {
|
||||||
|
await createUserMentions(item, models)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('user mention failure', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// item mentions
|
||||||
|
try {
|
||||||
|
await createItemMentions(item, models)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('item mention failure', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createUserMentions = async (item, models) => {
|
||||||
const mentions = item.text.match(namePattern)?.map(m => m.slice(1))
|
const mentions = item.text.match(namePattern)?.map(m => m.slice(1))
|
||||||
if (mentions?.length > 0) {
|
if (!mentions || mentions.length === 0) return
|
||||||
|
|
||||||
const users = await models.user.findMany({
|
const users = await models.user.findMany({
|
||||||
where: {
|
where: {
|
||||||
name: { in: mentions },
|
name: { in: mentions },
|
||||||
@ -1218,19 +1241,66 @@ export const createMentions = async (item, models) => {
|
|||||||
notifyMention({ models, userId: user.id, item })
|
notifyMention({ models, userId: user.id, item })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const createItemMentions = async (item, models) => {
|
||||||
|
const refs = item.text.match(refPattern)?.map(m => {
|
||||||
|
try {
|
||||||
|
const { itemId, commentId } = parseInternalLinks(m)
|
||||||
|
return Number(commentId || itemId)
|
||||||
|
} catch (err) {
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
} catch (e) {
|
}).filter(r => !!r)
|
||||||
console.error('mention failure', e)
|
if (!refs || refs.length === 0) return
|
||||||
|
|
||||||
|
const referee = await models.item.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: refs },
|
||||||
|
// Don't create mentions for your own items
|
||||||
|
userId: { not: item.userId }
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
referee.forEach(async r => {
|
||||||
|
const data = {
|
||||||
|
referrerId: item.id,
|
||||||
|
refereeId: r.id
|
||||||
|
}
|
||||||
|
|
||||||
|
const mention = await models.itemMention.upsert({
|
||||||
|
where: {
|
||||||
|
referrerId_refereeId: data
|
||||||
|
},
|
||||||
|
update: data,
|
||||||
|
create: data
|
||||||
|
})
|
||||||
|
|
||||||
|
// only send if mention is new to avoid duplicates
|
||||||
|
if (mention.createdAt.getTime() === mention.updatedAt.getTime()) {
|
||||||
|
notifyItemMention({ models, referrerItem: item, refereeItem: r })
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const updateItem = async (parent, { sub: subName, forward, options, ...item }, { me, models, lnd, hash, hmac }) => {
|
export const updateItem = async (parent, { sub: subName, forward, options, ...item }, { me, models, lnd, hash, hmac }) => {
|
||||||
// update iff this item belongs to me
|
// update iff this item belongs to me
|
||||||
const old = await models.item.findUnique({ where: { id: Number(item.id) }, include: { sub: true } })
|
const old = await models.item.findUnique({ where: { id: Number(item.id) }, include: { sub: true } })
|
||||||
if (Number(old.userId) !== Number(me?.id)) {
|
|
||||||
|
// author can always edit their own item
|
||||||
|
const mid = Number(me?.id)
|
||||||
|
const isMine = Number(old.userId) === mid
|
||||||
|
|
||||||
|
// allow admins to edit special items
|
||||||
|
const allowEdit = ITEM_ALLOW_EDITS.includes(old.id)
|
||||||
|
const adminEdit = SN_USER_IDS.includes(mid) && allowEdit
|
||||||
|
|
||||||
|
if (!isMine && !adminEdit) {
|
||||||
throw new GraphQLError('item does not belong to you', { extensions: { code: 'FORBIDDEN' } })
|
throw new GraphQLError('item does not belong to you', { extensions: { code: 'FORBIDDEN' } })
|
||||||
}
|
}
|
||||||
if (subName && old.subName !== subName) {
|
|
||||||
|
const differentSub = subName && old.subName !== subName
|
||||||
|
if (differentSub) {
|
||||||
const sub = await models.sub.findUnique({ where: { name: subName } })
|
const sub = await models.sub.findUnique({ where: { name: subName } })
|
||||||
if (old.freebie) {
|
if (old.freebie) {
|
||||||
if (!sub.allowFreebies) {
|
if (!sub.allowFreebies) {
|
||||||
@ -1244,10 +1314,13 @@ export const updateItem = async (parent, { sub: subName, forward, options, ...it
|
|||||||
// in case they lied about their existing boost
|
// in case they lied about their existing boost
|
||||||
await ssValidate(advSchema, { boost: item.boost }, { models, me, existingBoost: old.boost })
|
await ssValidate(advSchema, { boost: item.boost }, { models, me, existingBoost: old.boost })
|
||||||
|
|
||||||
// prevent update if it's not explicitly allowed, not their bio, not their job and older than 10 minutes
|
|
||||||
const user = await models.user.findUnique({ where: { id: me.id } })
|
const user = await models.user.findUnique({ where: { id: me.id } })
|
||||||
if (!ITEM_ALLOW_EDITS.includes(old.id) && user.bioId !== old.id &&
|
|
||||||
!isJob(item) && Date.now() > new Date(old.createdAt).getTime() + 10 * 60000) {
|
// prevent update if it's not explicitly allowed, not their bio, not their job and older than 10 minutes
|
||||||
|
const myBio = user.bioId === old.id
|
||||||
|
const timer = Date.now() < new Date(old.createdAt).getTime() + 10 * 60_000
|
||||||
|
|
||||||
|
if (!allowEdit && !myBio && !timer) {
|
||||||
throw new GraphQLError('item can no longer be editted', { extensions: { code: 'BAD_INPUT' } })
|
throw new GraphQLError('item can no longer be editted', { extensions: { code: 'BAD_INPUT' } })
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1266,7 +1339,7 @@ export const updateItem = async (parent, { sub: subName, forward, options, ...it
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
item = { subName, userId: me.id, ...item }
|
item = { subName, userId: old.userId, ...item }
|
||||||
const fwdUsers = await getForwardUsers(models, forward)
|
const fwdUsers = await getForwardUsers(models, forward)
|
||||||
|
|
||||||
const uploadIds = uploadIdsFromText(item.text, { models })
|
const uploadIds = uploadIdsFromText(item.text, { models })
|
||||||
@ -1275,7 +1348,7 @@ export const updateItem = async (parent, { sub: subName, forward, options, ...it
|
|||||||
([item] = await serialize(
|
([item] = await serialize(
|
||||||
models.$queryRawUnsafe(`${SELECT} FROM update_item($1::JSONB, $2::JSONB, $3::JSONB, $4::INTEGER[]) AS "Item"`,
|
models.$queryRawUnsafe(`${SELECT} FROM update_item($1::JSONB, $2::JSONB, $3::JSONB, $4::INTEGER[]) AS "Item"`,
|
||||||
JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options), uploadIds),
|
JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options), uploadIds),
|
||||||
{ models, lnd, me, hash, hmac, fee: imgFees }
|
{ models, lnd, me, hash, hmac, fee: imgFees, verifyPayment: !!hash || !me }
|
||||||
))
|
))
|
||||||
|
|
||||||
await createMentions(item, models)
|
await createMentions(item, models)
|
||||||
@ -1303,7 +1376,7 @@ export const createItem = async (parent, { forward, options, ...item }, { me, mo
|
|||||||
item.subName = item.sub
|
item.subName = item.sub
|
||||||
delete item.sub
|
delete item.sub
|
||||||
|
|
||||||
item.userId = me ? Number(me.id) : ANON_USER_ID
|
item.userId = me ? Number(me.id) : USER_ID.anon
|
||||||
|
|
||||||
const fwdUsers = await getForwardUsers(models, forward)
|
const fwdUsers = await getForwardUsers(models, forward)
|
||||||
if (item.url && !isJob(item)) {
|
if (item.url && !isJob(item)) {
|
||||||
@ -1332,7 +1405,7 @@ export const createItem = async (parent, { forward, options, ...item }, { me, mo
|
|||||||
models.$queryRawUnsafe(
|
models.$queryRawUnsafe(
|
||||||
`${SELECT} FROM create_item($1::JSONB, $2::JSONB, $3::JSONB, '${spamInterval}'::INTERVAL, $4::INTEGER[]) AS "Item"`,
|
`${SELECT} FROM create_item($1::JSONB, $2::JSONB, $3::JSONB, '${spamInterval}'::INTERVAL, $4::INTEGER[]) AS "Item"`,
|
||||||
JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options), uploadIds),
|
JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options), uploadIds),
|
||||||
{ models, lnd, me, hash, hmac, fee }
|
{ models, lnd, me, hash, hmac, fee, verifyPayment: !!hash || !me }
|
||||||
))
|
))
|
||||||
|
|
||||||
await createMentions(item, models)
|
await createMentions(item, models)
|
||||||
@ -1367,7 +1440,7 @@ const enqueueDeletionJob = async (item, models) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const deleteReminderAndJob = async ({ me, item, models }) => {
|
const deleteReminderAndJob = async ({ me, item, models }) => {
|
||||||
if (me?.id && me.id !== ANON_USER_ID) {
|
if (me?.id && me.id !== USER_ID.anon) {
|
||||||
await models.$transaction([
|
await models.$transaction([
|
||||||
models.$queryRawUnsafe(`
|
models.$queryRawUnsafe(`
|
||||||
DELETE FROM pgboss.job
|
DELETE FROM pgboss.job
|
||||||
@ -1389,7 +1462,7 @@ const deleteReminderAndJob = async ({ me, item, models }) => {
|
|||||||
|
|
||||||
const createReminderAndJob = async ({ me, item, models }) => {
|
const createReminderAndJob = async ({ me, item, models }) => {
|
||||||
// disallow anon to use reminder
|
// disallow anon to use reminder
|
||||||
if (!me || me.id === ANON_USER_ID) {
|
if (!me || me.id === USER_ID.anon) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const reminderCommand = getReminderCommand(item.text)
|
const reminderCommand = getReminderCommand(item.text)
|
||||||
|
@ -140,6 +140,22 @@ export default {
|
|||||||
LIMIT ${LIMIT}`
|
LIMIT ${LIMIT}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// item mentions
|
||||||
|
if (meFull.noteItemMentions) {
|
||||||
|
itemDrivenQueries.push(
|
||||||
|
`SELECT "Referrer".*, "ItemMention".created_at AS "sortTime", 'ItemMention' AS type
|
||||||
|
FROM "ItemMention"
|
||||||
|
JOIN "Item" "Referee" ON "ItemMention"."refereeId" = "Referee".id
|
||||||
|
JOIN "Item" "Referrer" ON "ItemMention"."referrerId" = "Referrer".id
|
||||||
|
${whereClause(
|
||||||
|
'"ItemMention".created_at < $2',
|
||||||
|
'"Referrer"."userId" <> $1',
|
||||||
|
'"Referee"."userId" = $1'
|
||||||
|
)}
|
||||||
|
ORDER BY "sortTime" DESC
|
||||||
|
LIMIT ${LIMIT}`
|
||||||
|
)
|
||||||
|
}
|
||||||
// Inner union to de-dupe item-driven notifications
|
// Inner union to de-dupe item-driven notifications
|
||||||
queries.push(
|
queries.push(
|
||||||
// Only record per item ID
|
// Only record per item ID
|
||||||
@ -157,6 +173,7 @@ export default {
|
|||||||
WHEN type = 'Reply' THEN 2
|
WHEN type = 'Reply' THEN 2
|
||||||
WHEN type = 'FollowActivity' THEN 3
|
WHEN type = 'FollowActivity' THEN 3
|
||||||
WHEN type = 'TerritoryPost' THEN 4
|
WHEN type = 'TerritoryPost' THEN 4
|
||||||
|
WHEN type = 'ItemMention' THEN 5
|
||||||
END ASC
|
END ASC
|
||||||
)`
|
)`
|
||||||
)
|
)
|
||||||
@ -456,6 +473,9 @@ export default {
|
|||||||
mention: async (n, args, { models }) => true,
|
mention: async (n, args, { models }) => true,
|
||||||
item: async (n, args, { models, me }) => getItem(n, { id: n.id }, { models, me })
|
item: async (n, args, { models, me }) => getItem(n, { id: n.id }, { models, me })
|
||||||
},
|
},
|
||||||
|
ItemMention: {
|
||||||
|
item: async (n, args, { models, me }) => getItem(n, { id: n.id }, { models, me })
|
||||||
|
},
|
||||||
InvoicePaid: {
|
InvoicePaid: {
|
||||||
invoice: async (n, args, { me, models }) => getInvoice(n, { id: n.id }, { me, models })
|
invoice: async (n, args, { me, models }) => getInvoice(n, { id: n.id }, { me, models })
|
||||||
},
|
},
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { GraphQLError } from 'graphql'
|
import { GraphQLError } from 'graphql'
|
||||||
import { amountSchema, ssValidate } from '@/lib/validate'
|
import { amountSchema, ssValidate } from '@/lib/validate'
|
||||||
import serialize from './serial'
|
import serialize from './serial'
|
||||||
import { ANON_USER_ID } from '@/lib/constants'
|
import { USER_ID } from '@/lib/constants'
|
||||||
import { getItem } from './item'
|
import { getItem } from './item'
|
||||||
import { topUsers } from './user'
|
import { topUsers } from './user'
|
||||||
|
|
||||||
@ -165,8 +165,8 @@ export default {
|
|||||||
await ssValidate(amountSchema, { amount: sats })
|
await ssValidate(amountSchema, { amount: sats })
|
||||||
|
|
||||||
await serialize(
|
await serialize(
|
||||||
models.$queryRaw`SELECT donate(${sats}::INTEGER, ${me?.id || ANON_USER_ID}::INTEGER)`,
|
models.$queryRaw`SELECT donate(${sats}::INTEGER, ${me?.id || USER_ID.anon}::INTEGER)`,
|
||||||
{ models, lnd, me, hash, hmac, fee: sats }
|
{ models, lnd, me, hash, hmac, fee: sats, verifyPayment: !!hash || !me }
|
||||||
)
|
)
|
||||||
|
|
||||||
return sats
|
return sats
|
||||||
|
@ -7,7 +7,7 @@ import { createHmac } from './wallet'
|
|||||||
import { msatsToSats, numWithUnits } from '@/lib/format'
|
import { msatsToSats, numWithUnits } from '@/lib/format'
|
||||||
import { BALANCE_LIMIT_MSATS } from '@/lib/constants'
|
import { BALANCE_LIMIT_MSATS } from '@/lib/constants'
|
||||||
|
|
||||||
export default async function serialize (trx, { models, lnd, me, hash, hmac, fee }) {
|
export default async function serialize (trx, { models, lnd, me, hash, hmac, fee, verifyPayment: verify }) {
|
||||||
// wrap first argument in array if not array already
|
// wrap first argument in array if not array already
|
||||||
const isArray = Array.isArray(trx)
|
const isArray = Array.isArray(trx)
|
||||||
if (!isArray) trx = [trx]
|
if (!isArray) trx = [trx]
|
||||||
@ -17,7 +17,7 @@ export default async function serialize (trx, { models, lnd, me, hash, hmac, fee
|
|||||||
trx = trx.filter(q => !!q)
|
trx = trx.filter(q => !!q)
|
||||||
|
|
||||||
let invoice
|
let invoice
|
||||||
if (hash) {
|
if (verify) {
|
||||||
invoice = await verifyPayment(models, hash, hmac, fee)
|
invoice = await verifyPayment(models, hash, hmac, fee)
|
||||||
trx = [
|
trx = [
|
||||||
models.$executeRaw`SELECT confirm_invoice(${hash}, ${invoice.msatsReceived})`,
|
models.$executeRaw`SELECT confirm_invoice(${hash}, ${invoice.msatsReceived})`,
|
||||||
|
@ -248,7 +248,7 @@ export default {
|
|||||||
|
|
||||||
const results = await serialize(
|
const results = await serialize(
|
||||||
queries,
|
queries,
|
||||||
{ models, lnd, me, hash, hmac, fee: sub.billingCost })
|
{ models, lnd, me, hash, hmac, fee: sub.billingCost, verifyPayment: !!hash || !me })
|
||||||
return results[1]
|
return results[1]
|
||||||
},
|
},
|
||||||
toggleMuteSub: async (parent, { name }, { me, models }) => {
|
toggleMuteSub: async (parent, { name }, { me, models }) => {
|
||||||
@ -368,7 +368,7 @@ export default {
|
|||||||
models.sub.update({ where: { name }, data: newSub }),
|
models.sub.update({ where: { name }, data: newSub }),
|
||||||
isTransfer && models.territoryTransfer.create({ data: { subName: name, oldUserId: oldSub.userId, newUserId: me.id } })
|
isTransfer && models.territoryTransfer.create({ data: { subName: name, oldUserId: oldSub.userId, newUserId: me.id } })
|
||||||
],
|
],
|
||||||
{ models, lnd, hash, me, hmac, fee: billingCost })
|
{ models, lnd, hash, me, hmac, fee: billingCost, verifyPayment: !!hash || !me })
|
||||||
|
|
||||||
if (isTransfer) notifyTerritoryTransfer({ models, sub: newSub, to: me })
|
if (isTransfer) notifyTerritoryTransfer({ models, sub: newSub, to: me })
|
||||||
}
|
}
|
||||||
@ -382,7 +382,10 @@ export default {
|
|||||||
return await models.user.findUnique({ where: { id: sub.userId } })
|
return await models.user.findUnique({ where: { id: sub.userId } })
|
||||||
},
|
},
|
||||||
meMuteSub: async (sub, args, { models }) => {
|
meMuteSub: async (sub, args, { models }) => {
|
||||||
return sub.meMuteSub || sub.MuteSub?.length > 0
|
if (sub.meMuteSub !== undefined) {
|
||||||
|
return sub.meMuteSub
|
||||||
|
}
|
||||||
|
return sub.MuteSub?.length > 0
|
||||||
},
|
},
|
||||||
nposts: async (sub, { when, from, to }, { models }) => {
|
nposts: async (sub, { when, from, to }, { models }) => {
|
||||||
if (typeof sub.nposts !== 'undefined') {
|
if (typeof sub.nposts !== 'undefined') {
|
||||||
@ -395,7 +398,11 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
meSubscription: async (sub, args, { me, models }) => {
|
meSubscription: async (sub, args, { me, models }) => {
|
||||||
return sub.meSubscription || sub.SubSubscription?.length > 0
|
if (sub.meSubscription !== undefined) {
|
||||||
|
return sub.meSubscription
|
||||||
|
}
|
||||||
|
|
||||||
|
return sub.SubSubscription?.length > 0
|
||||||
},
|
},
|
||||||
createdAt: sub => sub.createdAt || sub.created_at
|
createdAt: sub => sub.createdAt || sub.created_at
|
||||||
}
|
}
|
||||||
@ -457,7 +464,7 @@ async function createSub (parent, data, { me, models, lnd, hash, hmac }) {
|
|||||||
subName: data.name
|
subName: data.name
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
], { models, lnd, me, hash, hmac, fee: billingCost })
|
], { models, lnd, me, hash, hmac, fee: billingCost, verifyPayment: !!hash || !me })
|
||||||
|
|
||||||
return results[1]
|
return results[1]
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -538,7 +545,7 @@ async function updateSub (parent, { oldName, ...data }, { me, models, lnd, hash,
|
|||||||
userId: me.id
|
userId: me.id
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
], { models, lnd, me, hash, hmac, fee: proratedCost })
|
], { models, lnd, me, hash, hmac, fee: proratedCost, verifyPayment: !!hash || !me })
|
||||||
return results[2]
|
return results[2]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { GraphQLError } from 'graphql'
|
import { GraphQLError } from 'graphql'
|
||||||
import { ANON_USER_ID, IMAGE_PIXELS_MAX, UPLOAD_SIZE_MAX, UPLOAD_SIZE_MAX_AVATAR, UPLOAD_TYPES_ALLOW } from '@/lib/constants'
|
import { USER_ID, IMAGE_PIXELS_MAX, UPLOAD_SIZE_MAX, UPLOAD_SIZE_MAX_AVATAR, UPLOAD_TYPES_ALLOW } from '@/lib/constants'
|
||||||
import { createPresignedPost } from '@/api/s3'
|
import { createPresignedPost } from '@/api/s3'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@ -26,7 +26,7 @@ export default {
|
|||||||
size,
|
size,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
userId: me?.id || ANON_USER_ID,
|
userId: me?.id || USER_ID.anon,
|
||||||
paid: false
|
paid: false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -5,7 +5,7 @@ import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
|
|||||||
import { msatsToSats } from '@/lib/format'
|
import { msatsToSats } from '@/lib/format'
|
||||||
import { bioSchema, emailSchema, settingsSchema, ssValidate, userSchema } from '@/lib/validate'
|
import { bioSchema, emailSchema, settingsSchema, ssValidate, userSchema } from '@/lib/validate'
|
||||||
import { getItem, updateItem, filterClause, createItem, whereClause, muteClause } from './item'
|
import { getItem, updateItem, filterClause, createItem, whereClause, muteClause } from './item'
|
||||||
import { ANON_USER_ID, DELETE_USER_ID, RESERVED_MAX_USER_ID, SN_NO_REWARDS_IDS } from '@/lib/constants'
|
import { USER_ID, RESERVED_MAX_USER_ID, SN_NO_REWARDS_IDS } from '@/lib/constants'
|
||||||
import { viewGroup } from './growth'
|
import { viewGroup } from './growth'
|
||||||
import { timeUnitForRange, whenRange } from '@/lib/time'
|
import { timeUnitForRange, whenRange } from '@/lib/time'
|
||||||
import assertApiKeyNotPermitted from './apiKey'
|
import assertApiKeyNotPermitted from './apiKey'
|
||||||
@ -217,7 +217,7 @@ export default {
|
|||||||
SELECT name
|
SELECT name
|
||||||
FROM users
|
FROM users
|
||||||
WHERE (
|
WHERE (
|
||||||
id > ${RESERVED_MAX_USER_ID} OR id IN (${ANON_USER_ID}, ${DELETE_USER_ID})
|
id > ${RESERVED_MAX_USER_ID} OR id IN (${USER_ID.anon}, ${USER_ID.delete})
|
||||||
)
|
)
|
||||||
AND SIMILARITY(name, ${q}) > 0.1
|
AND SIMILARITY(name, ${q}) > 0.1
|
||||||
ORDER BY SIMILARITY(name, ${q}) DESC
|
ORDER BY SIMILARITY(name, ${q}) DESC
|
||||||
@ -347,6 +347,26 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (user.noteItemMentions) {
|
||||||
|
const [newMentions] = await models.$queryRawUnsafe(`
|
||||||
|
SELECT EXISTS(
|
||||||
|
SELECT *
|
||||||
|
FROM "ItemMention"
|
||||||
|
JOIN "Item" "Referee" ON "ItemMention"."refereeId" = "Referee".id
|
||||||
|
JOIN "Item" ON "ItemMention"."referrerId" = "Item".id
|
||||||
|
${whereClause(
|
||||||
|
'"ItemMention".created_at > $2',
|
||||||
|
'"Item"."userId" <> $1',
|
||||||
|
'"Referee"."userId" = $1',
|
||||||
|
await filterClause(me, models),
|
||||||
|
muteClause(me)
|
||||||
|
)})`, me.id, lastChecked)
|
||||||
|
if (newMentions.exists) {
|
||||||
|
foundNotes()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (user.noteForwardedSats) {
|
if (user.noteForwardedSats) {
|
||||||
const [newFwdSats] = await models.$queryRawUnsafe(`
|
const [newFwdSats] = await models.$queryRawUnsafe(`
|
||||||
SELECT EXISTS(
|
SELECT EXISTS(
|
||||||
@ -518,7 +538,7 @@ export default {
|
|||||||
return await models.$queryRaw`
|
return await models.$queryRaw`
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM users
|
FROM users
|
||||||
WHERE (id > ${RESERVED_MAX_USER_ID} OR id IN (${ANON_USER_ID}, ${DELETE_USER_ID}))
|
WHERE (id > ${RESERVED_MAX_USER_ID} OR id IN (${USER_ID.anon}, ${USER_ID.delete}))
|
||||||
AND SIMILARITY(name, ${q}) > ${Number(similarity) || 0.1} ORDER BY SIMILARITY(name, ${q}) DESC LIMIT ${Number(limit) || 5}`
|
AND SIMILARITY(name, ${q}) > ${Number(similarity) || 0.1} ORDER BY SIMILARITY(name, ${q}) DESC LIMIT ${Number(limit) || 5}`
|
||||||
},
|
},
|
||||||
userStatsActions: async (parent, { when, from, to }, { me, models }) => {
|
userStatsActions: async (parent, { when, from, to }, { me, models }) => {
|
||||||
@ -700,7 +720,7 @@ export default {
|
|||||||
} else if (authType === 'nostr') {
|
} else if (authType === 'nostr') {
|
||||||
user = await models.user.update({ where: { id: me.id }, data: { hideNostr: true, nostrAuthPubkey: null } })
|
user = await models.user.update({ where: { id: me.id }, data: { hideNostr: true, nostrAuthPubkey: null } })
|
||||||
} else if (authType === 'email') {
|
} else if (authType === 'email') {
|
||||||
user = await models.user.update({ where: { id: me.id }, data: { email: null, emailVerified: null } })
|
user = await models.user.update({ where: { id: me.id }, data: { email: null, emailVerified: null, emailHash: null } })
|
||||||
} else {
|
} else {
|
||||||
throw new GraphQLError('no such account', { extensions: { code: 'BAD_INPUT' } })
|
throw new GraphQLError('no such account', { extensions: { code: 'BAD_INPUT' } })
|
||||||
}
|
}
|
||||||
|
@ -7,7 +7,7 @@ import { SELECT } from './item'
|
|||||||
import { lnAddrOptions } from '@/lib/lnurl'
|
import { lnAddrOptions } from '@/lib/lnurl'
|
||||||
import { msatsToSats, msatsToSatsDecimal, ensureB64 } from '@/lib/format'
|
import { msatsToSats, msatsToSatsDecimal, ensureB64 } from '@/lib/format'
|
||||||
import { CLNAutowithdrawSchema, LNDAutowithdrawSchema, amountSchema, lnAddrAutowithdrawSchema, lnAddrSchema, ssValidate, withdrawlSchema } from '@/lib/validate'
|
import { CLNAutowithdrawSchema, LNDAutowithdrawSchema, amountSchema, lnAddrAutowithdrawSchema, lnAddrSchema, ssValidate, withdrawlSchema } from '@/lib/validate'
|
||||||
import { ANON_BALANCE_LIMIT_MSATS, ANON_INV_PENDING_LIMIT, ANON_USER_ID, BALANCE_LIMIT_MSATS, INVOICE_RETENTION_DAYS, INV_PENDING_LIMIT, USER_IDS_BALANCE_NO_LIMIT, Wallet } from '@/lib/constants'
|
import { ANON_BALANCE_LIMIT_MSATS, ANON_INV_PENDING_LIMIT, USER_ID, BALANCE_LIMIT_MSATS, INVOICE_RETENTION_DAYS, INV_PENDING_LIMIT, USER_IDS_BALANCE_NO_LIMIT, Wallet } from '@/lib/constants'
|
||||||
import { datePivot } from '@/lib/time'
|
import { datePivot } from '@/lib/time'
|
||||||
import assertGofacYourself from './ofac'
|
import assertGofacYourself from './ofac'
|
||||||
import assertApiKeyNotPermitted from './apiKey'
|
import assertApiKeyNotPermitted from './apiKey'
|
||||||
@ -28,7 +28,7 @@ export async function getInvoice (parent, { id }, { me, models, lnd }) {
|
|||||||
throw new GraphQLError('invoice not found', { extensions: { code: 'BAD_INPUT' } })
|
throw new GraphQLError('invoice not found', { extensions: { code: 'BAD_INPUT' } })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (inv.user.id === ANON_USER_ID) {
|
if (inv.user.id === USER_ID.anon) {
|
||||||
return inv
|
return inv
|
||||||
}
|
}
|
||||||
if (!me) {
|
if (!me) {
|
||||||
@ -339,7 +339,7 @@ export default {
|
|||||||
expirePivot = { seconds: Math.min(expireSecs, 180) }
|
expirePivot = { seconds: Math.min(expireSecs, 180) }
|
||||||
invLimit = ANON_INV_PENDING_LIMIT
|
invLimit = ANON_INV_PENDING_LIMIT
|
||||||
balanceLimit = ANON_BALANCE_LIMIT_MSATS
|
balanceLimit = ANON_BALANCE_LIMIT_MSATS
|
||||||
id = ANON_USER_ID
|
id = USER_ID.anon
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await models.user.findUnique({ where: { id } })
|
const user = await models.user.findUnique({ where: { id } })
|
||||||
|
@ -46,7 +46,6 @@ export default gql`
|
|||||||
id: ID,
|
id: ID,
|
||||||
option: String!
|
option: String!
|
||||||
count: Int!
|
count: Int!
|
||||||
meVoted: Boolean!
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Poll {
|
type Poll {
|
||||||
|
@ -43,6 +43,12 @@ export default gql`
|
|||||||
sortTime: Date!
|
sortTime: Date!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ItemMention {
|
||||||
|
id: ID!
|
||||||
|
item: Item!
|
||||||
|
sortTime: Date!
|
||||||
|
}
|
||||||
|
|
||||||
type Invitification {
|
type Invitification {
|
||||||
id: ID!
|
id: ID!
|
||||||
invite: Invite!
|
invite: Invite!
|
||||||
@ -130,7 +136,7 @@ export default gql`
|
|||||||
union Notification = Reply | Votification | Mention
|
union Notification = Reply | Votification | Mention
|
||||||
| Invitification | Earn | JobChanged | InvoicePaid | WithdrawlPaid | Referral
|
| Invitification | Earn | JobChanged | InvoicePaid | WithdrawlPaid | Referral
|
||||||
| Streak | FollowActivity | ForwardedVotification | Revenue | SubStatus
|
| Streak | FollowActivity | ForwardedVotification | Revenue | SubStatus
|
||||||
| TerritoryPost | TerritoryTransfer | Reminder
|
| TerritoryPost | TerritoryTransfer | Reminder | ItemMention
|
||||||
|
|
||||||
type Notifications {
|
type Notifications {
|
||||||
lastChecked: Date
|
lastChecked: Date
|
||||||
|
@ -95,6 +95,7 @@ export default gql`
|
|||||||
noteItemSats: Boolean!
|
noteItemSats: Boolean!
|
||||||
noteJobIndicator: Boolean!
|
noteJobIndicator: Boolean!
|
||||||
noteMentions: Boolean!
|
noteMentions: Boolean!
|
||||||
|
noteItemMentions: Boolean!
|
||||||
nsfwMode: Boolean!
|
nsfwMode: Boolean!
|
||||||
tipDefault: Int!
|
tipDefault: Int!
|
||||||
turboTipping: Boolean!
|
turboTipping: Boolean!
|
||||||
@ -161,6 +162,7 @@ export default gql`
|
|||||||
noteItemSats: Boolean!
|
noteItemSats: Boolean!
|
||||||
noteJobIndicator: Boolean!
|
noteJobIndicator: Boolean!
|
||||||
noteMentions: Boolean!
|
noteMentions: Boolean!
|
||||||
|
noteItemMentions: Boolean!
|
||||||
nsfwMode: Boolean!
|
nsfwMode: Boolean!
|
||||||
tipDefault: Int!
|
tipDefault: Int!
|
||||||
turboTipping: Boolean!
|
turboTipping: Boolean!
|
||||||
|
@ -105,3 +105,8 @@ cointastical,issue,#1191,#134,medium,,,,22k,cointastical@stacker.news,2024-05-28
|
|||||||
kravhen,pr,#1198,#1180,good-first-issue,,,required linting,18k,nichro@getalby.com,2024-05-28
|
kravhen,pr,#1198,#1180,good-first-issue,,,required linting,18k,nichro@getalby.com,2024-05-28
|
||||||
OneOneSeven117,issue,#1198,#1180,good-first-issue,,,required linting,2k,OneOneSeven@stacker.news,2024-05-28
|
OneOneSeven117,issue,#1198,#1180,good-first-issue,,,required linting,2k,OneOneSeven@stacker.news,2024-05-28
|
||||||
tsmith123,pr,#1207,#837,easy,high,1,,180k,stickymarch60@walletofsatoshi.com,2024-05-31
|
tsmith123,pr,#1207,#837,easy,high,1,,180k,stickymarch60@walletofsatoshi.com,2024-05-31
|
||||||
|
SatsAllDay,pr,#1214,#1199,good-first-issue,,,,20k,weareallsatoshi@getalby.com,2024-06-03
|
||||||
|
SatsAllDay,pr,#1197,#1192,medium,,,,250k,weareallsatoshi@getalby.com,2024-06-03
|
||||||
|
tsmith123,pr,#1216,#1213,easy,,1,,90k,stickymarch60@walletofsatoshi.com,2024-06-03
|
||||||
|
tsmith123,pr,#1231,#1230,good-first-issue,,,,20k,stickymarch60@walletofsatoshi.com,2024-06-13
|
||||||
|
felipebueno,issue,#1231,#1230,good-first-issue,,,,2k,felipebueno@getalby.com,2024-06-13
|
||||||
|
|
@ -138,3 +138,11 @@ export function WalletSecurityBanner () {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function AuthBanner () {
|
||||||
|
return (
|
||||||
|
<Alert className={`${styles.banner} mt-0`} key='info' variant='danger'>
|
||||||
|
Please add a second auth method to avoid losing access to your account.
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
@ -2,9 +2,9 @@ import { useApolloClient } from '@apollo/client'
|
|||||||
import { useMe } from './me'
|
import { useMe } from './me'
|
||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||||
import { datePivot, timeSince } from '@/lib/time'
|
import { datePivot, timeSince } from '@/lib/time'
|
||||||
import { ANON_USER_ID, JIT_INVOICE_TIMEOUT_MS } from '@/lib/constants'
|
import { USER_ID, JIT_INVOICE_TIMEOUT_MS } from '@/lib/constants'
|
||||||
import { HAS_NOTIFICATIONS } from '@/fragments/notifications'
|
import { HAS_NOTIFICATIONS } from '@/fragments/notifications'
|
||||||
import Item from './item'
|
import Item, { ItemSkeleton } from './item'
|
||||||
import { RootProvider } from './root'
|
import { RootProvider } from './root'
|
||||||
import Comment from './comment'
|
import Comment from './comment'
|
||||||
|
|
||||||
@ -25,7 +25,7 @@ export function ClientNotificationProvider ({ children }) {
|
|||||||
const me = useMe()
|
const me = useMe()
|
||||||
// anons don't have access to /notifications
|
// anons don't have access to /notifications
|
||||||
// but we'll store client notifications anyway for simplicity's sake
|
// but we'll store client notifications anyway for simplicity's sake
|
||||||
const storageKey = `client-notifications:${me?.id || ANON_USER_ID}`
|
const storageKey = `client-notifications:${me?.id || USER_ID.anon}`
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loaded = loadNotifications(storageKey, client)
|
const loaded = loadNotifications(storageKey, client)
|
||||||
@ -103,7 +103,7 @@ function ClientNotification ({ n, message }) {
|
|||||||
<small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small>
|
<small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small>
|
||||||
</small>
|
</small>
|
||||||
{!n.item
|
{!n.item
|
||||||
? null
|
? <ItemSkeleton />
|
||||||
: n.item.title
|
: n.item.title
|
||||||
? <Item item={n.item} />
|
? <Item item={n.item} />
|
||||||
: (
|
: (
|
||||||
|
@ -9,7 +9,7 @@ import Eye from '@/svgs/eye-fill.svg'
|
|||||||
import EyeClose from '@/svgs/eye-close-line.svg'
|
import EyeClose from '@/svgs/eye-close-line.svg'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import CommentEdit from './comment-edit'
|
import CommentEdit from './comment-edit'
|
||||||
import { ANON_USER_ID, COMMENT_DEPTH_LIMIT, UNKNOWN_LINK_REL } from '@/lib/constants'
|
import { USER_ID, COMMENT_DEPTH_LIMIT, UNKNOWN_LINK_REL } from '@/lib/constants'
|
||||||
import PayBounty from './pay-bounty'
|
import PayBounty from './pay-bounty'
|
||||||
import BountyIcon from '@/svgs/bounty-bag.svg'
|
import BountyIcon from '@/svgs/bounty-bag.svg'
|
||||||
import ActionTooltip from './action-tooltip'
|
import ActionTooltip from './action-tooltip'
|
||||||
@ -25,6 +25,7 @@ import Skull from '@/svgs/death-skull.svg'
|
|||||||
import { commentSubTreeRootId } from '@/lib/item'
|
import { commentSubTreeRootId } from '@/lib/item'
|
||||||
import Pin from '@/svgs/pushpin-fill.svg'
|
import Pin from '@/svgs/pushpin-fill.svg'
|
||||||
import LinkToContext from './link-to-context'
|
import LinkToContext from './link-to-context'
|
||||||
|
import { ItemContextProvider, useItemContext } from './item'
|
||||||
|
|
||||||
function Parent ({ item, rootText }) {
|
function Parent ({ item, rootText }) {
|
||||||
const root = useRoot()
|
const root = useRoot()
|
||||||
@ -128,25 +129,22 @@ export default function Comment ({
|
|||||||
|
|
||||||
const bottomedOut = depth === COMMENT_DEPTH_LIMIT
|
const bottomedOut = depth === COMMENT_DEPTH_LIMIT
|
||||||
// Don't show OP badge when anon user comments on anon user posts
|
// Don't show OP badge when anon user comments on anon user posts
|
||||||
const op = root.user.name === item.user.name && Number(item.user.id) !== ANON_USER_ID
|
const op = root.user.name === item.user.name && Number(item.user.id) !== USER_ID.anon
|
||||||
? 'OP'
|
? 'OP'
|
||||||
: root.forwards?.some(f => f.user.name === item.user.name) && Number(item.user.id) !== ANON_USER_ID
|
: root.forwards?.some(f => f.user.name === item.user.name) && Number(item.user.id) !== USER_ID.anon
|
||||||
? 'fwd'
|
? 'fwd'
|
||||||
: null
|
: null
|
||||||
const bountyPaid = root.bountyPaidTo?.includes(Number(item.id))
|
const bountyPaid = root.bountyPaidTo?.includes(Number(item.id))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<ItemContextProvider>
|
||||||
<div
|
<div
|
||||||
ref={ref} className={includeParent ? '' : `${styles.comment} ${collapse === 'yep' ? styles.collapsed : ''}`}
|
ref={ref} className={includeParent ? '' : `${styles.comment} ${collapse === 'yep' ? styles.collapsed : ''}`}
|
||||||
onMouseEnter={() => ref.current.classList.add('outline-new-comment-unset')}
|
onMouseEnter={() => ref.current.classList.add('outline-new-comment-unset')}
|
||||||
onTouchStart={() => ref.current.classList.add('outline-new-comment-unset')}
|
onTouchStart={() => ref.current.classList.add('outline-new-comment-unset')}
|
||||||
>
|
>
|
||||||
<div className={`${itemStyles.item} ${styles.item}`}>
|
<div className={`${itemStyles.item} ${styles.item}`}>
|
||||||
{item.outlawed && !me?.privates?.wildWestMode
|
<ZapIcon item={item} pin={pin} me={me} />
|
||||||
? <Skull className={styles.dontLike} width={24} height={24} />
|
|
||||||
: item.meDontLikeSats > item.meSats
|
|
||||||
? <DownZap width={24} height={24} className={styles.dontLike} item={item} />
|
|
||||||
: pin ? <Pin width={22} height={22} className={styles.pin} /> : <UpVote item={item} className={styles.upvote} />}
|
|
||||||
<div className={`${itemStyles.hunk} ${styles.hunk}`}>
|
<div className={`${itemStyles.hunk} ${styles.hunk}`}>
|
||||||
<div className='d-flex align-items-center'>
|
<div className='d-flex align-items-center'>
|
||||||
{item.user?.meMute && !includeParent && collapse === 'yep'
|
{item.user?.meMute && !includeParent && collapse === 'yep'
|
||||||
@ -244,9 +242,24 @@ export default function Comment ({
|
|||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</ItemContextProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ZapIcon ({ item, pin }) {
|
||||||
|
const me = useMe()
|
||||||
|
const { pendingSats, pendingDownSats } = useItemContext()
|
||||||
|
|
||||||
|
const meSats = item.meSats + pendingSats
|
||||||
|
const downSats = item.meDontLikeSats + pendingDownSats
|
||||||
|
|
||||||
|
return item.outlawed && !me?.privates?.wildWestMode
|
||||||
|
? <Skull className={styles.dontLike} width={24} height={24} />
|
||||||
|
: downSats > meSats
|
||||||
|
? <DownZap width={24} height={24} className={styles.dontLike} item={item} />
|
||||||
|
: pin ? <Pin width={22} height={22} className={styles.pin} /> : <UpVote item={item} className={styles.upvote} />
|
||||||
|
}
|
||||||
|
|
||||||
export function CommentSkeleton ({ skeletonChildren }) {
|
export function CommentSkeleton ({ skeletonChildren }) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.comment}>
|
<div className={styles.comment}>
|
||||||
|
@ -6,10 +6,12 @@ import Navbar from 'react-bootstrap/Navbar'
|
|||||||
import { numWithUnits } from '@/lib/format'
|
import { numWithUnits } from '@/lib/format'
|
||||||
import { defaultCommentSort } from '@/lib/item'
|
import { defaultCommentSort } from '@/lib/item'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
|
import { ItemContextProvider, useItemContext } from './item'
|
||||||
|
|
||||||
export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats }) {
|
export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const sort = router.query.sort || defaultCommentSort(pinned, bio, parentCreatedAt)
|
const sort = router.query.sort || defaultCommentSort(pinned, bio, parentCreatedAt)
|
||||||
|
const { pendingCommentSats } = useItemContext()
|
||||||
|
|
||||||
const getHandleClick = sort => {
|
const getHandleClick = sort => {
|
||||||
return () => {
|
return () => {
|
||||||
@ -24,7 +26,7 @@ export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, comm
|
|||||||
activeKey={sort}
|
activeKey={sort}
|
||||||
>
|
>
|
||||||
<Nav.Item className='text-muted'>
|
<Nav.Item className='text-muted'>
|
||||||
{numWithUnits(commentSats)}
|
{numWithUnits(commentSats + pendingCommentSats)}
|
||||||
</Nav.Item>
|
</Nav.Item>
|
||||||
<div className='ms-auto d-flex'>
|
<div className='ms-auto d-flex'>
|
||||||
<Nav.Item>
|
<Nav.Item>
|
||||||
@ -66,7 +68,7 @@ export default function Comments ({ parentId, pinned, bio, parentCreatedAt, comm
|
|||||||
const pins = comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position)
|
const pins = comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<ItemContextProvider>
|
||||||
{comments?.length > 0
|
{comments?.length > 0
|
||||||
? <CommentsHeader
|
? <CommentsHeader
|
||||||
commentSats={commentSats} parentCreatedAt={parentCreatedAt}
|
commentSats={commentSats} parentCreatedAt={parentCreatedAt}
|
||||||
@ -91,7 +93,7 @@ export default function Comments ({ parentId, pinned, bio, parentCreatedAt, comm
|
|||||||
{comments.filter(({ position }) => !position).map(item => (
|
{comments.filter(({ position }) => !position).map(item => (
|
||||||
<Comment depth={1} key={item.id} item={item} {...props} />
|
<Comment depth={1} key={item.id} item={item} {...props} />
|
||||||
))}
|
))}
|
||||||
</>
|
</ItemContextProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4,18 +4,24 @@ import { useToast } from './toast'
|
|||||||
import ItemAct from './item-act'
|
import ItemAct from './item-act'
|
||||||
import AccordianItem from './accordian-item'
|
import AccordianItem from './accordian-item'
|
||||||
import Flag from '@/svgs/flag-fill.svg'
|
import Flag from '@/svgs/flag-fill.svg'
|
||||||
import { useMemo } from 'react'
|
import { useCallback, useMemo } from 'react'
|
||||||
import getColor from '@/lib/rainbow'
|
import getColor from '@/lib/rainbow'
|
||||||
import { gql, useMutation } from '@apollo/client'
|
import { gql, useMutation } from '@apollo/client'
|
||||||
|
import { useItemContext } from './item'
|
||||||
|
import { useLightning } from './lightning'
|
||||||
|
|
||||||
export function DownZap ({ item, ...props }) {
|
export function DownZap ({ item, ...props }) {
|
||||||
|
const { pendingDownSats } = useItemContext()
|
||||||
const { meDontLikeSats } = item
|
const { meDontLikeSats } = item
|
||||||
const style = useMemo(() => (meDontLikeSats
|
|
||||||
|
const downSats = meDontLikeSats + pendingDownSats
|
||||||
|
|
||||||
|
const style = useMemo(() => (downSats
|
||||||
? {
|
? {
|
||||||
fill: getColor(meDontLikeSats),
|
fill: getColor(downSats),
|
||||||
filter: `drop-shadow(0 0 6px ${getColor(meDontLikeSats)}90)`
|
filter: `drop-shadow(0 0 6px ${getColor(downSats)}90)`
|
||||||
}
|
}
|
||||||
: undefined), [meDontLikeSats])
|
: undefined), [downSats])
|
||||||
return (
|
return (
|
||||||
<DownZapper item={item} As={({ ...oprops }) => <Flag {...props} {...oprops} style={style} />} />
|
<DownZapper item={item} As={({ ...oprops }) => <Flag {...props} {...oprops} style={style} />} />
|
||||||
)
|
)
|
||||||
@ -24,6 +30,17 @@ export function DownZap ({ item, ...props }) {
|
|||||||
function DownZapper ({ item, As, children }) {
|
function DownZapper ({ item, As, children }) {
|
||||||
const toaster = useToast()
|
const toaster = useToast()
|
||||||
const showModal = useShowModal()
|
const showModal = useShowModal()
|
||||||
|
const strike = useLightning()
|
||||||
|
const { setPendingDownSats } = useItemContext()
|
||||||
|
|
||||||
|
const optimisticUpdate = useCallback((sats, { onClose } = {}) => {
|
||||||
|
setPendingDownSats(pendingSats => pendingSats + sats)
|
||||||
|
strike()
|
||||||
|
onClose?.()
|
||||||
|
return () => {
|
||||||
|
setPendingDownSats(pendingSats => pendingSats - sats)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<As
|
<As
|
||||||
@ -31,7 +48,7 @@ function DownZapper ({ item, As, children }) {
|
|||||||
try {
|
try {
|
||||||
showModal(onClose =>
|
showModal(onClose =>
|
||||||
<ItemAct
|
<ItemAct
|
||||||
onClose={onClose} item={item} down
|
onClose={onClose} item={item} down optimisticUpdate={optimisticUpdate}
|
||||||
>
|
>
|
||||||
<AccordianItem
|
<AccordianItem
|
||||||
header='what is a downzap?' body={
|
header='what is a downzap?' body={
|
||||||
|
@ -33,7 +33,6 @@ import EyeClose from '@/svgs/eye-close-line.svg'
|
|||||||
import Info from './info'
|
import Info from './info'
|
||||||
import { InvoiceCanceledError, usePayment } from './payment'
|
import { InvoiceCanceledError, usePayment } from './payment'
|
||||||
import { useMe } from './me'
|
import { useMe } from './me'
|
||||||
import { optimisticUpdate } from '@/lib/apollo'
|
|
||||||
import { useClientNotifications } from './client-notifications'
|
import { useClientNotifications } from './client-notifications'
|
||||||
import { ActCanceledError } from './item-act'
|
import { ActCanceledError } from './item-act'
|
||||||
|
|
||||||
@ -121,7 +120,7 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
|
|||||||
const imageUploadRef = useRef(null)
|
const imageUploadRef = useRef(null)
|
||||||
const previousTab = useRef(tab)
|
const previousTab = useRef(tab)
|
||||||
const { merge, setDisabled: setSubmitDisabled } = useFeeButton()
|
const { merge, setDisabled: setSubmitDisabled } = useFeeButton()
|
||||||
const toaster = useToast()
|
|
||||||
const [updateImageFeesInfo] = useLazyQuery(gql`
|
const [updateImageFeesInfo] = useLazyQuery(gql`
|
||||||
query imageFeesInfo($s3Keys: [Int]!) {
|
query imageFeesInfo($s3Keys: [Int]!) {
|
||||||
imageFeesInfo(s3Keys: $s3Keys) {
|
imageFeesInfo(s3Keys: $s3Keys) {
|
||||||
@ -135,7 +134,6 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
|
|||||||
nextFetchPolicy: 'no-cache',
|
nextFetchPolicy: 'no-cache',
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
toaster.danger(`unabled to get image fees: ${err.message || err.toString?.()}`)
|
|
||||||
},
|
},
|
||||||
onCompleted: ({ imageFeesInfo }) => {
|
onCompleted: ({ imageFeesInfo }) => {
|
||||||
merge({
|
merge({
|
||||||
@ -805,7 +803,7 @@ const StorageKeyPrefixContext = createContext()
|
|||||||
export function Form ({
|
export function Form ({
|
||||||
initial, schema, onSubmit, children, initialError, validateImmediately,
|
initial, schema, onSubmit, children, initialError, validateImmediately,
|
||||||
storageKeyPrefix, validateOnChange = true, prepaid, requireSession, innerRef,
|
storageKeyPrefix, validateOnChange = true, prepaid, requireSession, innerRef,
|
||||||
optimisticUpdate: optimisticUpdateArgs, clientNotification, signal, ...props
|
optimisticUpdate, clientNotification, signal, ...props
|
||||||
}) {
|
}) {
|
||||||
const toaster = useToast()
|
const toaster = useToast()
|
||||||
const initialErrorToasted = useRef(false)
|
const initialErrorToasted = useRef(false)
|
||||||
@ -845,9 +843,7 @@ export function Form ({
|
|||||||
throw new SessionRequiredError()
|
throw new SessionRequiredError()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (optimisticUpdateArgs) {
|
revert = optimisticUpdate?.(variables)
|
||||||
revert = optimisticUpdate(optimisticUpdateArgs(variables))
|
|
||||||
}
|
|
||||||
|
|
||||||
await signal?.pause({ me, amount })
|
await signal?.pause({ me, amount })
|
||||||
|
|
||||||
@ -866,8 +862,6 @@ export function Form ({
|
|||||||
clearLocalStorage(values)
|
clearLocalStorage(values)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
revert?.()
|
|
||||||
|
|
||||||
if (err instanceof InvoiceCanceledError || err instanceof ActCanceledError) {
|
if (err instanceof InvoiceCanceledError || err instanceof ActCanceledError) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -881,6 +875,7 @@ export function Form ({
|
|||||||
|
|
||||||
cancel?.()
|
cancel?.()
|
||||||
} finally {
|
} finally {
|
||||||
|
revert?.()
|
||||||
// if we reach this line, the submit either failed or was successful so we can remove the pending notification.
|
// if we reach this line, the submit either failed or was successful so we can remove the pending notification.
|
||||||
// if we don't reach this line, the page was probably reloaded and we can use the pending notification
|
// if we don't reach this line, the page was probably reloaded and we can use the pending notification
|
||||||
// stored in localStorage to handle this case.
|
// stored in localStorage to handle this case.
|
||||||
|
@ -4,11 +4,11 @@ import Tooltip from 'react-bootstrap/Tooltip'
|
|||||||
import CowboyHatIcon from '@/svgs/cowboy.svg'
|
import CowboyHatIcon from '@/svgs/cowboy.svg'
|
||||||
import AnonIcon from '@/svgs/spy-fill.svg'
|
import AnonIcon from '@/svgs/spy-fill.svg'
|
||||||
import { numWithUnits } from '@/lib/format'
|
import { numWithUnits } from '@/lib/format'
|
||||||
import { AD_USER_ID, ANON_USER_ID } from '@/lib/constants'
|
import { USER_ID } from '@/lib/constants'
|
||||||
|
|
||||||
export default function Hat ({ user, badge, className = 'ms-1', height = 16, width = 16 }) {
|
export default function Hat ({ user, badge, className = 'ms-1', height = 16, width = 16 }) {
|
||||||
if (!user || Number(user.id) === AD_USER_ID) return null
|
if (!user || Number(user.id) === USER_ID.ad) return null
|
||||||
if (Number(user.id) === ANON_USER_ID) {
|
if (Number(user.id) === USER_ID.anon) {
|
||||||
return (
|
return (
|
||||||
<HatTooltip overlayText='anonymous'>
|
<HatTooltip overlayText='anonymous'>
|
||||||
{badge
|
{badge
|
||||||
|
@ -10,9 +10,9 @@ import { useToast } from './toast'
|
|||||||
import { useLightning } from './lightning'
|
import { useLightning } from './lightning'
|
||||||
import { nextTip } from './upvote'
|
import { nextTip } from './upvote'
|
||||||
import { InvoiceCanceledError, usePayment } from './payment'
|
import { InvoiceCanceledError, usePayment } from './payment'
|
||||||
import { optimisticUpdate } from '@/lib/apollo'
|
|
||||||
import { Types as ClientNotification, ClientNotifyProvider, useClientNotifications } from './client-notifications'
|
import { Types as ClientNotification, ClientNotifyProvider, useClientNotifications } from './client-notifications'
|
||||||
import { ZAP_UNDO_DELAY_MS } from '@/lib/constants'
|
import { ZAP_UNDO_DELAY_MS } from '@/lib/constants'
|
||||||
|
import { useItemContext } from './item'
|
||||||
|
|
||||||
const defaultTips = [100, 1000, 10_000, 100_000]
|
const defaultTips = [100, 1000, 10_000, 100_000]
|
||||||
|
|
||||||
@ -100,11 +100,10 @@ export const actUpdate = ({ me, onUpdate }) => (cache, args) => {
|
|||||||
onUpdate?.(cache, args)
|
onUpdate?.(cache, args)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ItemAct ({ onClose, item, down, children, abortSignal }) {
|
export default function ItemAct ({ onClose, item, down, children, abortSignal, optimisticUpdate }) {
|
||||||
const inputRef = useRef(null)
|
const inputRef = useRef(null)
|
||||||
const me = useMe()
|
const me = useMe()
|
||||||
const [oValue, setOValue] = useState()
|
const [oValue, setOValue] = useState()
|
||||||
const strike = useLightning()
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
inputRef.current?.focus()
|
inputRef.current?.focus()
|
||||||
@ -120,23 +119,12 @@ export default function ItemAct ({ onClose, item, down, children, abortSignal })
|
|||||||
act: down ? 'DONT_LIKE_THIS' : 'TIP',
|
act: down ? 'DONT_LIKE_THIS' : 'TIP',
|
||||||
hash,
|
hash,
|
||||||
hmac
|
hmac
|
||||||
}
|
},
|
||||||
|
update: actUpdate({ me })
|
||||||
})
|
})
|
||||||
if (!me) setItemMeAnonSats({ id: item.id, amount })
|
if (!me) setItemMeAnonSats({ id: item.id, amount })
|
||||||
addCustomTip(Number(amount))
|
addCustomTip(Number(amount))
|
||||||
}, [me, act, down, item.id, strike])
|
}, [me, act, down, item.id])
|
||||||
|
|
||||||
const optimisticUpdate = useCallback(({ amount }) => {
|
|
||||||
const variables = {
|
|
||||||
id: item.id,
|
|
||||||
sats: Number(amount),
|
|
||||||
act: down ? 'DONT_LIKE_THIS' : 'TIP'
|
|
||||||
}
|
|
||||||
const optimisticResponse = { act: { ...variables, path: item.path } }
|
|
||||||
strike()
|
|
||||||
onClose()
|
|
||||||
return { mutation: ACT_MUTATION, variables, optimisticResponse, update: actUpdate({ me }) }
|
|
||||||
}, [item.id, down, !!me, strike])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ClientNotifyProvider additionalProps={{ itemId: item.id }}>
|
<ClientNotifyProvider additionalProps={{ itemId: item.id }}>
|
||||||
@ -147,7 +135,7 @@ export default function ItemAct ({ onClose, item, down, children, abortSignal })
|
|||||||
}}
|
}}
|
||||||
schema={amountSchema}
|
schema={amountSchema}
|
||||||
prepaid
|
prepaid
|
||||||
optimisticUpdate={optimisticUpdate}
|
optimisticUpdate={({ amount }) => optimisticUpdate(amount, { onClose })}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
clientNotification={ClientNotification.Zap}
|
clientNotification={ClientNotification.Zap}
|
||||||
signal={abortSignal}
|
signal={abortSignal}
|
||||||
@ -252,9 +240,10 @@ export function useZap () {
|
|||||||
const toaster = useToast()
|
const toaster = useToast()
|
||||||
const strike = useLightning()
|
const strike = useLightning()
|
||||||
const payment = usePayment()
|
const payment = usePayment()
|
||||||
|
const { pendingSats } = useItemContext()
|
||||||
|
|
||||||
return useCallback(async ({ item, mem, abortSignal }) => {
|
return useCallback(async ({ item, abortSignal, optimisticUpdate }) => {
|
||||||
const meSats = (item?.meSats || 0)
|
const meSats = (item?.meSats || 0) + pendingSats
|
||||||
|
|
||||||
// add current sats to next tip since idempotent zaps use desired total zap not difference
|
// add current sats to next tip since idempotent zaps use desired total zap not difference
|
||||||
const sats = meSats + nextTip(meSats, { ...me?.privates })
|
const sats = meSats + nextTip(meSats, { ...me?.privates })
|
||||||
@ -262,12 +251,11 @@ export function useZap () {
|
|||||||
|
|
||||||
const variables = { id: item.id, sats, act: 'TIP' }
|
const variables = { id: item.id, sats, act: 'TIP' }
|
||||||
const notifyProps = { itemId: item.id, sats: satsDelta }
|
const notifyProps = { itemId: item.id, sats: satsDelta }
|
||||||
const optimisticResponse = { act: { path: item.path, ...variables } }
|
// const optimisticResponse = { act: { path: item.path, ...variables } }
|
||||||
|
|
||||||
let revert, cancel, nid
|
let revert, cancel, nid
|
||||||
try {
|
try {
|
||||||
revert = optimisticUpdate({ mutation: ZAP_MUTATION, variables, optimisticResponse, update })
|
revert = optimisticUpdate?.(satsDelta)
|
||||||
strike()
|
|
||||||
|
|
||||||
await abortSignal.pause({ me, amount: satsDelta })
|
await abortSignal.pause({ me, amount: satsDelta })
|
||||||
|
|
||||||
@ -277,10 +265,16 @@ export function useZap () {
|
|||||||
|
|
||||||
let hash, hmac;
|
let hash, hmac;
|
||||||
[{ hash, hmac }, cancel] = await payment.request(satsDelta)
|
[{ hash, hmac }, cancel] = await payment.request(satsDelta)
|
||||||
await zap({ variables: { ...variables, hash, hmac } })
|
|
||||||
|
await zap({
|
||||||
|
variables: { ...variables, hash, hmac },
|
||||||
|
update: (...args) => {
|
||||||
|
revert?.()
|
||||||
|
update(...args)
|
||||||
|
}
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
revert?.()
|
revert?.()
|
||||||
|
|
||||||
if (error instanceof InvoiceCanceledError || error instanceof ActCanceledError) {
|
if (error instanceof InvoiceCanceledError || error instanceof ActCanceledError) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -296,7 +290,7 @@ export function useZap () {
|
|||||||
} finally {
|
} finally {
|
||||||
if (nid) unnotify(nid)
|
if (nid) unnotify(nid)
|
||||||
}
|
}
|
||||||
}, [me?.id, strike, payment, notify, unnotify])
|
}, [me?.id, strike, payment, notify, unnotify, pendingSats])
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ActCanceledError extends Error {
|
export class ActCanceledError extends Error {
|
||||||
|
@ -15,13 +15,14 @@ import BookmarkDropdownItem from './bookmark'
|
|||||||
import SubscribeDropdownItem from './subscribe'
|
import SubscribeDropdownItem from './subscribe'
|
||||||
import { CopyLinkDropdownItem, CrosspostDropdownItem } from './share'
|
import { CopyLinkDropdownItem, CrosspostDropdownItem } from './share'
|
||||||
import Hat from './hat'
|
import Hat from './hat'
|
||||||
import { AD_USER_ID } from '@/lib/constants'
|
import { USER_ID } from '@/lib/constants'
|
||||||
import ActionDropdown from './action-dropdown'
|
import ActionDropdown from './action-dropdown'
|
||||||
import MuteDropdownItem from './mute'
|
import MuteDropdownItem from './mute'
|
||||||
import { DropdownItemUpVote } from './upvote'
|
import { DropdownItemUpVote } from './upvote'
|
||||||
import { useRoot } from './root'
|
import { useRoot } from './root'
|
||||||
import { MuteSubDropdownItem, PinSubDropdownItem } from './territory-header'
|
import { MuteSubDropdownItem, PinSubDropdownItem } from './territory-header'
|
||||||
import UserPopover from './user-popover'
|
import UserPopover from './user-popover'
|
||||||
|
import { useItemContext } from './item'
|
||||||
|
|
||||||
export default function ItemInfo ({
|
export default function ItemInfo ({
|
||||||
item, full, commentsText = 'comments',
|
item, full, commentsText = 'comments',
|
||||||
@ -36,6 +37,7 @@ export default function ItemInfo ({
|
|||||||
const [hasNewComments, setHasNewComments] = useState(false)
|
const [hasNewComments, setHasNewComments] = useState(false)
|
||||||
const [meTotalSats, setMeTotalSats] = useState(0)
|
const [meTotalSats, setMeTotalSats] = useState(0)
|
||||||
const root = useRoot()
|
const root = useRoot()
|
||||||
|
const { pendingSats, pendingCommentSats, pendingDownSats } = useItemContext()
|
||||||
const sub = item?.sub || root?.sub
|
const sub = item?.sub || root?.sub
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -45,8 +47,8 @@ export default function ItemInfo ({
|
|||||||
}, [item])
|
}, [item])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (item) setMeTotalSats((item.meSats || 0) + (item.meAnonSats || 0))
|
if (item) setMeTotalSats((item.meSats || 0) + (item.meAnonSats || 0) + (pendingSats))
|
||||||
}, [item?.meSats, item?.meAnonSats])
|
}, [item?.meSats, item?.meAnonSats, pendingSats])
|
||||||
|
|
||||||
// territory founders can pin any post in their territory
|
// territory founders can pin any post in their territory
|
||||||
// and OPs can pin any root reply in their post
|
// and OPs can pin any root reply in their post
|
||||||
@ -56,9 +58,11 @@ export default function ItemInfo ({
|
|||||||
const rootReply = item.path.split('.').length === 2
|
const rootReply = item.path.split('.').length === 2
|
||||||
const canPin = (isPost && mySub) || (myPost && rootReply)
|
const canPin = (isPost && mySub) || (myPost && rootReply)
|
||||||
|
|
||||||
|
const downSats = item.meDontLikeSats + pendingDownSats
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className || `${styles.other}`}>
|
<div className={className || `${styles.other}`}>
|
||||||
{!(item.position && (pinnable || !item.subName)) && !(!item.parentId && Number(item.user?.id) === AD_USER_ID) &&
|
{!(item.position && (pinnable || !item.subName)) && !(!item.parentId && Number(item.user?.id) === USER_ID.ad) &&
|
||||||
<>
|
<>
|
||||||
<span title={`from ${numWithUnits(item.upvotes, {
|
<span title={`from ${numWithUnits(item.upvotes, {
|
||||||
abbreviate: false,
|
abbreviate: false,
|
||||||
@ -66,11 +70,11 @@ export default function ItemInfo ({
|
|||||||
unitPlural: 'stackers'
|
unitPlural: 'stackers'
|
||||||
})} ${item.mine
|
})} ${item.mine
|
||||||
? `\\ ${numWithUnits(item.meSats, { abbreviate: false })} to post`
|
? `\\ ${numWithUnits(item.meSats, { abbreviate: false })} to post`
|
||||||
: `(${numWithUnits(meTotalSats, { abbreviate: false })}${item.meDontLikeSats
|
: `(${numWithUnits(meTotalSats, { abbreviate: false })}${downSats
|
||||||
? ` & ${numWithUnits(item.meDontLikeSats, { abbreviate: false, unitSingular: 'downsat', unitPlural: 'downsats' })}`
|
? ` & ${numWithUnits(downSats, { abbreviate: false, unitSingular: 'downsat', unitPlural: 'downsats' })}`
|
||||||
: ''} from me)`} `}
|
: ''} from me)`} `}
|
||||||
>
|
>
|
||||||
{numWithUnits(item.sats)}
|
{numWithUnits(item.sats + pendingSats)}
|
||||||
</span>
|
</span>
|
||||||
<span> \ </span>
|
<span> \ </span>
|
||||||
</>}
|
</>}
|
||||||
@ -88,7 +92,7 @@ export default function ItemInfo ({
|
|||||||
`/items/${item.id}?commentsViewedAt=${viewedAt}`,
|
`/items/${item.id}?commentsViewedAt=${viewedAt}`,
|
||||||
`/items/${item.id}`)
|
`/items/${item.id}`)
|
||||||
}
|
}
|
||||||
}} title={numWithUnits(item.commentSats)} className='text-reset position-relative'
|
}} title={numWithUnits(item.commentSats + pendingCommentSats)} className='text-reset position-relative'
|
||||||
>
|
>
|
||||||
{numWithUnits(item.ncomments, {
|
{numWithUnits(item.ncomments, {
|
||||||
abbreviate: false,
|
abbreviate: false,
|
||||||
@ -177,7 +181,7 @@ export default function ItemInfo ({
|
|||||||
<CrosspostDropdownItem item={item} />}
|
<CrosspostDropdownItem item={item} />}
|
||||||
{me && !item.position &&
|
{me && !item.position &&
|
||||||
!item.mine && !item.deletedAt &&
|
!item.mine && !item.deletedAt &&
|
||||||
(item.meDontLikeSats > meTotalSats
|
(downSats > meTotalSats
|
||||||
? <DropdownItemUpVote item={item} />
|
? <DropdownItemUpVote item={item} />
|
||||||
: <DontLikeThisDropdownItem item={item} />)}
|
: <DontLikeThisDropdownItem item={item} />)}
|
||||||
{me && sub && !item.mine && !item.outlawed && Number(me.id) === Number(sub.userId) && sub.moderated &&
|
{me && sub && !item.mine && !item.outlawed && Number(me.id) === Number(sub.userId) && sub.moderated &&
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import styles from './item.module.css'
|
import styles from './item.module.css'
|
||||||
import UpVote from './upvote'
|
import UpVote from './upvote'
|
||||||
import { useRef } from 'react'
|
import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react'
|
||||||
import { AD_USER_ID, UNKNOWN_LINK_REL } from '@/lib/constants'
|
import { USER_ID, UNKNOWN_LINK_REL } from '@/lib/constants'
|
||||||
import Pin from '@/svgs/pushpin-fill.svg'
|
import Pin from '@/svgs/pushpin-fill.svg'
|
||||||
import reactStringReplace from 'react-string-replace'
|
import reactStringReplace from 'react-string-replace'
|
||||||
import PollIcon from '@/svgs/bar-chart-horizontal-fill.svg'
|
import PollIcon from '@/svgs/bar-chart-horizontal-fill.svg'
|
||||||
@ -45,6 +45,52 @@ export function SearchTitle ({ title }) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ItemContext = createContext({
|
||||||
|
pendingSats: 0,
|
||||||
|
setPendingSats: undefined,
|
||||||
|
pendingVote: undefined,
|
||||||
|
setPendingVote: undefined,
|
||||||
|
pendingDownSats: 0,
|
||||||
|
setPendingDownSats: undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ItemContextProvider = ({ children }) => {
|
||||||
|
const parentCtx = useItemContext()
|
||||||
|
const [pendingSats, innerSetPendingSats] = useState(0)
|
||||||
|
const [pendingCommentSats, innerSetPendingCommentSats] = useState(0)
|
||||||
|
const [pendingVote, setPendingVote] = useState()
|
||||||
|
const [pendingDownSats, setPendingDownSats] = useState(0)
|
||||||
|
|
||||||
|
// cascade comment sats up to root context
|
||||||
|
const setPendingSats = useCallback((sats) => {
|
||||||
|
innerSetPendingSats(sats)
|
||||||
|
parentCtx?.setPendingCommentSats?.(sats)
|
||||||
|
}, [parentCtx?.setPendingCommentSats])
|
||||||
|
|
||||||
|
const setPendingCommentSats = useCallback((sats) => {
|
||||||
|
innerSetPendingCommentSats(sats)
|
||||||
|
parentCtx?.setPendingCommentSats?.(sats)
|
||||||
|
}, [parentCtx?.setPendingCommentSats])
|
||||||
|
|
||||||
|
const value = useMemo(() =>
|
||||||
|
({
|
||||||
|
pendingSats,
|
||||||
|
setPendingSats,
|
||||||
|
pendingCommentSats,
|
||||||
|
setPendingCommentSats,
|
||||||
|
pendingVote,
|
||||||
|
setPendingVote,
|
||||||
|
pendingDownSats,
|
||||||
|
setPendingDownSats
|
||||||
|
}),
|
||||||
|
[pendingSats, setPendingSats, pendingCommentSats, setPendingCommentSats, pendingVote, setPendingVote, pendingDownSats, setPendingDownSats])
|
||||||
|
return <ItemContext.Provider value={value}>{children}</ItemContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useItemContext = () => {
|
||||||
|
return useContext(ItemContext)
|
||||||
|
}
|
||||||
|
|
||||||
export default function Item ({ item, rank, belowTitle, right, full, children, siblingComments, onQuoteReply, pinnable }) {
|
export default function Item ({ item, rank, belowTitle, right, full, children, siblingComments, onQuoteReply, pinnable }) {
|
||||||
const titleRef = useRef()
|
const titleRef = useRef()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@ -52,7 +98,7 @@ export default function Item ({ item, rank, belowTitle, right, full, children, s
|
|||||||
const image = item.url && item.url.startsWith(process.env.NEXT_PUBLIC_IMGPROXY_URL)
|
const image = item.url && item.url.startsWith(process.env.NEXT_PUBLIC_IMGPROXY_URL)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<ItemContextProvider>
|
||||||
{rank
|
{rank
|
||||||
? (
|
? (
|
||||||
<div className={styles.rank}>
|
<div className={styles.rank}>
|
||||||
@ -60,13 +106,7 @@ export default function Item ({ item, rank, belowTitle, right, full, children, s
|
|||||||
</div>)
|
</div>)
|
||||||
: <div />}
|
: <div />}
|
||||||
<div className={`${styles.item} ${siblingComments ? 'pt-3' : ''}`}>
|
<div className={`${styles.item} ${siblingComments ? 'pt-3' : ''}`}>
|
||||||
{item.position && (pinnable || !item.subName)
|
<ZapIcon item={item} pinnable={pinnable} />
|
||||||
? <Pin width={24} height={24} className={styles.pin} />
|
|
||||||
: item.meDontLikeSats > item.meSats
|
|
||||||
? <DownZap width={24} height={24} className={styles.dontLike} item={item} />
|
|
||||||
: Number(item.user?.id) === AD_USER_ID
|
|
||||||
? <AdIcon width={24} height={24} className={styles.ad} />
|
|
||||||
: <UpVote item={item} className={styles.upvote} />}
|
|
||||||
<div className={styles.hunk}>
|
<div className={styles.hunk}>
|
||||||
<div className={`${styles.main} flex-wrap`}>
|
<div className={`${styles.main} flex-wrap`}>
|
||||||
<Link
|
<Link
|
||||||
@ -99,7 +139,7 @@ export default function Item ({ item, rank, belowTitle, right, full, children, s
|
|||||||
full={full} item={item}
|
full={full} item={item}
|
||||||
onQuoteReply={onQuoteReply}
|
onQuoteReply={onQuoteReply}
|
||||||
pinnable={pinnable}
|
pinnable={pinnable}
|
||||||
extraBadges={Number(item?.user?.id) === AD_USER_ID && <Badge className={styles.newComment} bg={null}>AD</Badge>}
|
extraBadges={Number(item?.user?.id) === USER_ID.ad && <Badge className={styles.newComment} bg={null}>AD</Badge>}
|
||||||
/>
|
/>
|
||||||
{belowTitle}
|
{belowTitle}
|
||||||
</div>
|
</div>
|
||||||
@ -110,7 +150,7 @@ export default function Item ({ item, rank, belowTitle, right, full, children, s
|
|||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</ItemContextProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -130,7 +170,7 @@ export function ItemSummary ({ item }) {
|
|||||||
item={item}
|
item={item}
|
||||||
showUser={false}
|
showUser={false}
|
||||||
showActionDropdown={false}
|
showActionDropdown={false}
|
||||||
extraBadges={item.title && Number(item?.user?.id) === AD_USER_ID && <Badge className={styles.newComment} bg={null}>AD</Badge>}
|
extraBadges={item.title && Number(item?.user?.id) === USER_ID.ad && <Badge className={styles.newComment} bg={null}>AD</Badge>}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -188,6 +228,21 @@ export function ItemSkeleton ({ rank, children, showUpvote = true }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ZapIcon ({ item, pinnable }) {
|
||||||
|
const { pendingSats, pendingDownSats } = useItemContext()
|
||||||
|
|
||||||
|
const meSats = item.meSats + pendingSats
|
||||||
|
const downSats = item.meDontLikeSats + pendingDownSats
|
||||||
|
|
||||||
|
return item.position && (pinnable || !item.subName)
|
||||||
|
? <Pin width={24} height={24} className={styles.pin} />
|
||||||
|
: downSats > meSats
|
||||||
|
? <DownZap width={24} height={24} className={styles.dontLike} item={item} />
|
||||||
|
: Number(item.user?.id) === USER_ID.ad
|
||||||
|
? <AdIcon width={24} height={24} className={styles.ad} />
|
||||||
|
: <UpVote item={item} className={styles.upvote} />
|
||||||
|
}
|
||||||
|
|
||||||
function PollIndicator ({ item }) {
|
function PollIndicator ({ item }) {
|
||||||
const hasExpiration = !!item.pollExpiresAt
|
const hasExpiration = !!item.pollExpiresAt
|
||||||
const timeRemaining = timeLeft(new Date(item.pollExpiresAt))
|
const timeRemaining = timeLeft(new Date(item.pollExpiresAt))
|
||||||
|
@ -6,7 +6,7 @@ import BackArrow from '../../svgs/arrow-left-line.svg'
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import Price from '../price'
|
import Price from '../price'
|
||||||
import SubSelect from '../sub-select'
|
import SubSelect from '../sub-select'
|
||||||
import { ANON_USER_ID, BALANCE_LIMIT_MSATS, Wallet } from '../../lib/constants'
|
import { USER_ID, BALANCE_LIMIT_MSATS, Wallet } from '../../lib/constants'
|
||||||
import Head from 'next/head'
|
import Head from 'next/head'
|
||||||
import NoteIcon from '../../svgs/notification-4-fill.svg'
|
import NoteIcon from '../../svgs/notification-4-fill.svg'
|
||||||
import { useMe } from '../me'
|
import { useMe } from '../me'
|
||||||
@ -307,7 +307,7 @@ export function AnonDropdown ({ path }) {
|
|||||||
<Dropdown className={styles.dropdown} align='end'>
|
<Dropdown className={styles.dropdown} align='end'>
|
||||||
<Dropdown.Toggle className='nav-link nav-item' id='profile' variant='custom'>
|
<Dropdown.Toggle className='nav-link nav-item' id='profile' variant='custom'>
|
||||||
<Nav.Link eventKey='anon' as='span' className='p-0 fw-normal'>
|
<Nav.Link eventKey='anon' as='span' className='p-0 fw-normal'>
|
||||||
@anon<Hat user={{ id: ANON_USER_ID }} />
|
@anon<Hat user={{ id: USER_ID.anon }} />
|
||||||
</Nav.Link>
|
</Nav.Link>
|
||||||
</Dropdown.Toggle>
|
</Dropdown.Toggle>
|
||||||
<Dropdown.Menu className='p-3'>
|
<Dropdown.Menu className='p-3'>
|
||||||
|
@ -36,13 +36,8 @@ import { ITEM_FULL } from '@/fragments/items'
|
|||||||
function Notification ({ n, fresh }) {
|
function Notification ({ n, fresh }) {
|
||||||
const type = n.__typename
|
const type = n.__typename
|
||||||
|
|
||||||
// we need to resolve item id to item to show item for client notifications
|
|
||||||
const { data } = useQuery(ITEM_FULL, { variables: { id: n.itemId }, skip: !n.itemId })
|
|
||||||
const item = data?.item
|
|
||||||
const itemN = { item, ...n }
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NotificationLayout nid={nid(n)} {...defaultOnClick(itemN)} fresh={fresh}>
|
<NotificationLayout nid={nid(n)} {...defaultOnClick(n)} fresh={fresh}>
|
||||||
{
|
{
|
||||||
(type === 'Earn' && <EarnNotification n={n} />) ||
|
(type === 'Earn' && <EarnNotification n={n} />) ||
|
||||||
(type === 'Revenue' && <RevenueNotification n={n} />) ||
|
(type === 'Revenue' && <RevenueNotification n={n} />) ||
|
||||||
@ -54,6 +49,7 @@ function Notification ({ n, fresh }) {
|
|||||||
(type === 'Votification' && <Votification n={n} />) ||
|
(type === 'Votification' && <Votification n={n} />) ||
|
||||||
(type === 'ForwardedVotification' && <ForwardedVotification n={n} />) ||
|
(type === 'ForwardedVotification' && <ForwardedVotification n={n} />) ||
|
||||||
(type === 'Mention' && <Mention n={n} />) ||
|
(type === 'Mention' && <Mention n={n} />) ||
|
||||||
|
(type === 'ItemMention' && <ItemMention n={n} />) ||
|
||||||
(type === 'JobChanged' && <JobChanged n={n} />) ||
|
(type === 'JobChanged' && <JobChanged n={n} />) ||
|
||||||
(type === 'Reply' && <Reply n={n} />) ||
|
(type === 'Reply' && <Reply n={n} />) ||
|
||||||
(type === 'SubStatus' && <SubStatus n={n} />) ||
|
(type === 'SubStatus' && <SubStatus n={n} />) ||
|
||||||
@ -61,15 +57,26 @@ function Notification ({ n, fresh }) {
|
|||||||
(type === 'TerritoryPost' && <TerritoryPost n={n} />) ||
|
(type === 'TerritoryPost' && <TerritoryPost n={n} />) ||
|
||||||
(type === 'TerritoryTransfer' && <TerritoryTransfer n={n} />) ||
|
(type === 'TerritoryTransfer' && <TerritoryTransfer n={n} />) ||
|
||||||
(type === 'Reminder' && <Reminder n={n} />) ||
|
(type === 'Reminder' && <Reminder n={n} />) ||
|
||||||
([ClientTypes.Zap.ERROR, ClientTypes.Zap.PENDING].includes(type) && <ClientZap n={itemN} />) ||
|
<ClientNotification n={n} />
|
||||||
([ClientTypes.Reply.ERROR, ClientTypes.Reply.PENDING].includes(type) && <ClientReply n={itemN} />) ||
|
|
||||||
([ClientTypes.Bounty.ERROR, ClientTypes.Bounty.PENDING].includes(type) && <ClientBounty n={itemN} />) ||
|
|
||||||
([ClientTypes.PollVote.ERROR, ClientTypes.PollVote.PENDING].includes(type) && <ClientPollVote n={itemN} />)
|
|
||||||
}
|
}
|
||||||
</NotificationLayout>
|
</NotificationLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ClientNotification ({ n }) {
|
||||||
|
// we need to resolve item id to item to show item for client notifications
|
||||||
|
const { data } = useQuery(ITEM_FULL, { variables: { id: n.itemId }, skip: !n.itemId })
|
||||||
|
const item = data?.item
|
||||||
|
const itemN = { item, ...n }
|
||||||
|
|
||||||
|
return (
|
||||||
|
([ClientTypes.Zap.ERROR, ClientTypes.Zap.PENDING].includes(n.__typename) && <ClientZap n={itemN} />) ||
|
||||||
|
([ClientTypes.Reply.ERROR, ClientTypes.Reply.PENDING].includes(n.__typename) && <ClientReply n={itemN} />) ||
|
||||||
|
([ClientTypes.Bounty.ERROR, ClientTypes.Bounty.PENDING].includes(n.__typename) && <ClientBounty n={itemN} />) ||
|
||||||
|
([ClientTypes.PollVote.ERROR, ClientTypes.PollVote.PENDING].includes(n.__typename) && <ClientPollVote n={itemN} />)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function NotificationLayout ({ children, nid, href, as, fresh }) {
|
function NotificationLayout ({ children, nid, href, as, fresh }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
if (!href) return <div className={fresh ? styles.fresh : ''}>{children}</div>
|
if (!href) return <div className={fresh ? styles.fresh : ''}>{children}</div>
|
||||||
@ -391,6 +398,26 @@ function Mention ({ n }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ItemMention ({ n }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<small className='fw-bold text-info ms-2'>
|
||||||
|
your item was mentioned in
|
||||||
|
</small>
|
||||||
|
<div>
|
||||||
|
{n.item?.title
|
||||||
|
? <Item item={n.item} />
|
||||||
|
: (
|
||||||
|
<div className='pb-2'>
|
||||||
|
<RootProvider root={n.item.root}>
|
||||||
|
<Comment item={n.item} noReply includeParent rootText='replying on:' clickToContext />
|
||||||
|
</RootProvider>
|
||||||
|
</div>)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function JobChanged ({ n }) {
|
function JobChanged ({ n }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -6,9 +6,8 @@ import { useMe } from './me'
|
|||||||
import { numWithUnits } from '@/lib/format'
|
import { numWithUnits } from '@/lib/format'
|
||||||
import { useShowModal } from './modal'
|
import { useShowModal } from './modal'
|
||||||
import { useRoot } from './root'
|
import { useRoot } from './root'
|
||||||
import { useAct, actUpdate, ACT_MUTATION } from './item-act'
|
import { useAct, actUpdate } from './item-act'
|
||||||
import { InvoiceCanceledError, usePayment } from './payment'
|
import { InvoiceCanceledError, usePayment } from './payment'
|
||||||
import { optimisticUpdate } from '@/lib/apollo'
|
|
||||||
import { useLightning } from './lightning'
|
import { useLightning } from './lightning'
|
||||||
import { useToast } from './toast'
|
import { useToast } from './toast'
|
||||||
import { Types as ClientNotification, useClientNotifications } from './client-notifications'
|
import { Types as ClientNotification, useClientNotifications } from './client-notifications'
|
||||||
@ -45,30 +44,21 @@ export default function PayBounty ({ children, item }) {
|
|||||||
const notifyProps = { itemId: item.id, sats }
|
const notifyProps = { itemId: item.id, sats }
|
||||||
const optimisticResponse = { act: { ...variables, path: item.path } }
|
const optimisticResponse = { act: { ...variables, path: item.path } }
|
||||||
|
|
||||||
let revert, cancel, nid
|
let cancel, nid
|
||||||
try {
|
try {
|
||||||
revert = optimisticUpdate({
|
|
||||||
mutation: ACT_MUTATION,
|
|
||||||
variables,
|
|
||||||
optimisticResponse,
|
|
||||||
update: actUpdate({ me, onUpdate: onUpdate(onComplete) })
|
|
||||||
})
|
|
||||||
|
|
||||||
if (me) {
|
if (me) {
|
||||||
nid = notify(ClientNotification.Bounty.PENDING, notifyProps)
|
nid = notify(ClientNotification.Bounty.PENDING, notifyProps)
|
||||||
}
|
}
|
||||||
|
|
||||||
let hash, hmac;
|
let hash, hmac;
|
||||||
[{ hash, hmac }, cancel] = await payment.request(sats)
|
[{ hash, hmac }, cancel] = await payment.request(sats)
|
||||||
|
|
||||||
await act({
|
await act({
|
||||||
variables: { hash, hmac, ...variables },
|
variables: { hash, hmac, ...variables },
|
||||||
optimisticResponse: {
|
optimisticResponse,
|
||||||
act: variables
|
update: actUpdate({ me, onUpdate: onUpdate(onComplete) })
|
||||||
}
|
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
revert?.()
|
|
||||||
|
|
||||||
if (error instanceof InvoiceCanceledError) {
|
if (error instanceof InvoiceCanceledError) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -4,14 +4,13 @@ import { fixedDecimal, numWithUnits } from '@/lib/format'
|
|||||||
import { timeLeft } from '@/lib/time'
|
import { timeLeft } from '@/lib/time'
|
||||||
import { useMe } from './me'
|
import { useMe } from './me'
|
||||||
import styles from './poll.module.css'
|
import styles from './poll.module.css'
|
||||||
import Check from '@/svgs/checkbox-circle-fill.svg'
|
|
||||||
import { signIn } from 'next-auth/react'
|
import { signIn } from 'next-auth/react'
|
||||||
import ActionTooltip from './action-tooltip'
|
import ActionTooltip from './action-tooltip'
|
||||||
import { POLL_COST } from '@/lib/constants'
|
import { POLL_COST } from '@/lib/constants'
|
||||||
import { InvoiceCanceledError, usePayment } from './payment'
|
import { InvoiceCanceledError, usePayment } from './payment'
|
||||||
import { optimisticUpdate } from '@/lib/apollo'
|
|
||||||
import { useToast } from './toast'
|
import { useToast } from './toast'
|
||||||
import { Types as ClientNotification, useClientNotifications } from './client-notifications'
|
import { Types as ClientNotification, useClientNotifications } from './client-notifications'
|
||||||
|
import { useItemContext } from './item'
|
||||||
|
|
||||||
export default function Poll ({ item }) {
|
export default function Poll ({ item }) {
|
||||||
const me = useMe()
|
const me = useMe()
|
||||||
@ -22,6 +21,7 @@ export default function Poll ({ item }) {
|
|||||||
const [pollVote] = useMutation(POLL_VOTE_MUTATION)
|
const [pollVote] = useMutation(POLL_VOTE_MUTATION)
|
||||||
const toaster = useToast()
|
const toaster = useToast()
|
||||||
const { notify, unnotify } = useClientNotifications()
|
const { notify, unnotify } = useClientNotifications()
|
||||||
|
const { pendingVote, setPendingVote } = useItemContext()
|
||||||
|
|
||||||
const update = (cache, { data: { pollVote } }) => {
|
const update = (cache, { data: { pollVote } }) => {
|
||||||
cache.modify({
|
cache.modify({
|
||||||
@ -40,9 +40,6 @@ export default function Poll ({ item }) {
|
|||||||
fields: {
|
fields: {
|
||||||
count (existingCount) {
|
count (existingCount) {
|
||||||
return existingCount + 1
|
return existingCount + 1
|
||||||
},
|
|
||||||
meVoted () {
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -59,9 +56,9 @@ export default function Poll ({ item }) {
|
|||||||
const variables = { id: v.id }
|
const variables = { id: v.id }
|
||||||
const notifyProps = { itemId: item.id }
|
const notifyProps = { itemId: item.id }
|
||||||
const optimisticResponse = { pollVote: v.id }
|
const optimisticResponse = { pollVote: v.id }
|
||||||
let revert, cancel, nid
|
let cancel, nid
|
||||||
try {
|
try {
|
||||||
revert = optimisticUpdate({ mutation: POLL_VOTE_MUTATION, variables, optimisticResponse, update })
|
setPendingVote(v.id)
|
||||||
|
|
||||||
if (me) {
|
if (me) {
|
||||||
nid = notify(ClientNotification.PollVote.PENDING, notifyProps)
|
nid = notify(ClientNotification.PollVote.PENDING, notifyProps)
|
||||||
@ -69,10 +66,9 @@ export default function Poll ({ item }) {
|
|||||||
|
|
||||||
let hash, hmac;
|
let hash, hmac;
|
||||||
[{ hash, hmac }, cancel] = await payment.request(item.pollCost || POLL_COST)
|
[{ hash, hmac }, cancel] = await payment.request(item.pollCost || POLL_COST)
|
||||||
await pollVote({ variables: { hash, hmac, ...variables } })
|
|
||||||
} catch (error) {
|
|
||||||
revert?.()
|
|
||||||
|
|
||||||
|
await pollVote({ variables: { hash, hmac, ...variables }, optimisticResponse, update })
|
||||||
|
} catch (error) {
|
||||||
if (error instanceof InvoiceCanceledError) {
|
if (error instanceof InvoiceCanceledError) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -86,6 +82,7 @@ export default function Poll ({ item }) {
|
|||||||
|
|
||||||
cancel?.()
|
cancel?.()
|
||||||
} finally {
|
} finally {
|
||||||
|
setPendingVote(undefined)
|
||||||
if (nid) unnotify(nid)
|
if (nid) unnotify(nid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -100,7 +97,8 @@ export default function Poll ({ item }) {
|
|||||||
const hasExpiration = !!item.pollExpiresAt
|
const hasExpiration = !!item.pollExpiresAt
|
||||||
const timeRemaining = timeLeft(new Date(item.pollExpiresAt))
|
const timeRemaining = timeLeft(new Date(item.pollExpiresAt))
|
||||||
const mine = item.user.id === me?.id
|
const mine = item.user.id === me?.id
|
||||||
const showPollButton = (!hasExpiration || timeRemaining) && !item.poll.meVoted && !mine
|
const showPollButton = (!hasExpiration || timeRemaining) && !item.poll.meVoted && !mine && !pendingVote
|
||||||
|
const pollCount = item.poll.count + (pendingVote ? 1 : 0)
|
||||||
return (
|
return (
|
||||||
<div className={styles.pollBox}>
|
<div className={styles.pollBox}>
|
||||||
{item.poll.options.map(v =>
|
{item.poll.options.map(v =>
|
||||||
@ -108,10 +106,12 @@ export default function Poll ({ item }) {
|
|||||||
? <PollButton key={v.id} v={v} />
|
? <PollButton key={v.id} v={v} />
|
||||||
: <PollResult
|
: <PollResult
|
||||||
key={v.id} v={v}
|
key={v.id} v={v}
|
||||||
progress={item.poll.count ? fixedDecimal(v.count * 100 / item.poll.count, 1) : 0}
|
progress={pollCount
|
||||||
|
? fixedDecimal((v.count + (pendingVote === v.id ? 1 : 0)) * 100 / pollCount, 1)
|
||||||
|
: 0}
|
||||||
/>)}
|
/>)}
|
||||||
<div className='text-muted mt-1'>
|
<div className='text-muted mt-1'>
|
||||||
{numWithUnits(item.poll.count, { unitSingular: 'vote', unitPlural: 'votes' })}
|
{numWithUnits(pollCount, { unitSingular: 'vote', unitPlural: 'votes' })}
|
||||||
{hasExpiration && ` \\ ${timeRemaining ? `${timeRemaining} left` : 'poll ended'}`}
|
{hasExpiration && ` \\ ${timeRemaining ? `${timeRemaining} left` : 'poll ended'}`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -121,7 +121,7 @@ export default function Poll ({ item }) {
|
|||||||
function PollResult ({ v, progress }) {
|
function PollResult ({ v, progress }) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.pollResult}>
|
<div className={styles.pollResult}>
|
||||||
<span className={styles.pollOption}>{v.option}{v.meVoted && <Check className='fill-grey ms-1 align-self-center flex-shrink-0' width={16} height={16} />}</span>
|
<span className={styles.pollOption}>{v.option}</span>
|
||||||
<span className='ms-auto me-2 align-self-center'>{progress}%</span>
|
<span className='ms-auto me-2 align-self-center'>{progress}%</span>
|
||||||
<div className={styles.pollProgress} style={{ width: `${progress}%` }} />
|
<div className={styles.pollProgress} style={{ width: `${progress}%` }} />
|
||||||
</div>
|
</div>
|
||||||
|
@ -186,6 +186,7 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
|
|||||||
} catch {
|
} catch {
|
||||||
// ignore invalid URLs
|
// ignore invalid URLs
|
||||||
}
|
}
|
||||||
|
|
||||||
const internalURL = process.env.NEXT_PUBLIC_URL
|
const internalURL = process.env.NEXT_PUBLIC_URL
|
||||||
if (!!text && !/^https?:\/\//.test(text)) {
|
if (!!text && !/^https?:\/\//.test(text)) {
|
||||||
if (props['data-footnote-ref'] || typeof props['data-footnote-backref'] !== 'undefined') {
|
if (props['data-footnote-ref'] || typeof props['data-footnote-backref'] !== 'undefined') {
|
||||||
@ -210,6 +211,19 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
|
|||||||
</UserPopover>
|
</UserPopover>
|
||||||
)
|
)
|
||||||
} else if (href.startsWith('/') || url?.origin === internalURL) {
|
} else if (href.startsWith('/') || url?.origin === internalURL) {
|
||||||
|
try {
|
||||||
|
const { linkText } = parseInternalLinks(href)
|
||||||
|
if (linkText) {
|
||||||
|
return (
|
||||||
|
<ItemPopover id={linkText.replace('#', '').split('/')[0]}>
|
||||||
|
<Link href={href}>{text}</Link>
|
||||||
|
</ItemPopover>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore errors like invalid URLs
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
id={props.id}
|
id={props.id}
|
||||||
@ -226,7 +240,7 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const linkText = parseInternalLinks(href)
|
const { linkText } = parseInternalLinks(href)
|
||||||
if (linkText) {
|
if (linkText) {
|
||||||
return (
|
return (
|
||||||
<ItemPopover id={linkText.replace('#', '').split('/')[0]}>
|
<ItemPopover id={linkText.replace('#', '').split('/')[0]}>
|
||||||
@ -244,7 +258,6 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
|
|||||||
paddingRight: '15px'
|
paddingRight: '15px'
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
const { provider, id, meta } = parseEmbedUrl(href)
|
const { provider, id, meta } = parseEmbedUrl(href)
|
||||||
// Youtube video embed
|
// Youtube video embed
|
||||||
if (provider === 'youtube') {
|
if (provider === 'youtube') {
|
||||||
@ -275,9 +288,6 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// ignore invalid URLs
|
|
||||||
}
|
|
||||||
|
|
||||||
// assume the link is an image which will fallback to link if it's not
|
// assume the link is an image which will fallback to link if it's not
|
||||||
return <Img src={href} rel={rel ?? UNKNOWN_LINK_REL} {...props}>{children}</Img>
|
return <Img src={href} rel={rel ?? UNKNOWN_LINK_REL} {...props}>{children}</Img>
|
||||||
|
@ -12,6 +12,8 @@ import Popover from 'react-bootstrap/Popover'
|
|||||||
import { useShowModal } from './modal'
|
import { useShowModal } from './modal'
|
||||||
import { numWithUnits } from '@/lib/format'
|
import { numWithUnits } from '@/lib/format'
|
||||||
import { Dropdown } from 'react-bootstrap'
|
import { Dropdown } from 'react-bootstrap'
|
||||||
|
import { useLightning } from './lightning'
|
||||||
|
import { useItemContext } from './item'
|
||||||
|
|
||||||
const UpvotePopover = ({ target, show, handleClose }) => {
|
const UpvotePopover = ({ target, show, handleClose }) => {
|
||||||
const me = useMe()
|
const me = useMe()
|
||||||
@ -54,12 +56,23 @@ const TipPopover = ({ target, show, handleClose }) => (
|
|||||||
|
|
||||||
export function DropdownItemUpVote ({ item }) {
|
export function DropdownItemUpVote ({ item }) {
|
||||||
const showModal = useShowModal()
|
const showModal = useShowModal()
|
||||||
|
const { setPendingSats } = useItemContext()
|
||||||
|
const strike = useLightning()
|
||||||
|
|
||||||
|
const optimisticUpdate = useCallback((sats, { onClose } = {}) => {
|
||||||
|
setPendingSats(pendingSats => pendingSats + sats)
|
||||||
|
strike()
|
||||||
|
onClose?.()
|
||||||
|
return () => {
|
||||||
|
setPendingSats(pendingSats => pendingSats - sats)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dropdown.Item
|
<Dropdown.Item
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
showModal(onClose =>
|
showModal(onClose =>
|
||||||
<ItemAct onClose={onClose} item={item} />)
|
<ItemAct onClose={onClose} item={item} optimisticUpdate={optimisticUpdate} />)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className='text-success'>zap</span>
|
<span className='text-success'>zap</span>
|
||||||
@ -96,8 +109,9 @@ export default function UpVote ({ item, className }) {
|
|||||||
setWalkthrough(upvotePopover: $upvotePopover, tipPopover: $tipPopover)
|
setWalkthrough(upvotePopover: $upvotePopover, tipPopover: $tipPopover)
|
||||||
}`
|
}`
|
||||||
)
|
)
|
||||||
|
const strike = useLightning()
|
||||||
const [controller, setController] = useState(null)
|
const [controller, setController] = useState()
|
||||||
|
const { pendingSats, setPendingSats } = useItemContext()
|
||||||
const pending = controller?.started && !controller.done
|
const pending = controller?.started && !controller.done
|
||||||
|
|
||||||
const setVoteShow = useCallback((yes) => {
|
const setVoteShow = useCallback((yes) => {
|
||||||
@ -134,7 +148,7 @@ export default function UpVote ({ item, className }) {
|
|||||||
[item?.mine, item?.meForward, item?.deletedAt])
|
[item?.mine, item?.meForward, item?.deletedAt])
|
||||||
|
|
||||||
const [meSats, overlayText, color, nextColor] = useMemo(() => {
|
const [meSats, overlayText, color, nextColor] = useMemo(() => {
|
||||||
const meSats = (item?.meSats || item?.meAnonSats || 0)
|
const meSats = (item?.meSats || item?.meAnonSats || 0) + pendingSats
|
||||||
|
|
||||||
// what should our next tip be?
|
// what should our next tip be?
|
||||||
const sats = nextTip(meSats, { ...me?.privates })
|
const sats = nextTip(meSats, { ...me?.privates })
|
||||||
@ -142,7 +156,16 @@ export default function UpVote ({ item, className }) {
|
|||||||
return [
|
return [
|
||||||
meSats, me ? numWithUnits(sats, { abbreviate: false }) : 'zap it',
|
meSats, me ? numWithUnits(sats, { abbreviate: false }) : 'zap it',
|
||||||
getColor(meSats), getColor(meSats + sats)]
|
getColor(meSats), getColor(meSats + sats)]
|
||||||
}, [item?.meSats, item?.meAnonSats, me?.privates?.tipDefault, me?.privates?.turboDefault])
|
}, [item?.meSats, item?.meAnonSats, pendingSats, me?.privates?.tipDefault, me?.privates?.turboDefault])
|
||||||
|
|
||||||
|
const optimisticUpdate = useCallback((sats, { onClose } = {}) => {
|
||||||
|
setPendingSats(pendingSats => pendingSats + sats)
|
||||||
|
strike()
|
||||||
|
onClose?.()
|
||||||
|
return () => {
|
||||||
|
setPendingSats(pendingSats => pendingSats - sats)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleModalClosed = () => {
|
const handleModalClosed = () => {
|
||||||
setHover(false)
|
setHover(false)
|
||||||
@ -160,13 +183,16 @@ export default function UpVote ({ item, className }) {
|
|||||||
|
|
||||||
if (pending) {
|
if (pending) {
|
||||||
controller.abort()
|
controller.abort()
|
||||||
|
setController(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const c = new ZapUndoController()
|
const c = new ZapUndoController()
|
||||||
setController(c)
|
setController(c)
|
||||||
|
|
||||||
showModal(onClose =>
|
showModal(onClose =>
|
||||||
<ItemAct onClose={onClose} item={item} abortSignal={c.signal} />, { onClose: handleModalClosed })
|
<ItemAct
|
||||||
|
onClose={onClose} item={item} abortSignal={c.signal} optimisticUpdate={optimisticUpdate}
|
||||||
|
/>, { onClose: handleModalClosed })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleShortPress = async () => {
|
const handleShortPress = async () => {
|
||||||
@ -186,18 +212,19 @@ export default function UpVote ({ item, className }) {
|
|||||||
|
|
||||||
if (pending) {
|
if (pending) {
|
||||||
controller.abort()
|
controller.abort()
|
||||||
|
setController(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const c = new ZapUndoController()
|
const c = new ZapUndoController()
|
||||||
setController(c)
|
setController(c)
|
||||||
|
|
||||||
await zap({ item, me, abortSignal: c.signal })
|
await zap({ item, me, abortSignal: c.signal, optimisticUpdate })
|
||||||
} else {
|
} else {
|
||||||
showModal(onClose => <ItemAct onClose={onClose} item={item} />, { onClose: handleModalClosed })
|
showModal(onClose => <ItemAct onClose={onClose} item={item} optimisticUpdate={optimisticUpdate} />, { onClose: handleModalClosed })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fillColor = hover ? nextColor : color
|
const fillColor = hover || pending ? nextColor : color
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className='upvoteParent'>
|
<div ref={ref} className='upvoteParent'>
|
||||||
@ -222,7 +249,7 @@ export default function UpVote ({ item, className }) {
|
|||||||
${meSats ? styles.voted : ''}
|
${meSats ? styles.voted : ''}
|
||||||
${pending ? styles.pending : ''}`
|
${pending ? styles.pending : ''}`
|
||||||
}
|
}
|
||||||
style={meSats || hover
|
style={meSats || hover || pending
|
||||||
? {
|
? {
|
||||||
fill: fillColor,
|
fill: fillColor,
|
||||||
filter: `drop-shadow(0 0 6px ${fillColor}90)`
|
filter: `drop-shadow(0 0 6px ${fillColor}90)`
|
||||||
|
@ -498,6 +498,10 @@ services:
|
|||||||
- 'stacker_lnd'
|
- 'stacker_lnd'
|
||||||
- '--lnd-port'
|
- '--lnd-port'
|
||||||
- '10009'
|
- '10009'
|
||||||
|
- '--max-amount'
|
||||||
|
- '0'
|
||||||
|
- '--daily-limit'
|
||||||
|
- '0'
|
||||||
lnbits:
|
lnbits:
|
||||||
image: lnbits/lnbits:0.12.5
|
image: lnbits/lnbits:0.12.5
|
||||||
container_name: lnbits
|
container_name: lnbits
|
||||||
|
@ -126,7 +126,6 @@ export const POLL_FIELDS = gql`
|
|||||||
id
|
id
|
||||||
option
|
option
|
||||||
count
|
count
|
||||||
meVoted
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
|
@ -25,6 +25,14 @@ export const NOTIFICATIONS = gql`
|
|||||||
text
|
text
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
... on ItemMention {
|
||||||
|
id
|
||||||
|
sortTime
|
||||||
|
item {
|
||||||
|
...ItemFullFields
|
||||||
|
text
|
||||||
|
}
|
||||||
|
}
|
||||||
... on Votification {
|
... on Votification {
|
||||||
id
|
id
|
||||||
sortTime
|
sortTime
|
||||||
|
@ -38,6 +38,7 @@ export const ME = gql`
|
|||||||
noteItemSats
|
noteItemSats
|
||||||
noteJobIndicator
|
noteJobIndicator
|
||||||
noteMentions
|
noteMentions
|
||||||
|
noteItemMentions
|
||||||
sats
|
sats
|
||||||
tipDefault
|
tipDefault
|
||||||
tipPopover
|
tipPopover
|
||||||
@ -73,6 +74,7 @@ export const SETTINGS_FIELDS = gql`
|
|||||||
noteEarning
|
noteEarning
|
||||||
noteAllDescendants
|
noteAllDescendants
|
||||||
noteMentions
|
noteMentions
|
||||||
|
noteItemMentions
|
||||||
noteDeposits
|
noteDeposits
|
||||||
noteWithdrawals
|
noteWithdrawals
|
||||||
noteInvites
|
noteInvites
|
||||||
|
@ -255,17 +255,3 @@ function getClient (uri) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function optimisticUpdate ({ mutation, variables, optimisticResponse, update }) {
|
|
||||||
const { cache, queryManager } = getApolloClient()
|
|
||||||
|
|
||||||
const mutationId = String(queryManager.mutationIdCounter++)
|
|
||||||
queryManager.markMutationOptimistic(optimisticResponse, {
|
|
||||||
mutationId,
|
|
||||||
document: mutation,
|
|
||||||
variables,
|
|
||||||
update
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => cache.removeOptimistic(mutationId)
|
|
||||||
}
|
|
||||||
|
@ -36,8 +36,16 @@ export const ITEM_SPAM_INTERVAL = '10m'
|
|||||||
export const ANON_ITEM_SPAM_INTERVAL = '0'
|
export const ANON_ITEM_SPAM_INTERVAL = '0'
|
||||||
export const INV_PENDING_LIMIT = 100
|
export const INV_PENDING_LIMIT = 100
|
||||||
export const BALANCE_LIMIT_MSATS = 100000000 // 100k sat
|
export const BALANCE_LIMIT_MSATS = 100000000 // 100k sat
|
||||||
export const SN_USER_IDS = [616, 6030, 4502, 27]
|
export const USER_ID = {
|
||||||
export const SN_NO_REWARDS_IDS = [27, 4502]
|
k00b: 616,
|
||||||
|
ek: 6030,
|
||||||
|
sn: 4502,
|
||||||
|
anon: 27,
|
||||||
|
ad: 9,
|
||||||
|
delete: 106
|
||||||
|
}
|
||||||
|
export const SN_USER_IDS = [USER_ID.k00b, USER_ID.ek, USER_ID.sn]
|
||||||
|
export const SN_NO_REWARDS_IDS = [USER_ID.anon, USER_ID.sn]
|
||||||
export const ANON_INV_PENDING_LIMIT = 1000
|
export const ANON_INV_PENDING_LIMIT = 1000
|
||||||
export const ANON_BALANCE_LIMIT_MSATS = 0 // disable
|
export const ANON_BALANCE_LIMIT_MSATS = 0 // disable
|
||||||
export const MAX_POLL_NUM_CHOICES = 10
|
export const MAX_POLL_NUM_CHOICES = 10
|
||||||
@ -54,17 +62,14 @@ export const ITEM_TYPES_USER = ['all', 'posts', 'comments', 'bounties', 'links',
|
|||||||
export const ITEM_TYPES = ['all', 'posts', 'comments', 'bounties', 'links', 'discussions', 'polls', 'freebies', 'bios', 'jobs']
|
export const ITEM_TYPES = ['all', 'posts', 'comments', 'bounties', 'links', 'discussions', 'polls', 'freebies', 'bios', 'jobs']
|
||||||
export const ITEM_TYPES_UNIVERSAL = ['all', 'posts', 'comments', 'freebies']
|
export const ITEM_TYPES_UNIVERSAL = ['all', 'posts', 'comments', 'freebies']
|
||||||
export const OLD_ITEM_DAYS = 3
|
export const OLD_ITEM_DAYS = 3
|
||||||
export const ANON_USER_ID = 27
|
|
||||||
export const DELETE_USER_ID = 106
|
|
||||||
export const AD_USER_ID = 9
|
|
||||||
export const ANON_FEE_MULTIPLIER = 100
|
export const ANON_FEE_MULTIPLIER = 100
|
||||||
export const SSR = typeof window === 'undefined'
|
export const SSR = typeof window === 'undefined'
|
||||||
export const MAX_FORWARDS = 5
|
export const MAX_FORWARDS = 5
|
||||||
export const LNURLP_COMMENT_MAX_LENGTH = 1000
|
export const LNURLP_COMMENT_MAX_LENGTH = 1000
|
||||||
export const RESERVED_MAX_USER_ID = 615
|
export const RESERVED_MAX_USER_ID = 615
|
||||||
export const GLOBAL_SEED = 616
|
export const GLOBAL_SEED = USER_ID.k00b
|
||||||
export const FREEBIE_BASE_COST_THRESHOLD = 10
|
export const FREEBIE_BASE_COST_THRESHOLD = 10
|
||||||
export const USER_IDS_BALANCE_NO_LIMIT = [...SN_USER_IDS, AD_USER_ID]
|
export const USER_IDS_BALANCE_NO_LIMIT = [...SN_USER_IDS, USER_ID.anon, USER_ID.ad]
|
||||||
|
|
||||||
// WIP ultimately subject to this list: https://ofac.treasury.gov/sanctions-programs-and-country-information
|
// WIP ultimately subject to this list: https://ofac.treasury.gov/sanctions-programs-and-country-information
|
||||||
// From lawyers: north korea, cuba, iran, ukraine, syria
|
// From lawyers: north korea, cuba, iran, ukraine, syria
|
||||||
|
55
lib/url.js
55
lib/url.js
@ -1,10 +1,23 @@
|
|||||||
export function ensureProtocol (value) {
|
export function ensureProtocol (value) {
|
||||||
if (!value) return value
|
if (!value) return value
|
||||||
value = value.trim()
|
value = value.trim()
|
||||||
if (!/^([a-z0-9]+:\/\/|mailto:)/.test(value)) {
|
let url
|
||||||
value = 'http://' + value
|
|
||||||
}
|
try {
|
||||||
|
url = new URL(value)
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
url = new URL('http://' + value)
|
||||||
|
} catch {
|
||||||
return value
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove trailing slash if new URL() added it
|
||||||
|
if (url.href.endsWith('/') && !value.endsWith('/')) {
|
||||||
|
return url.href.slice(0, -1)
|
||||||
|
}
|
||||||
|
return url.href
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isExternal (url) {
|
export function isExternal (url) {
|
||||||
@ -26,14 +39,22 @@ export function removeTracking (value) {
|
|||||||
/**
|
/**
|
||||||
* parse links like https://stacker.news/items/123456 as #123456
|
* parse links like https://stacker.news/items/123456 as #123456
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export function isItemPath (pathname) {
|
||||||
|
if (!pathname) return false
|
||||||
|
|
||||||
|
const [page, id] = pathname.split('/').filter(part => !!part)
|
||||||
|
return page === 'items' && /^[0-9]+$/.test(id)
|
||||||
|
}
|
||||||
|
|
||||||
export function parseInternalLinks (href) {
|
export function parseInternalLinks (href) {
|
||||||
const url = new URL(href)
|
const url = new URL(href)
|
||||||
const internalURL = process.env.NEXT_PUBLIC_URL
|
const internalURL = process.env.NEXT_PUBLIC_URL
|
||||||
const { pathname, searchParams } = url
|
const { pathname, searchParams } = url
|
||||||
|
|
||||||
// ignore empty parts which exist due to pathname starting with '/'
|
// ignore empty parts which exist due to pathname starting with '/'
|
||||||
const emptyPart = part => !!part
|
if (isItemPath(pathname) && url.origin === internalURL) {
|
||||||
const parts = pathname.split('/').filter(emptyPart)
|
const parts = pathname.split('/').filter(part => !!part)
|
||||||
if (parts[0] === 'items' && /^[0-9]+$/.test(parts[1]) && url.origin === internalURL) {
|
|
||||||
const itemId = parts[1]
|
const itemId = parts[1]
|
||||||
// check for valid item page due to referral links like /items/123456/r/ekzyis
|
// check for valid item page due to referral links like /items/123456/r/ekzyis
|
||||||
const itemPages = ['edit', 'ots', 'related']
|
const itemPages = ['edit', 'ots', 'related']
|
||||||
@ -44,18 +65,22 @@ export function parseInternalLinks (href) {
|
|||||||
// and not #2
|
// and not #2
|
||||||
// since commentId will be ignored anyway
|
// since commentId will be ignored anyway
|
||||||
const linkText = `#${itemId}/${itemPage}`
|
const linkText = `#${itemId}/${itemPage}`
|
||||||
return linkText
|
return { itemId, linkText }
|
||||||
}
|
}
|
||||||
const commentId = searchParams.get('commentId')
|
const commentId = searchParams.get('commentId')
|
||||||
const linkText = `#${commentId || itemId}`
|
const linkText = `#${commentId || itemId}`
|
||||||
return linkText
|
return { itemId, commentId, linkText }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseEmbedUrl (href) {
|
export function parseEmbedUrl (href) {
|
||||||
|
try {
|
||||||
const { hostname, pathname, searchParams } = new URL(href)
|
const { hostname, pathname, searchParams } = new URL(href)
|
||||||
|
|
||||||
if (hostname.endsWith('youtube.com') && pathname.includes('/watch')) {
|
if (hostname.endsWith('youtube.com')) {
|
||||||
|
if (pathname.includes('/watch')) {
|
||||||
return {
|
return {
|
||||||
provider: 'youtube',
|
provider: 'youtube',
|
||||||
id: searchParams.get('v'),
|
id: searchParams.get('v'),
|
||||||
@ -66,6 +91,15 @@ export function parseEmbedUrl (href) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pathname.includes('/shorts')) {
|
||||||
|
const id = pathname.split('/').slice(-1).join()
|
||||||
|
return {
|
||||||
|
provider: 'youtube',
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (hostname.endsWith('youtu.be') && pathname.length > 1) {
|
if (hostname.endsWith('youtu.be') && pathname.length > 1) {
|
||||||
return {
|
return {
|
||||||
provider: 'youtube',
|
provider: 'youtube',
|
||||||
@ -86,6 +120,9 @@ export function parseEmbedUrl (href) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
// Important to return empty object as default
|
// Important to return empty object as default
|
||||||
return {}
|
return {}
|
||||||
|
@ -24,7 +24,7 @@ describe('internal links', () => {
|
|||||||
'parses %p as %p',
|
'parses %p as %p',
|
||||||
(href, expected) => {
|
(href, expected) => {
|
||||||
process.env.NEXT_PUBLIC_URL = 'https://stacker.news'
|
process.env.NEXT_PUBLIC_URL = 'https://stacker.news'
|
||||||
const actual = parseInternalLinks(href)
|
const { linkText: actual } = parseInternalLinks(href)
|
||||||
expect(actual).toBe(expected)
|
expect(actual).toBe(expected)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import webPush from 'web-push'
|
import webPush from 'web-push'
|
||||||
import removeMd from 'remove-markdown'
|
import removeMd from 'remove-markdown'
|
||||||
import { ANON_USER_ID, COMMENT_DEPTH_LIMIT, FOUND_BLURBS, LOST_BLURBS } from './constants'
|
import { USER_ID, COMMENT_DEPTH_LIMIT, FOUND_BLURBS, LOST_BLURBS } from './constants'
|
||||||
import { msatsToSats, numWithUnits } from './format'
|
import { msatsToSats, numWithUnits } from './format'
|
||||||
import models from '@/api/models'
|
import models from '@/api/models'
|
||||||
import { isMuted } from '@/lib/user'
|
import { isMuted } from '@/lib/user'
|
||||||
@ -38,6 +38,7 @@ const createUserFilter = (tag) => {
|
|||||||
const tagMap = {
|
const tagMap = {
|
||||||
REPLY: 'noteAllDescendants',
|
REPLY: 'noteAllDescendants',
|
||||||
MENTION: 'noteMentions',
|
MENTION: 'noteMentions',
|
||||||
|
ITEM_MENTION: 'noteItemMentions',
|
||||||
TIP: 'noteItemSats',
|
TIP: 'noteItemSats',
|
||||||
FORWARDEDTIP: 'noteForwardedSats',
|
FORWARDEDTIP: 'noteForwardedSats',
|
||||||
REFERRAL: 'noteInvites',
|
REFERRAL: 'noteInvites',
|
||||||
@ -179,7 +180,7 @@ export const notifyTerritorySubscribers = async ({ models, item }) => {
|
|||||||
|
|
||||||
export const notifyItemParents = async ({ models, item, me }) => {
|
export const notifyItemParents = async ({ models, item, me }) => {
|
||||||
try {
|
try {
|
||||||
const user = await models.user.findUnique({ where: { id: me?.id || ANON_USER_ID } })
|
const user = await models.user.findUnique({ where: { id: me?.id || USER_ID.anon } })
|
||||||
const parents = await models.$queryRawUnsafe(
|
const parents = await models.$queryRawUnsafe(
|
||||||
'SELECT DISTINCT p."userId" FROM "Item" i JOIN "Item" p ON p.path @> i.path WHERE i.id = $1 and p."userId" <> $2 ' +
|
'SELECT DISTINCT p."userId" FROM "Item" i JOIN "Item" p ON p.path @> i.path WHERE i.id = $1 and p."userId" <> $2 ' +
|
||||||
'AND NOT EXISTS (SELECT 1 FROM "Mute" m WHERE m."muterId" = p."userId" AND m."mutedId" = $2)',
|
'AND NOT EXISTS (SELECT 1 FROM "Mute" m WHERE m."muterId" = p."userId" AND m."mutedId" = $2)',
|
||||||
@ -262,6 +263,27 @@ export const notifyMention = async ({ models, userId, item }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const notifyItemMention = async ({ models, referrerItem, refereeItem }) => {
|
||||||
|
try {
|
||||||
|
const muted = await isMuted({ models, muterId: refereeItem.userId, mutedId: referrerItem.userId })
|
||||||
|
if (!muted) {
|
||||||
|
const referrer = await models.user.findUnique({ where: { id: referrerItem.userId } })
|
||||||
|
|
||||||
|
// replace full links to #<id> syntax as rendered on site
|
||||||
|
const body = referrerItem.text.replace(new RegExp(`${process.env.NEXT_PUBLIC_URL}/items/(\\d+)`, 'gi'), '#$1')
|
||||||
|
|
||||||
|
await sendUserNotification(refereeItem.userId, {
|
||||||
|
title: `@${referrer.name} mentioned one of your items`,
|
||||||
|
body,
|
||||||
|
item: referrerItem,
|
||||||
|
tag: 'ITEM_MENTION'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const notifyReferral = async (userId) => {
|
export const notifyReferral = async (userId) => {
|
||||||
try {
|
try {
|
||||||
await sendUserNotification(userId, { title: 'someone joined via one of your referral links', tag: 'REFERRAL' })
|
await sendUserNotification(userId, { title: 'someone joined via one of your referral links', tag: 'REFERRAL' })
|
||||||
|
@ -30,6 +30,7 @@ import { INVOICE_RETENTION_DAYS, ZAP_UNDO_DELAY_MS } from '@/lib/constants'
|
|||||||
import { OverlayTrigger, Tooltip } from 'react-bootstrap'
|
import { OverlayTrigger, Tooltip } from 'react-bootstrap'
|
||||||
import { useField } from 'formik'
|
import { useField } from 'formik'
|
||||||
import styles from './settings.module.css'
|
import styles from './settings.module.css'
|
||||||
|
import { AuthBanner } from '@/components/banners'
|
||||||
|
|
||||||
export const getServerSideProps = getGetServerSideProps({ query: SETTINGS, authRequired: true })
|
export const getServerSideProps = getGetServerSideProps({ query: SETTINGS, authRequired: true })
|
||||||
|
|
||||||
@ -106,7 +107,7 @@ export default function Settings ({ ssrData }) {
|
|||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className='pb-3 w-100 mt-2' style={{ maxWidth: '600px' }}>
|
<div className='pb-3 w-100 mt-2' style={{ maxWidth: '600px' }}>
|
||||||
{hasOnlyOneAuthMethod(settings?.authMethods) && <div className={styles.alert}>Please add a second auth method to avoid losing access to your account.</div>}
|
{hasOnlyOneAuthMethod(settings?.authMethods) && <AuthBanner />}
|
||||||
<SettingsHeader />
|
<SettingsHeader />
|
||||||
<Form
|
<Form
|
||||||
initial={{
|
initial={{
|
||||||
@ -120,6 +121,7 @@ export default function Settings ({ ssrData }) {
|
|||||||
noteEarning: settings?.noteEarning,
|
noteEarning: settings?.noteEarning,
|
||||||
noteAllDescendants: settings?.noteAllDescendants,
|
noteAllDescendants: settings?.noteAllDescendants,
|
||||||
noteMentions: settings?.noteMentions,
|
noteMentions: settings?.noteMentions,
|
||||||
|
noteItemMentions: settings?.noteItemMentions,
|
||||||
noteDeposits: settings?.noteDeposits,
|
noteDeposits: settings?.noteDeposits,
|
||||||
noteWithdrawals: settings?.noteWithdrawals,
|
noteWithdrawals: settings?.noteWithdrawals,
|
||||||
noteInvites: settings?.noteInvites,
|
noteInvites: settings?.noteInvites,
|
||||||
@ -280,6 +282,11 @@ export default function Settings ({ ssrData }) {
|
|||||||
name='noteMentions'
|
name='noteMentions'
|
||||||
groupClassName='mb-0'
|
groupClassName='mb-0'
|
||||||
/>
|
/>
|
||||||
|
<Checkbox
|
||||||
|
label='someone mentions one of my items'
|
||||||
|
name='noteItemMentions'
|
||||||
|
groupClassName='mb-0'
|
||||||
|
/>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label='there is a new job'
|
label='there is a new job'
|
||||||
name='noteJobIndicator'
|
name='noteJobIndicator'
|
||||||
@ -1001,10 +1008,9 @@ const ZapUndosField = () => {
|
|||||||
zap undos
|
zap undos
|
||||||
<Info>
|
<Info>
|
||||||
<ul className='fw-bold'>
|
<ul className='fw-bold'>
|
||||||
<li>An undo button is shown after every zap that exceeds or is equal to the threshold</li>
|
<li>After every zap that exceeds or is equal to the threshold, the bolt will pulse</li>
|
||||||
<li>The button is shown for {ZAP_UNDO_DELAY_MS / 1000} seconds</li>
|
<li>You can undo the zap if you click the bolt while it's pulsing</li>
|
||||||
<li>The button is only shown for zaps from the custodial wallet</li>
|
<li>The bolt will pulse for {ZAP_UNDO_DELAY_MS / 1000} seconds</li>
|
||||||
<li>Use a budget or manual approval with attached wallets</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</Info>
|
</Info>
|
||||||
</div>
|
</div>
|
||||||
|
@ -0,0 +1,92 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "PollBlindVote" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"itemId" INTEGER NOT NULL,
|
||||||
|
"userId" INTEGER NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "PollBlindVote_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "PollBlindVote.userId_index" ON "PollBlindVote"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "PollBlindVote.itemId_userId_unique" ON "PollBlindVote"("itemId", "userId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PollBlindVote" ADD CONSTRAINT "PollBlindVote_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PollBlindVote" ADD CONSTRAINT "PollBlindVote_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- migrate existing poll votes
|
||||||
|
INSERT INTO "PollBlindVote" ("itemId", "userId")
|
||||||
|
SELECT "itemId", "userId" FROM "PollVote";
|
||||||
|
|
||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `userId` on the `PollVote` table. All the data in the column will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "PollVote" DROP CONSTRAINT "PollVote_userId_fkey";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "PollVote.itemId_userId_unique";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "PollVote.userId_index";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "PollVote" DROP COLUMN "userId";
|
||||||
|
|
||||||
|
-- update `poll_vote` function to update both "PollVote" and "PollBlindVote" tables
|
||||||
|
-- create poll vote
|
||||||
|
-- if user hasn't already voted
|
||||||
|
-- charges user item.pollCost
|
||||||
|
-- adds POLL to ItemAct
|
||||||
|
-- adds PollVote
|
||||||
|
-- adds PollBlindVote
|
||||||
|
CREATE OR REPLACE FUNCTION poll_vote(option_id INTEGER, user_id INTEGER)
|
||||||
|
RETURNS "Item"
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
item "Item";
|
||||||
|
option "PollOption";
|
||||||
|
BEGIN
|
||||||
|
PERFORM ASSERT_SERIALIZED();
|
||||||
|
|
||||||
|
SELECT * INTO option FROM "PollOption" where id = option_id;
|
||||||
|
IF option IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'INVALID_POLL_OPTION';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT * INTO item FROM "Item" where id = option."itemId";
|
||||||
|
IF item IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'POLL_DOES_NOT_EXIST';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF item."userId" = user_id THEN
|
||||||
|
RAISE EXCEPTION 'POLL_OWNER_CANT_VOTE';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- no longer check `PollVote` to see if a user has voted. Instead, check `PollBlindVote`
|
||||||
|
IF EXISTS (SELECT 1 FROM "PollBlindVote" WHERE "itemId" = item.id AND "userId" = user_id) THEN
|
||||||
|
RAISE EXCEPTION 'POLL_VOTE_ALREADY_EXISTS';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
PERFORM item_act(item.id, user_id, 'POLL', item."pollCost");
|
||||||
|
|
||||||
|
INSERT INTO "PollVote" (created_at, updated_at, "itemId", "pollOptionId")
|
||||||
|
VALUES (now_utc(), now_utc(), item.id, option_id);
|
||||||
|
|
||||||
|
INSERT INTO "PollBlindVote" (created_at, updated_at, "itemId", "userId")
|
||||||
|
VALUES (now_utc(), now_utc(), item.id, user_id);
|
||||||
|
|
||||||
|
RETURN item;
|
||||||
|
END;
|
||||||
|
$$;
|
31
prisma/migrations/20240529105359_item_mentions/migration.sql
Normal file
31
prisma/migrations/20240529105359_item_mentions/migration.sql
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ItemMention" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"referrerId" INTEGER NOT NULL,
|
||||||
|
"refereeId" INTEGER NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ItemMention_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ItemMention.created_at_index" ON "ItemMention"("created_at");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ItemMention.referrerId_index" ON "ItemMention"("referrerId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ItemMention.refereeId_index" ON "ItemMention"("refereeId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ItemMention.referrerId_refereeId_unique" ON "ItemMention"("referrerId", "refereeId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ItemMention" ADD CONSTRAINT "ItemMention_referrerId_fkey" FOREIGN KEY ("referrerId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ItemMention" ADD CONSTRAINT "ItemMention_refereeId_fkey" FOREIGN KEY ("refereeId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "users" ADD COLUMN "noteItemMentions" BOOLEAN NOT NULL DEFAULT true;
|
@ -44,6 +44,7 @@ model User {
|
|||||||
noteInvites Boolean @default(true)
|
noteInvites Boolean @default(true)
|
||||||
noteItemSats Boolean @default(true)
|
noteItemSats Boolean @default(true)
|
||||||
noteMentions Boolean @default(true)
|
noteMentions Boolean @default(true)
|
||||||
|
noteItemMentions Boolean @default(true)
|
||||||
noteForwardedSats Boolean @default(true)
|
noteForwardedSats Boolean @default(true)
|
||||||
lastCheckedJobs DateTime?
|
lastCheckedJobs DateTime?
|
||||||
noteJobIndicator Boolean @default(true)
|
noteJobIndicator Boolean @default(true)
|
||||||
@ -79,7 +80,6 @@ model User {
|
|||||||
actions ItemAct[]
|
actions ItemAct[]
|
||||||
mentions Mention[]
|
mentions Mention[]
|
||||||
messages Message[]
|
messages Message[]
|
||||||
PollVote PollVote[]
|
|
||||||
PushSubscriptions PushSubscription[]
|
PushSubscriptions PushSubscription[]
|
||||||
ReferralAct ReferralAct[]
|
ReferralAct ReferralAct[]
|
||||||
Streak Streak[]
|
Streak Streak[]
|
||||||
@ -125,6 +125,7 @@ model User {
|
|||||||
Replies Reply[]
|
Replies Reply[]
|
||||||
walletLogs WalletLog[]
|
walletLogs WalletLog[]
|
||||||
Reminder Reminder[]
|
Reminder Reminder[]
|
||||||
|
PollBlindVote PollBlindVote[]
|
||||||
|
|
||||||
@@index([photoId])
|
@@index([photoId])
|
||||||
@@index([createdAt], map: "users.created_at_index")
|
@@index([createdAt], map: "users.created_at_index")
|
||||||
@ -411,6 +412,8 @@ model Item {
|
|||||||
user User @relation("UserItems", fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation("UserItems", fields: [userId], references: [id], onDelete: Cascade)
|
||||||
actions ItemAct[]
|
actions ItemAct[]
|
||||||
mentions Mention[]
|
mentions Mention[]
|
||||||
|
referrer ItemMention[] @relation("referrer")
|
||||||
|
referee ItemMention[] @relation("referee")
|
||||||
PollOption PollOption[]
|
PollOption PollOption[]
|
||||||
PollVote PollVote[]
|
PollVote PollVote[]
|
||||||
ThreadSubscription ThreadSubscription[]
|
ThreadSubscription ThreadSubscription[]
|
||||||
@ -424,6 +427,7 @@ model Item {
|
|||||||
Ancestors Reply[] @relation("AncestorReplyItem")
|
Ancestors Reply[] @relation("AncestorReplyItem")
|
||||||
Replies Reply[]
|
Replies Reply[]
|
||||||
Reminder Reminder[]
|
Reminder Reminder[]
|
||||||
|
PollBlindVote PollBlindVote[]
|
||||||
|
|
||||||
@@index([uploadId])
|
@@index([uploadId])
|
||||||
@@index([lastZapAt])
|
@@index([lastZapAt])
|
||||||
@ -501,16 +505,25 @@ model PollVote {
|
|||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||||
userId Int
|
|
||||||
itemId Int
|
itemId Int
|
||||||
pollOptionId Int
|
pollOptionId Int
|
||||||
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||||||
pollOption PollOption @relation(fields: [pollOptionId], references: [id], onDelete: Cascade)
|
pollOption PollOption @relation(fields: [pollOptionId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([pollOptionId], map: "PollVote.pollOptionId_index")
|
||||||
|
}
|
||||||
|
|
||||||
|
model PollBlindVote {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||||
|
itemId Int
|
||||||
|
userId Int
|
||||||
|
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([itemId, userId], map: "PollVote.itemId_userId_unique")
|
@@unique([itemId, userId], map: "PollBlindVote.itemId_userId_unique")
|
||||||
@@index([pollOptionId], map: "PollVote.pollOptionId_index")
|
@@index([userId], map: "PollBlindVote.userId_index")
|
||||||
@@index([userId], map: "PollVote.userId_index")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum BillingType {
|
enum BillingType {
|
||||||
@ -661,6 +674,21 @@ model Mention {
|
|||||||
@@index([userId], map: "Mention.userId_index")
|
@@index([userId], map: "Mention.userId_index")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model ItemMention {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||||
|
referrerId Int
|
||||||
|
refereeId Int
|
||||||
|
referrerItem Item @relation("referrer", fields: [referrerId], references: [id], onDelete: Cascade)
|
||||||
|
refereeItem Item @relation("referee", fields: [refereeId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([referrerId, refereeId], map: "ItemMention.referrerId_refereeId_unique")
|
||||||
|
@@index([createdAt], map: "ItemMention.created_at_index")
|
||||||
|
@@index([referrerId], map: "ItemMention.referrerId_index")
|
||||||
|
@@index([refereeId], map: "ItemMention.refereeId_index")
|
||||||
|
}
|
||||||
|
|
||||||
model Invoice {
|
model Invoice {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
43
sndev
43
sndev
@ -159,6 +159,11 @@ OPTIONS"
|
|||||||
|
|
||||||
sndev__logs() {
|
sndev__logs() {
|
||||||
shift
|
shift
|
||||||
|
if [ $# -eq 1 ]; then
|
||||||
|
docker__compose logs -t --tail=1000 -f "$@"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
docker__compose logs "$@"
|
docker__compose logs "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -219,6 +224,43 @@ USAGE
|
|||||||
echo "$help"
|
echo "$help"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sndev__fund_user() {
|
||||||
|
shift
|
||||||
|
if [ -z "$1" ]; then
|
||||||
|
echo "<nym> argument required"
|
||||||
|
sndev__help_fund_user
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ -z "$2" ]; then
|
||||||
|
echo "<msats> argument required"
|
||||||
|
sndev__help_fund_user
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
re='^[0-9]+$'
|
||||||
|
if ! [[ $2 =~ $re ]]; then
|
||||||
|
echo "<msats> is not a positive integer"
|
||||||
|
sndev__help_fund_user
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
docker__exec db psql -U sn -d stackernews -q <<EOF
|
||||||
|
UPDATE users set msats = $2 where name = '$1';
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
sndev__help_fund_user() {
|
||||||
|
help="
|
||||||
|
fund a nym without using an LN invoice (local only)
|
||||||
|
|
||||||
|
USAGE
|
||||||
|
$ sndev fund_user <nym> <msats>
|
||||||
|
|
||||||
|
<nym> - the name of the user you want to fund
|
||||||
|
<msats> - the amount of millisatoshis to set the account to. Must be >= 0
|
||||||
|
"
|
||||||
|
|
||||||
|
echo "$help"
|
||||||
|
}
|
||||||
|
|
||||||
sndev__fund() {
|
sndev__fund() {
|
||||||
shift
|
shift
|
||||||
docker__stacker_lnd -t payinvoice "$@"
|
docker__stacker_lnd -t payinvoice "$@"
|
||||||
@ -516,6 +558,7 @@ COMMANDS
|
|||||||
|
|
||||||
sn:
|
sn:
|
||||||
login login as a nym
|
login login as a nym
|
||||||
|
fund_user fund a nym without using an LN invoice
|
||||||
|
|
||||||
lnd:
|
lnd:
|
||||||
fund pay a bolt11 for funding
|
fund pay a bolt11 for funding
|
||||||
|
@ -114,7 +114,7 @@ const mergeAndShowNotification = async (sw, payload, currentNotifications, tag,
|
|||||||
// merge notifications into single notification payload
|
// merge notifications into single notification payload
|
||||||
// ---
|
// ---
|
||||||
// tags that need to know the amount of notifications with same tag for merging
|
// tags that need to know the amount of notifications with same tag for merging
|
||||||
const AMOUNT_TAGS = ['REPLY', 'MENTION', 'REFERRAL', 'INVITE', 'FOLLOW', 'TERRITORY_POST']
|
const AMOUNT_TAGS = ['REPLY', 'MENTION', 'ITEM_MENTION', 'REFERRAL', 'INVITE', 'FOLLOW', 'TERRITORY_POST']
|
||||||
// tags that need to know the sum of sats of notifications with same tag for merging
|
// tags that need to know the sum of sats of notifications with same tag for merging
|
||||||
const SUM_SATS_TAGS = ['DEPOSIT', 'WITHDRAWAL']
|
const SUM_SATS_TAGS = ['DEPOSIT', 'WITHDRAWAL']
|
||||||
// this should reflect the amount of notifications that were already merged before
|
// this should reflect the amount of notifications that were already merged before
|
||||||
@ -143,6 +143,8 @@ const mergeAndShowNotification = async (sw, payload, currentNotifications, tag,
|
|||||||
title = `you have ${amount} new replies`
|
title = `you have ${amount} new replies`
|
||||||
} else if (compareTag === 'MENTION') {
|
} else if (compareTag === 'MENTION') {
|
||||||
title = `you were mentioned ${amount} times`
|
title = `you were mentioned ${amount} times`
|
||||||
|
} else if (compareTag === 'ITEM_MENTION') {
|
||||||
|
title = `your items were mentioned ${amount} times`
|
||||||
} else if (compareTag === 'REFERRAL') {
|
} else if (compareTag === 'REFERRAL') {
|
||||||
title = `${amount} stackers joined via your referral links`
|
title = `${amount} stackers joined via your referral links`
|
||||||
} else if (compareTag === 'INVITE') {
|
} else if (compareTag === 'INVITE') {
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { deleteObjects } from '@/api/s3'
|
import { deleteObjects } from '@/api/s3'
|
||||||
import { ANON_USER_ID } from '@/lib/constants'
|
import { USER_ID } from '@/lib/constants'
|
||||||
|
|
||||||
export async function deleteUnusedImages ({ models }) {
|
export async function deleteUnusedImages ({ models }) {
|
||||||
// delete all images in database and S3 which weren't paid in the last 24 hours
|
// delete all images in database and S3 which weren't paid in the last 24 hours
|
||||||
@ -14,7 +14,7 @@ export async function deleteUnusedImages ({ models }) {
|
|||||||
AND NOT EXISTS (SELECT * FROM users WHERE "photoId" = "Upload".id)
|
AND NOT EXISTS (SELECT * FROM users WHERE "photoId" = "Upload".id)
|
||||||
AND NOT EXISTS (SELECT * FROM "Item" WHERE "uploadId" = "Upload".id)
|
AND NOT EXISTS (SELECT * FROM "Item" WHERE "uploadId" = "Upload".id)
|
||||||
))
|
))
|
||||||
AND created_at < date_trunc('hour', now() - CASE WHEN "userId" = ${ANON_USER_ID} THEN interval '1 hour' ELSE interval '24 hours' END)`
|
AND created_at < date_trunc('hour', now() - CASE WHEN "userId" = ${USER_ID.anon} THEN interval '1 hour' ELSE interval '24 hours' END)`
|
||||||
|
|
||||||
const s3Keys = unpaidImages.map(({ id }) => id)
|
const s3Keys = unpaidImages.map(({ id }) => id)
|
||||||
if (s3Keys.length === 0) {
|
if (s3Keys.length === 0) {
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import * as math from 'mathjs'
|
import * as math from 'mathjs'
|
||||||
import { ANON_USER_ID, SN_USER_IDS } from '@/lib/constants.js'
|
import { USER_ID, SN_USER_IDS } from '@/lib/constants.js'
|
||||||
|
|
||||||
export async function trust ({ boss, models }) {
|
export async function trust ({ boss, models }) {
|
||||||
try {
|
try {
|
||||||
@ -127,7 +127,7 @@ async function getGraph (models) {
|
|||||||
FROM "ItemAct"
|
FROM "ItemAct"
|
||||||
JOIN "Item" ON "Item".id = "ItemAct"."itemId" AND "ItemAct".act IN ('FEE', 'TIP', 'DONT_LIKE_THIS')
|
JOIN "Item" ON "Item".id = "ItemAct"."itemId" AND "ItemAct".act IN ('FEE', 'TIP', 'DONT_LIKE_THIS')
|
||||||
AND "Item"."parentId" IS NULL AND NOT "Item".bio AND "Item"."userId" <> "ItemAct"."userId"
|
AND "Item"."parentId" IS NULL AND NOT "Item".bio AND "Item"."userId" <> "ItemAct"."userId"
|
||||||
JOIN users ON "ItemAct"."userId" = users.id AND users.id <> ${ANON_USER_ID}
|
JOIN users ON "ItemAct"."userId" = users.id AND users.id <> ${USER_ID.anon}
|
||||||
GROUP BY user_id, name, item_id, user_at, against
|
GROUP BY user_id, name, item_id, user_at, against
|
||||||
HAVING CASE WHEN
|
HAVING CASE WHEN
|
||||||
"ItemAct".act = 'DONT_LIKE_THIS' THEN sum("ItemAct".msats) > ${AGAINST_MSAT_MIN}
|
"ItemAct".act = 'DONT_LIKE_THIS' THEN sum("ItemAct".msats) > ${AGAINST_MSAT_MIN}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user