Compare commits

..

2 Commits

Author SHA1 Message Date
ekzyis
34bfa89e74 wip 2024-06-17 02:59:51 -05:00
ekzyis
bb8c1ccffc refactor webln 2024-06-03 17:41:15 -05:00
47 changed files with 415 additions and 954 deletions

View File

@ -1,8 +1,8 @@
export default { export default {
Query: { Query: {
snl: async (parent, _, { models }) => { snl: async (parent, _, { models }) => {
const snl = await models.snl.findFirst() const { live } = await models.snl.findFirst()
return !!snl?.live return live
} }
}, },
Mutation: { Mutation: {

View File

@ -1,4 +1,4 @@
import { USER_ID, AWS_S3_URL_REGEXP } from '@/lib/constants' import { ANON_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 : USER_ID.anon, s3Keys) const [info] = await models.$queryRawUnsafe('SELECT * FROM image_fees_info($1::INTEGER, $2::INTEGER[])', me ? me.id : ANON_USER_ID, 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)

View File

@ -1,5 +1,5 @@
import { GraphQLError } from 'graphql' import { GraphQLError } from 'graphql'
import { ensureProtocol, parseInternalLinks, removeTracking, stripTrailingSlash } from '@/lib/url' import { ensureProtocol, 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,
USER_ID, ANON_ITEM_SPAM_INTERVAL, POLL_COST, ANON_USER_ID, ANON_ITEM_SPAM_INTERVAL, POLL_COST,
ITEM_ALLOW_EDITS, GLOBAL_SEED, ANON_FEE_MULTIPLIER, NOFOLLOW_LIMIT, UNKNOWN_LINK_REL, SN_USER_IDS ITEM_ALLOW_EDITS, GLOBAL_SEED, ANON_FEE_MULTIPLIER, NOFOLLOW_LIMIT, UNKNOWN_LINK_REL
} 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, notifyItemMention } from '@/lib/webPush' import { notifyItemParents, notifyUserSubscribers, notifyZapped, notifyTerritorySubscribers, notifyMention } 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, verifyPayment: !!hash || !me } { models, lnd, me, hash, hmac }
) )
return id return id
@ -859,7 +859,7 @@ export default {
} }
} }
if (me && idempotent) { if (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, verifyPayment: !!hash } { models, lnd, hash, hmac }
) )
} else { } else {
await serialize( await serialize(
models.$queryRaw` models.$queryRaw`
SELECT SELECT
item_act(${Number(id)}::INTEGER, item_act(${Number(id)}::INTEGER,
${me?.id || USER_ID.anon}::INTEGER, ${act}::"ItemActType", ${Number(sats)}::INTEGER)`, ${me?.id || ANON_USER_ID}::INTEGER, ${act}::"ItemActType", ${Number(sats)}::INTEGER)`,
{ models, lnd, me, hash, hmac, fee: sats, verifyPayment: !!hash || !me } { models, lnd, me, hash, hmac, fee: sats }
) )
} }
@ -1004,7 +1004,8 @@ export default {
} }
const options = await models.$queryRaw` const options = await models.$queryRaw`
SELECT "PollOption".id, option, count("PollVote".id)::INTEGER as count SELECT "PollOption".id, option, count("PollVote"."userId")::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}
@ -1012,16 +1013,9 @@ 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 = !!meVoted poll.meVoted = options.some(o => o.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
@ -1163,7 +1157,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 ?? USER_ID.anon const meId = me?.id ?? ANON_USER_ID
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
@ -1172,8 +1166,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 ?? USER_ID.anon const meId = me?.id ?? ANON_USER_ID
if (meId !== item.userId || meId === USER_ID.anon) { if (meId !== item.userId || meId === ANON_USER_ID) {
// 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
@ -1185,7 +1179,6 @@ 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
@ -1195,112 +1188,49 @@ export const createMentions = async (item, models) => {
return return
} }
// user mentions
try { try {
await createUserMentions(item, models) const mentions = item.text.match(namePattern)?.map(m => m.slice(1))
if (mentions?.length > 0) {
const users = await models.user.findMany({
where: {
name: { in: mentions },
// Don't create mentions when mentioning yourself
id: { not: item.userId }
}
})
users.forEach(async user => {
const data = {
itemId: item.id,
userId: user.id
}
const mention = await models.mention.upsert({
where: {
itemId_userId: data
},
update: data,
create: data
})
// only send if mention is new to avoid duplicates
if (mention.createdAt.getTime() === mention.updatedAt.getTime()) {
notifyMention({ models, userId: user.id, item })
}
})
}
} catch (e) { } catch (e) {
console.error('user mention failure', e) console.error('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))
if (!mentions || mentions.length === 0) return
const users = await models.user.findMany({
where: {
name: { in: mentions },
// Don't create mentions when mentioning yourself
id: { not: item.userId }
}
})
users.forEach(async user => {
const data = {
itemId: item.id,
userId: user.id
}
const mention = await models.mention.upsert({
where: {
itemId_userId: data
},
update: data,
create: data
})
// only send if mention is new to avoid duplicates
if (mention.createdAt.getTime() === mention.updatedAt.getTime()) {
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
}
}).filter(r => !!r)
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) {
@ -1314,13 +1244,10 @@ 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 })
const user = await models.user.findUnique({ where: { id: me.id } })
// prevent update if it's not explicitly allowed, not their bio, not their job and older than 10 minutes // 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 user = await models.user.findUnique({ where: { id: me.id } })
const timer = Date.now() < new Date(old.createdAt).getTime() + 10 * 60_000 if (!ITEM_ALLOW_EDITS.includes(old.id) && user.bioId !== old.id &&
!isJob(item) && Date.now() > new Date(old.createdAt).getTime() + 10 * 60000) {
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' } })
} }
@ -1339,7 +1266,7 @@ export const updateItem = async (parent, { sub: subName, forward, options, ...it
} }
} }
item = { subName, userId: old.userId, ...item } item = { subName, userId: me.id, ...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 })
@ -1348,7 +1275,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, verifyPayment: !!hash || !me } { models, lnd, me, hash, hmac, fee: imgFees }
)) ))
await createMentions(item, models) await createMentions(item, models)
@ -1376,7 +1303,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) : USER_ID.anon item.userId = me ? Number(me.id) : ANON_USER_ID
const fwdUsers = await getForwardUsers(models, forward) const fwdUsers = await getForwardUsers(models, forward)
if (item.url && !isJob(item)) { if (item.url && !isJob(item)) {
@ -1405,7 +1332,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, verifyPayment: !!hash || !me } { models, lnd, me, hash, hmac, fee }
)) ))
await createMentions(item, models) await createMentions(item, models)
@ -1440,7 +1367,7 @@ const enqueueDeletionJob = async (item, models) => {
} }
const deleteReminderAndJob = async ({ me, item, models }) => { const deleteReminderAndJob = async ({ me, item, models }) => {
if (me?.id && me.id !== USER_ID.anon) { if (me?.id && me.id !== ANON_USER_ID) {
await models.$transaction([ await models.$transaction([
models.$queryRawUnsafe(` models.$queryRawUnsafe(`
DELETE FROM pgboss.job DELETE FROM pgboss.job
@ -1462,7 +1389,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 === USER_ID.anon) { if (!me || me.id === ANON_USER_ID) {
return return
} }
const reminderCommand = getReminderCommand(item.text) const reminderCommand = getReminderCommand(item.text)

View File

@ -140,22 +140,6 @@ 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
@ -173,7 +157,6 @@ 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
)` )`
) )
@ -473,9 +456,6 @@ 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 })
}, },

View File

@ -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 { USER_ID } from '@/lib/constants' import { ANON_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 || USER_ID.anon}::INTEGER)`, models.$queryRaw`SELECT donate(${sats}::INTEGER, ${me?.id || ANON_USER_ID}::INTEGER)`,
{ models, lnd, me, hash, hmac, fee: sats, verifyPayment: !!hash || !me } { models, lnd, me, hash, hmac, fee: sats }
) )
return sats return sats

View File

@ -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, verifyPayment: verify }) { export default async function serialize (trx, { models, lnd, me, hash, hmac, fee }) {
// 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 (verify) { if (hash) {
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})`,

View File

@ -248,7 +248,7 @@ export default {
const results = await serialize( const results = await serialize(
queries, queries,
{ models, lnd, me, hash, hmac, fee: sub.billingCost, verifyPayment: !!hash || !me }) { models, lnd, me, hash, hmac, fee: sub.billingCost })
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, verifyPayment: !!hash || !me }) { models, lnd, hash, me, hmac, fee: billingCost })
if (isTransfer) notifyTerritoryTransfer({ models, sub: newSub, to: me }) if (isTransfer) notifyTerritoryTransfer({ models, sub: newSub, to: me })
} }
@ -382,10 +382,7 @@ 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 }) => {
if (sub.meMuteSub !== undefined) { return sub.meMuteSub || sub.MuteSub?.length > 0
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') {
@ -398,11 +395,7 @@ export default {
} }
}, },
meSubscription: async (sub, args, { me, models }) => { meSubscription: async (sub, args, { me, models }) => {
if (sub.meSubscription !== undefined) { return sub.meSubscription || sub.SubSubscription?.length > 0
return sub.meSubscription
}
return sub.SubSubscription?.length > 0
}, },
createdAt: sub => sub.createdAt || sub.created_at createdAt: sub => sub.createdAt || sub.created_at
} }
@ -464,7 +457,7 @@ async function createSub (parent, data, { me, models, lnd, hash, hmac }) {
subName: data.name subName: data.name
} }
}) })
], { models, lnd, me, hash, hmac, fee: billingCost, verifyPayment: !!hash || !me }) ], { models, lnd, me, hash, hmac, fee: billingCost })
return results[1] return results[1]
} catch (error) { } catch (error) {
@ -545,7 +538,7 @@ async function updateSub (parent, { oldName, ...data }, { me, models, lnd, hash,
userId: me.id userId: me.id
} }
}) })
], { models, lnd, me, hash, hmac, fee: proratedCost, verifyPayment: !!hash || !me }) ], { models, lnd, me, hash, hmac, fee: proratedCost })
return results[2] return results[2]
} }
} }

View File

@ -1,5 +1,5 @@
import { GraphQLError } from 'graphql' import { GraphQLError } from 'graphql'
import { USER_ID, IMAGE_PIXELS_MAX, UPLOAD_SIZE_MAX, UPLOAD_SIZE_MAX_AVATAR, UPLOAD_TYPES_ALLOW } from '@/lib/constants' import { ANON_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 || USER_ID.anon, userId: me?.id || ANON_USER_ID,
paid: false paid: false
} }

View File

@ -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 { USER_ID, RESERVED_MAX_USER_ID, SN_NO_REWARDS_IDS } from '@/lib/constants' import { ANON_USER_ID, DELETE_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 (${USER_ID.anon}, ${USER_ID.delete}) id > ${RESERVED_MAX_USER_ID} OR id IN (${ANON_USER_ID}, ${DELETE_USER_ID})
) )
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,26 +347,6 @@ 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(
@ -538,7 +518,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 (${USER_ID.anon}, ${USER_ID.delete})) WHERE (id > ${RESERVED_MAX_USER_ID} OR id IN (${ANON_USER_ID}, ${DELETE_USER_ID}))
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 }) => {
@ -720,7 +700,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, emailHash: null } }) user = await models.user.update({ where: { id: me.id }, data: { email: null, emailVerified: null } })
} else { } else {
throw new GraphQLError('no such account', { extensions: { code: 'BAD_INPUT' } }) throw new GraphQLError('no such account', { extensions: { code: 'BAD_INPUT' } })
} }

View File

@ -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, 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, ANON_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 === USER_ID.anon) { if (inv.user.id === ANON_USER_ID) {
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 = USER_ID.anon id = ANON_USER_ID
} }
const user = await models.user.findUnique({ where: { id } }) const user = await models.user.findUnique({ where: { id } })

View File

@ -46,6 +46,7 @@ export default gql`
id: ID, id: ID,
option: String! option: String!
count: Int! count: Int!
meVoted: Boolean!
} }
type Poll { type Poll {

View File

@ -43,12 +43,6 @@ 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!
@ -136,7 +130,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 | ItemMention | TerritoryPost | TerritoryTransfer | Reminder
type Notifications { type Notifications {
lastChecked: Date lastChecked: Date

View File

@ -95,7 +95,6 @@ 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!
@ -162,7 +161,6 @@ 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!

View File

@ -105,8 +105,3 @@ 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

1 name type pr id issue ids difficulty priority changes requested notes amount receive method date paid
105 kravhen pr #1198 #1180 good-first-issue required linting 18k nichro@getalby.com 2024-05-28
106 OneOneSeven117 issue #1198 #1180 good-first-issue required linting 2k OneOneSeven@stacker.news 2024-05-28
107 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

View File

@ -138,11 +138,3 @@ 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>
)
}

View File

@ -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 { USER_ID, JIT_INVOICE_TIMEOUT_MS } from '@/lib/constants' import { ANON_USER_ID, JIT_INVOICE_TIMEOUT_MS } from '@/lib/constants'
import { HAS_NOTIFICATIONS } from '@/fragments/notifications' import { HAS_NOTIFICATIONS } from '@/fragments/notifications'
import Item, { ItemSkeleton } from './item' import Item 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 || USER_ID.anon}` const storageKey = `client-notifications:${me?.id || ANON_USER_ID}`
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
? <ItemSkeleton /> ? null
: n.item.title : n.item.title
? <Item item={n.item} /> ? <Item item={n.item} />
: ( : (

View File

@ -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 { USER_ID, COMMENT_DEPTH_LIMIT, UNKNOWN_LINK_REL } from '@/lib/constants' import { ANON_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,7 +25,6 @@ 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()
@ -129,137 +128,125 @@ 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) !== USER_ID.anon const op = root.user.name === item.user.name && Number(item.user.id) !== ANON_USER_ID
? 'OP' ? 'OP'
: root.forwards?.some(f => f.user.name === item.user.name) && Number(item.user.id) !== USER_ID.anon : root.forwards?.some(f => f.user.name === item.user.name) && Number(item.user.id) !== ANON_USER_ID
? '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} />
<div className={`${itemStyles.hunk} ${styles.hunk}`}> : item.meDontLikeSats > item.meSats
<div className='d-flex align-items-center'> ? <DownZap width={24} height={24} className={styles.dontLike} item={item} />
{item.user?.meMute && !includeParent && collapse === 'yep' : pin ? <Pin width={22} height={22} className={styles.pin} /> : <UpVote item={item} className={styles.upvote} />}
? ( <div className={`${itemStyles.hunk} ${styles.hunk}`}>
<span <div className='d-flex align-items-center'>
className={`${itemStyles.other} ${styles.other} pointer`} onClick={() => { {item.user?.meMute && !includeParent && collapse === 'yep'
setCollapse('nope')
window.localStorage.setItem(`commentCollapse:${item.id}`, 'nope')
}}
>reply from someone you muted
</span>)
: <ItemInfo
item={item}
commentsText='replies'
commentTextSingular='reply'
className={`${itemStyles.other} ${styles.other}`}
embellishUser={op && <><span> </span><Badge bg={op === 'fwd' ? 'secondary' : 'boost'} className={`${styles.op} bg-opacity-75`}>{op}</Badge></>}
onQuoteReply={quoteReply}
nested={!includeParent}
extraInfo={
<>
{includeParent && <Parent item={item} rootText={rootText} />}
{bountyPaid &&
<ActionTooltip notForm overlayText={`${numWithUnits(root.bounty)} paid`}>
<BountyIcon className={`${styles.bountyIcon} ${'fill-success vertical-align-middle'}`} height={16} width={16} />
</ActionTooltip>}
</>
}
onEdit={e => { setEdit(!edit) }}
editText={edit ? 'cancel' : 'edit'}
/>}
{!includeParent && (collapse === 'yep'
? <Eye
className={styles.collapser} height={10} width={10} onClick={() => {
setCollapse('nope')
window.localStorage.setItem(`commentCollapse:${item.id}`, 'nope')
}}
/>
: <EyeClose
className={styles.collapser} height={10} width={10} onClick={() => {
setCollapse('yep')
window.localStorage.setItem(`commentCollapse:${item.id}`, 'yep')
}}
/>)}
{topLevel && (
<span className='d-flex ms-auto align-items-center'>
<Share title={item?.title} path={`/items/${item?.id}`} />
</span>
)}
</div>
{edit
? ( ? (
<CommentEdit <span
comment={item} className={`${itemStyles.other} ${styles.other} pointer`} onClick={() => {
onSuccess={() => { setCollapse('nope')
setEdit(!edit) window.localStorage.setItem(`commentCollapse:${item.id}`, 'nope')
}}
>reply from someone you muted
</span>)
: <ItemInfo
item={item}
commentsText='replies'
commentTextSingular='reply'
className={`${itemStyles.other} ${styles.other}`}
embellishUser={op && <><span> </span><Badge bg={op === 'fwd' ? 'secondary' : 'boost'} className={`${styles.op} bg-opacity-75`}>{op}</Badge></>}
onQuoteReply={quoteReply}
nested={!includeParent}
extraInfo={
<>
{includeParent && <Parent item={item} rootText={rootText} />}
{bountyPaid &&
<ActionTooltip notForm overlayText={`${numWithUnits(root.bounty)} paid`}>
<BountyIcon className={`${styles.bountyIcon} ${'fill-success vertical-align-middle'}`} height={16} width={16} />
</ActionTooltip>}
</>
}
onEdit={e => { setEdit(!edit) }}
editText={edit ? 'cancel' : 'edit'}
/>}
{!includeParent && (collapse === 'yep'
? <Eye
className={styles.collapser} height={10} width={10} onClick={() => {
setCollapse('nope')
window.localStorage.setItem(`commentCollapse:${item.id}`, 'nope')
}} }}
/> />
) : <EyeClose
: ( className={styles.collapser} height={10} width={10} onClick={() => {
<div className={styles.text} ref={textRef}> setCollapse('yep')
{item.searchText window.localStorage.setItem(`commentCollapse:${item.id}`, 'yep')
? <SearchText text={item.searchText} /> }}
: ( />)}
<Text itemId={item.id} topLevel={topLevel} rel={item.rel ?? UNKNOWN_LINK_REL} outlawed={item.outlawed} imgproxyUrls={item.imgproxyUrls}> {topLevel && (
{item.outlawed && !me?.privates?.wildWestMode <span className='d-flex ms-auto align-items-center'>
? '*stackers have outlawed this. turn on wild west mode in your [settings](/settings) to see outlawed content.*' <Share title={item?.title} path={`/items/${item?.id}`} />
: truncate ? truncateString(item.text) : item.text} </span>
</Text>)} )}
</div>
)}
</div> </div>
</div> {edit
{collapse !== 'yep' && ( ? (
bottomedOut <CommentEdit
? <div className={styles.children}><ReplyOnAnotherPage item={item} /></div> comment={item}
: ( onSuccess={() => {
<div className={styles.children}> setEdit(!edit)
{item.outlawed && !me?.privates?.wildWestMode }}
? <div className='py-2' /> />
: !noReply &&
<Reply depth={depth + 1} item={item} replyOpen={replyOpen} onCancelQuote={cancelQuote} onQuoteReply={quoteReply} quote={quote}>
{root.bounty && !bountyPaid && <PayBounty item={item} />}
</Reply>}
{children}
<div className={styles.comments}>
{item.comments && !noComments
? item.comments.map((item) => (
<Comment depth={depth + 1} key={item.id} item={item} />
))
: null}
</div>
</div>
) )
)} : (
<div className={styles.text} ref={textRef}>
{item.searchText
? <SearchText text={item.searchText} />
: (
<Text itemId={item.id} topLevel={topLevel} rel={item.rel ?? UNKNOWN_LINK_REL} outlawed={item.outlawed} imgproxyUrls={item.imgproxyUrls}>
{item.outlawed && !me?.privates?.wildWestMode
? '*stackers have outlawed this. turn on wild west mode in your [settings](/settings) to see outlawed content.*'
: truncate ? truncateString(item.text) : item.text}
</Text>)}
</div>
)}
</div>
</div> </div>
</ItemContextProvider> {collapse !== 'yep' && (
bottomedOut
? <div className={styles.children}><ReplyOnAnotherPage item={item} /></div>
: (
<div className={styles.children}>
{item.outlawed && !me?.privates?.wildWestMode
? <div className='py-2' />
: !noReply &&
<Reply depth={depth + 1} item={item} replyOpen={replyOpen} onCancelQuote={cancelQuote} onQuoteReply={quoteReply} quote={quote}>
{root.bounty && !bountyPaid && <PayBounty item={item} />}
</Reply>}
{children}
<div className={styles.comments}>
{item.comments && !noComments
? item.comments.map((item) => (
<Comment depth={depth + 1} key={item.id} item={item} />
))
: null}
</div>
</div>
)
)}
</div>
) )
} }
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}>

View File

@ -6,12 +6,10 @@ 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 () => {
@ -26,7 +24,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 + pendingCommentSats)} {numWithUnits(commentSats)}
</Nav.Item> </Nav.Item>
<div className='ms-auto d-flex'> <div className='ms-auto d-flex'>
<Nav.Item> <Nav.Item>
@ -68,7 +66,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}
@ -93,7 +91,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> </>
) )
} }

View File

@ -4,24 +4,18 @@ 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 { useCallback, useMemo } from 'react' import { 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(downSats), fill: getColor(meDontLikeSats),
filter: `drop-shadow(0 0 6px ${getColor(downSats)}90)` filter: `drop-shadow(0 0 6px ${getColor(meDontLikeSats)}90)`
} }
: undefined), [downSats]) : undefined), [meDontLikeSats])
return ( return (
<DownZapper item={item} As={({ ...oprops }) => <Flag {...props} {...oprops} style={style} />} /> <DownZapper item={item} As={({ ...oprops }) => <Flag {...props} {...oprops} style={style} />} />
) )
@ -30,17 +24,6 @@ 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
@ -48,7 +31,7 @@ function DownZapper ({ item, As, children }) {
try { try {
showModal(onClose => showModal(onClose =>
<ItemAct <ItemAct
onClose={onClose} item={item} down optimisticUpdate={optimisticUpdate} onClose={onClose} item={item} down
> >
<AccordianItem <AccordianItem
header='what is a downzap?' body={ header='what is a downzap?' body={

View File

@ -33,6 +33,7 @@ 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'
@ -120,7 +121,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) {
@ -134,6 +135,7 @@ 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({
@ -803,7 +805,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, clientNotification, signal, ...props optimisticUpdate: optimisticUpdateArgs, clientNotification, signal, ...props
}) { }) {
const toaster = useToast() const toaster = useToast()
const initialErrorToasted = useRef(false) const initialErrorToasted = useRef(false)
@ -843,7 +845,9 @@ export function Form ({
throw new SessionRequiredError() throw new SessionRequiredError()
} }
revert = optimisticUpdate?.(variables) if (optimisticUpdateArgs) {
revert = optimisticUpdate(optimisticUpdateArgs(variables))
}
await signal?.pause({ me, amount }) await signal?.pause({ me, amount })
@ -862,6 +866,8 @@ 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
} }
@ -875,7 +881,6 @@ 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.

View File

@ -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 { USER_ID } from '@/lib/constants' import { AD_USER_ID, ANON_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) === USER_ID.ad) return null if (!user || Number(user.id) === AD_USER_ID) return null
if (Number(user.id) === USER_ID.anon) { if (Number(user.id) === ANON_USER_ID) {
return ( return (
<HatTooltip overlayText='anonymous'> <HatTooltip overlayText='anonymous'>
{badge {badge

View File

@ -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,10 +100,11 @@ export const actUpdate = ({ me, onUpdate }) => (cache, args) => {
onUpdate?.(cache, args) onUpdate?.(cache, args)
} }
export default function ItemAct ({ onClose, item, down, children, abortSignal, optimisticUpdate }) { export default function ItemAct ({ onClose, item, down, children, abortSignal }) {
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()
@ -119,12 +120,23 @@ export default function ItemAct ({ onClose, item, down, children, abortSignal, o
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]) }, [me, act, down, item.id, strike])
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 }}>
@ -135,7 +147,7 @@ export default function ItemAct ({ onClose, item, down, children, abortSignal, o
}} }}
schema={amountSchema} schema={amountSchema}
prepaid prepaid
optimisticUpdate={({ amount }) => optimisticUpdate(amount, { onClose })} optimisticUpdate={optimisticUpdate}
onSubmit={onSubmit} onSubmit={onSubmit}
clientNotification={ClientNotification.Zap} clientNotification={ClientNotification.Zap}
signal={abortSignal} signal={abortSignal}
@ -240,10 +252,9 @@ 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, abortSignal, optimisticUpdate }) => { return useCallback(async ({ item, mem, abortSignal }) => {
const meSats = (item?.meSats || 0) + pendingSats const meSats = (item?.meSats || 0)
// 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 })
@ -251,11 +262,12 @@ 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?.(satsDelta) revert = optimisticUpdate({ mutation: ZAP_MUTATION, variables, optimisticResponse, update })
strike()
await abortSignal.pause({ me, amount: satsDelta }) await abortSignal.pause({ me, amount: satsDelta })
@ -265,16 +277,10 @@ 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
} }
@ -290,7 +296,7 @@ export function useZap () {
} finally { } finally {
if (nid) unnotify(nid) if (nid) unnotify(nid)
} }
}, [me?.id, strike, payment, notify, unnotify, pendingSats]) }, [me?.id, strike, payment, notify, unnotify])
} }
export class ActCanceledError extends Error { export class ActCanceledError extends Error {

View File

@ -15,14 +15,13 @@ 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 { USER_ID } from '@/lib/constants' import { AD_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',
@ -37,7 +36,6 @@ 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(() => {
@ -47,8 +45,8 @@ export default function ItemInfo ({
}, [item]) }, [item])
useEffect(() => { useEffect(() => {
if (item) setMeTotalSats((item.meSats || 0) + (item.meAnonSats || 0) + (pendingSats)) if (item) setMeTotalSats((item.meSats || 0) + (item.meAnonSats || 0))
}, [item?.meSats, item?.meAnonSats, pendingSats]) }, [item?.meSats, item?.meAnonSats])
// 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
@ -58,11 +56,9 @@ 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) === USER_ID.ad) && {!(item.position && (pinnable || !item.subName)) && !(!item.parentId && Number(item.user?.id) === AD_USER_ID) &&
<> <>
<span title={`from ${numWithUnits(item.upvotes, { <span title={`from ${numWithUnits(item.upvotes, {
abbreviate: false, abbreviate: false,
@ -70,11 +66,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 })}${downSats : `(${numWithUnits(meTotalSats, { abbreviate: false })}${item.meDontLikeSats
? ` & ${numWithUnits(downSats, { abbreviate: false, unitSingular: 'downsat', unitPlural: 'downsats' })}` ? ` & ${numWithUnits(item.meDontLikeSats, { abbreviate: false, unitSingular: 'downsat', unitPlural: 'downsats' })}`
: ''} from me)`} `} : ''} from me)`} `}
> >
{numWithUnits(item.sats + pendingSats)} {numWithUnits(item.sats)}
</span> </span>
<span> \ </span> <span> \ </span>
</>} </>}
@ -92,7 +88,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 + pendingCommentSats)} className='text-reset position-relative' }} title={numWithUnits(item.commentSats)} className='text-reset position-relative'
> >
{numWithUnits(item.ncomments, { {numWithUnits(item.ncomments, {
abbreviate: false, abbreviate: false,
@ -181,7 +177,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 &&
(downSats > meTotalSats (item.meDontLikeSats > 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 &&

View File

@ -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 { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react' import { useRef } from 'react'
import { USER_ID, UNKNOWN_LINK_REL } from '@/lib/constants' import { AD_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,52 +45,6 @@ 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()
@ -98,7 +52,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}>
@ -106,7 +60,13 @@ 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' : ''}`}>
<ZapIcon item={item} pinnable={pinnable} /> {item.position && (pinnable || !item.subName)
? <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
@ -139,7 +99,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) === USER_ID.ad && <Badge className={styles.newComment} bg={null}>AD</Badge>} extraBadges={Number(item?.user?.id) === AD_USER_ID && <Badge className={styles.newComment} bg={null}>AD</Badge>}
/> />
{belowTitle} {belowTitle}
</div> </div>
@ -150,7 +110,7 @@ export default function Item ({ item, rank, belowTitle, right, full, children, s
{children} {children}
</div> </div>
)} )}
</ItemContextProvider> </>
) )
} }
@ -170,7 +130,7 @@ export function ItemSummary ({ item }) {
item={item} item={item}
showUser={false} showUser={false}
showActionDropdown={false} showActionDropdown={false}
extraBadges={item.title && Number(item?.user?.id) === USER_ID.ad && <Badge className={styles.newComment} bg={null}>AD</Badge>} extraBadges={item.title && Number(item?.user?.id) === AD_USER_ID && <Badge className={styles.newComment} bg={null}>AD</Badge>}
/> />
) )
@ -228,21 +188,6 @@ 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))

View File

@ -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 { USER_ID, BALANCE_LIMIT_MSATS, Wallet } from '../../lib/constants' import { ANON_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: USER_ID.anon }} /> @anon<Hat user={{ id: ANON_USER_ID }} />
</Nav.Link> </Nav.Link>
</Dropdown.Toggle> </Dropdown.Toggle>
<Dropdown.Menu className='p-3'> <Dropdown.Menu className='p-3'>

View File

@ -36,8 +36,13 @@ 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(n)} fresh={fresh}> <NotificationLayout nid={nid(n)} {...defaultOnClick(itemN)} fresh={fresh}>
{ {
(type === 'Earn' && <EarnNotification n={n} />) || (type === 'Earn' && <EarnNotification n={n} />) ||
(type === 'Revenue' && <RevenueNotification n={n} />) || (type === 'Revenue' && <RevenueNotification n={n} />) ||
@ -49,7 +54,6 @@ 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} />) ||
@ -57,26 +61,15 @@ 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} />) ||
<ClientNotification n={n} /> ([ClientTypes.Zap.ERROR, ClientTypes.Zap.PENDING].includes(type) && <ClientZap n={itemN} />) ||
([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>
@ -398,26 +391,6 @@ 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 (
<> <>

View File

@ -6,8 +6,9 @@ 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 } from './item-act' import { useAct, actUpdate, ACT_MUTATION } 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'
@ -44,21 +45,30 @@ 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 cancel, nid let revert, 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: {
update: actUpdate({ me, onUpdate: onUpdate(onComplete) }) act: variables
}
}) })
} catch (error) { } catch (error) {
revert?.()
if (error instanceof InvoiceCanceledError) { if (error instanceof InvoiceCanceledError) {
return return
} }

View File

@ -4,13 +4,14 @@ 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()
@ -21,7 +22,6 @@ 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,6 +40,9 @@ export default function Poll ({ item }) {
fields: { fields: {
count (existingCount) { count (existingCount) {
return existingCount + 1 return existingCount + 1
},
meVoted () {
return true
} }
} }
}) })
@ -56,9 +59,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 cancel, nid let revert, cancel, nid
try { try {
setPendingVote(v.id) revert = optimisticUpdate({ mutation: POLL_VOTE_MUTATION, variables, optimisticResponse, update })
if (me) { if (me) {
nid = notify(ClientNotification.PollVote.PENDING, notifyProps) nid = notify(ClientNotification.PollVote.PENDING, notifyProps)
@ -66,9 +69,10 @@ 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 } })
await pollVote({ variables: { hash, hmac, ...variables }, optimisticResponse, update })
} catch (error) { } catch (error) {
revert?.()
if (error instanceof InvoiceCanceledError) { if (error instanceof InvoiceCanceledError) {
return return
} }
@ -82,7 +86,6 @@ export default function Poll ({ item }) {
cancel?.() cancel?.()
} finally { } finally {
setPendingVote(undefined)
if (nid) unnotify(nid) if (nid) unnotify(nid)
} }
} }
@ -97,8 +100,7 @@ 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 && !pendingVote const showPollButton = (!hasExpiration || timeRemaining) && !item.poll.meVoted && !mine
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 =>
@ -106,12 +108,10 @@ 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={pollCount progress={item.poll.count ? fixedDecimal(v.count * 100 / item.poll.count, 1) : 0}
? 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(pollCount, { unitSingular: 'vote', unitPlural: 'votes' })} {numWithUnits(item.poll.count, { 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}</span> <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='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>

View File

@ -186,7 +186,6 @@ 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') {
@ -211,19 +210,6 @@ 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}
@ -240,7 +226,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]}>
@ -258,35 +244,39 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
paddingRight: '15px' paddingRight: '15px'
} }
const { provider, id, meta } = parseEmbedUrl(href) try {
// Youtube video embed const { provider, id, meta } = parseEmbedUrl(href)
if (provider === 'youtube') { // Youtube video embed
return ( if (provider === 'youtube') {
<div style={videoWrapperStyles}> return (
<YouTube <div style={videoWrapperStyles}>
videoId={id} className={styles.videoContainer} opts={{ <YouTube
playerVars: { videoId={id} className={styles.videoContainer} opts={{
start: meta?.start || 0 playerVars: {
} start: meta?.start || 0
}} }
/> }}
</div>
)
}
// Rumble video embed
if (provider === 'rumble') {
return (
<div style={videoWrapperStyles}>
<div className={styles.videoContainer}>
<iframe
title='Rumble Video'
allowFullScreen=''
src={meta?.href}
/> />
</div> </div>
</div> )
) }
// Rumble video embed
if (provider === 'rumble') {
return (
<div style={videoWrapperStyles}>
<div className={styles.videoContainer}>
<iframe
title='Rumble Video'
allowFullScreen=''
src={meta?.href}
/>
</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

View File

@ -12,8 +12,6 @@ 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()
@ -56,23 +54,12 @@ 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} optimisticUpdate={optimisticUpdate} />) <ItemAct onClose={onClose} item={item} />)
}} }}
> >
<span className='text-success'>zap</span> <span className='text-success'>zap</span>
@ -109,9 +96,8 @@ export default function UpVote ({ item, className }) {
setWalkthrough(upvotePopover: $upvotePopover, tipPopover: $tipPopover) setWalkthrough(upvotePopover: $upvotePopover, tipPopover: $tipPopover)
}` }`
) )
const strike = useLightning()
const [controller, setController] = useState() const [controller, setController] = useState(null)
const { pendingSats, setPendingSats } = useItemContext()
const pending = controller?.started && !controller.done const pending = controller?.started && !controller.done
const setVoteShow = useCallback((yes) => { const setVoteShow = useCallback((yes) => {
@ -148,7 +134,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) + pendingSats const meSats = (item?.meSats || item?.meAnonSats || 0)
// what should our next tip be? // what should our next tip be?
const sats = nextTip(meSats, { ...me?.privates }) const sats = nextTip(meSats, { ...me?.privates })
@ -156,16 +142,7 @@ 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, pendingSats, me?.privates?.tipDefault, me?.privates?.turboDefault]) }, [item?.meSats, item?.meAnonSats, 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)
@ -183,16 +160,13 @@ 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 <ItemAct onClose={onClose} item={item} abortSignal={c.signal} />, { onClose: handleModalClosed })
onClose={onClose} item={item} abortSignal={c.signal} optimisticUpdate={optimisticUpdate}
/>, { onClose: handleModalClosed })
} }
const handleShortPress = async () => { const handleShortPress = async () => {
@ -212,19 +186,18 @@ 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, optimisticUpdate }) await zap({ item, me, abortSignal: c.signal })
} else { } else {
showModal(onClose => <ItemAct onClose={onClose} item={item} optimisticUpdate={optimisticUpdate} />, { onClose: handleModalClosed }) showModal(onClose => <ItemAct onClose={onClose} item={item} />, { onClose: handleModalClosed })
} }
} }
const fillColor = hover || pending ? nextColor : color const fillColor = hover ? nextColor : color
return ( return (
<div ref={ref} className='upvoteParent'> <div ref={ref} className='upvoteParent'>
@ -249,7 +222,7 @@ export default function UpVote ({ item, className }) {
${meSats ? styles.voted : ''} ${meSats ? styles.voted : ''}
${pending ? styles.pending : ''}` ${pending ? styles.pending : ''}`
} }
style={meSats || hover || pending style={meSats || hover
? { ? {
fill: fillColor, fill: fillColor,
filter: `drop-shadow(0 0 6px ${fillColor}90)` filter: `drop-shadow(0 0 6px ${fillColor}90)`

View File

@ -498,10 +498,6 @@ 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

View File

@ -126,6 +126,7 @@ export const POLL_FIELDS = gql`
id id
option option
count count
meVoted
} }
} }
}` }`

View File

@ -25,14 +25,6 @@ export const NOTIFICATIONS = gql`
text text
} }
} }
... on ItemMention {
id
sortTime
item {
...ItemFullFields
text
}
}
... on Votification { ... on Votification {
id id
sortTime sortTime

View File

@ -38,7 +38,6 @@ export const ME = gql`
noteItemSats noteItemSats
noteJobIndicator noteJobIndicator
noteMentions noteMentions
noteItemMentions
sats sats
tipDefault tipDefault
tipPopover tipPopover
@ -74,7 +73,6 @@ export const SETTINGS_FIELDS = gql`
noteEarning noteEarning
noteAllDescendants noteAllDescendants
noteMentions noteMentions
noteItemMentions
noteDeposits noteDeposits
noteWithdrawals noteWithdrawals
noteInvites noteInvites

View File

@ -255,3 +255,17 @@ 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)
}

View File

@ -36,16 +36,8 @@ 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 USER_ID = { export const SN_USER_IDS = [616, 6030, 4502, 27]
k00b: 616, export const SN_NO_REWARDS_IDS = [27, 4502]
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
@ -62,14 +54,17 @@ 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 = USER_ID.k00b export const GLOBAL_SEED = 616
export const FREEBIE_BASE_COST_THRESHOLD = 10 export const FREEBIE_BASE_COST_THRESHOLD = 10
export const USER_IDS_BALANCE_NO_LIMIT = [...SN_USER_IDS, USER_ID.anon, USER_ID.ad] export const USER_IDS_BALANCE_NO_LIMIT = [...SN_USER_IDS, AD_USER_ID]
// 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

View File

@ -1,23 +1,10 @@
export function ensureProtocol (value) { export function ensureProtocol (value) {
if (!value) return value if (!value) return value
value = value.trim() value = value.trim()
let url if (!/^([a-z0-9]+:\/\/|mailto:)/.test(value)) {
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) {
@ -39,22 +26,14 @@ 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 '/'
if (isItemPath(pathname) && url.origin === internalURL) { const emptyPart = part => !!part
const parts = pathname.split('/').filter(part => !!part) const parts = pathname.split('/').filter(emptyPart)
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']
@ -65,63 +44,47 @@ 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 { itemId, linkText } return linkText
} }
const commentId = searchParams.get('commentId') const commentId = searchParams.get('commentId')
const linkText = `#${commentId || itemId}` const linkText = `#${commentId || itemId}`
return { itemId, commentId, linkText } return 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')) { if (hostname.endsWith('youtube.com') && pathname.includes('/watch')) {
if (pathname.includes('/watch')) { return {
return { provider: 'youtube',
provider: 'youtube', id: searchParams.get('v'),
id: searchParams.get('v'), meta: {
meta: { href,
href, start: searchParams.get('t')
start: searchParams.get('t')
}
}
}
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',
id: pathname.slice(1), // remove leading slash id: pathname.slice(1), // remove leading slash
meta: { meta: {
href, href,
start: searchParams.get('t') start: searchParams.get('t')
}
} }
} }
}
if (hostname.endsWith('rumble.com') && pathname.includes('/embed')) { if (hostname.endsWith('rumble.com') && pathname.includes('/embed')) {
return { return {
provider: 'rumble', provider: 'rumble',
id: null, // not required id: null, // not required
meta: { meta: {
href href
}
} }
} }
} catch {
// ignore
} }
// Important to return empty object as default // Important to return empty object as default

View File

@ -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 { linkText: actual } = parseInternalLinks(href) const actual = parseInternalLinks(href)
expect(actual).toBe(expected) expect(actual).toBe(expected)
} }
) )

View File

@ -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 { USER_ID, COMMENT_DEPTH_LIMIT, FOUND_BLURBS, LOST_BLURBS } from './constants' import { ANON_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,7 +38,6 @@ 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',
@ -180,7 +179,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 || USER_ID.anon } }) const user = await models.user.findUnique({ where: { id: me?.id || ANON_USER_ID } })
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)',
@ -263,27 +262,6 @@ 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' })

View File

@ -30,7 +30,6 @@ 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 })
@ -107,7 +106,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) && <AuthBanner />} {hasOnlyOneAuthMethod(settings?.authMethods) && <div className={styles.alert}>Please add a second auth method to avoid losing access to your account.</div>}
<SettingsHeader /> <SettingsHeader />
<Form <Form
initial={{ initial={{
@ -121,7 +120,6 @@ 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,
@ -282,11 +280,6 @@ 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'
@ -1008,9 +1001,10 @@ const ZapUndosField = () => {
zap undos zap undos
<Info> <Info>
<ul className='fw-bold'> <ul className='fw-bold'>
<li>After every zap that exceeds or is equal to the threshold, the bolt will pulse</li> <li>An undo button is shown after every zap that exceeds or is equal to the threshold</li>
<li>You can undo the zap if you click the bolt while it's pulsing</li> <li>The button is shown for {ZAP_UNDO_DELAY_MS / 1000} seconds</li>
<li>The bolt will pulse for {ZAP_UNDO_DELAY_MS / 1000} seconds</li> <li>The button is only shown for zaps from the custodial wallet</li>
<li>Use a budget or manual approval with attached wallets</li>
</ul> </ul>
</Info> </Info>
</div> </div>

View File

@ -1,92 +0,0 @@
-- 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;
$$;

View File

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

View File

@ -44,7 +44,6 @@ 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)
@ -80,6 +79,7 @@ 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,7 +125,6 @@ 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")
@ -412,8 +411,6 @@ 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[]
@ -427,7 +424,6 @@ 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])
@ -505,25 +501,16 @@ 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)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([itemId, userId], map: "PollVote.itemId_userId_unique")
@@index([pollOptionId], map: "PollVote.pollOptionId_index") @@index([pollOptionId], map: "PollVote.pollOptionId_index")
} @@index([userId], map: "PollVote.userId_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)
@@unique([itemId, userId], map: "PollBlindVote.itemId_userId_unique")
@@index([userId], map: "PollBlindVote.userId_index")
} }
enum BillingType { enum BillingType {
@ -674,21 +661,6 @@ 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
View File

@ -159,11 +159,6 @@ 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 "$@"
} }
@ -224,43 +219,6 @@ 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 "$@"
@ -558,7 +516,6 @@ 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

View File

@ -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', 'ITEM_MENTION', 'REFERRAL', 'INVITE', 'FOLLOW', 'TERRITORY_POST'] const AMOUNT_TAGS = ['REPLY', '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,8 +143,6 @@ 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') {

View File

@ -1,5 +1,5 @@
import { deleteObjects } from '@/api/s3' import { deleteObjects } from '@/api/s3'
import { USER_ID } from '@/lib/constants' import { ANON_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" = ${USER_ID.anon} THEN interval '1 hour' ELSE interval '24 hours' END)` AND created_at < date_trunc('hour', now() - CASE WHEN "userId" = ${ANON_USER_ID} 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) {

View File

@ -1,5 +1,5 @@
import * as math from 'mathjs' import * as math from 'mathjs'
import { USER_ID, SN_USER_IDS } from '@/lib/constants.js' import { ANON_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 <> ${USER_ID.anon} JOIN users ON "ItemAct"."userId" = users.id AND users.id <> ${ANON_USER_ID}
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}