Compare commits

..

27 Commits

Author SHA1 Message Date
ekzyis
2bc441a618 wip 2024-06-17 10:00:40 +02:00
ekzyis
0f553b1aef refactor webln 2024-06-17 10:00:40 +02:00
Keyan
8329da1f56
Update awards.csv 2024-06-13 16:40:16 -05:00
keyan
b1f850ee0e clear email hash when email is unlinked 2024-06-13 12:14:08 -05:00
ekzyis
8a19fc0905
Use <Alert> for auth banner in /settings (#1238) 2024-06-12 18:16:54 -05:00
ekzyis
286f53f2b3
Update zap undos info (#1237) 2024-06-12 18:16:41 -05:00
ekzyis
cbcae1d128
Fix downzaps (#1236)
* Add optimistic update for downzaps

* Add optimistic update for zaps via dropdown

* Also use lightning strike for downzaps
2024-06-12 13:24:04 -05:00
ekzyis
967b5b74fb
Fix anon payment verification (#1235)
* Enforce hash & hmac for anons in serialize

* Enforce logged in for idempotent zaps
2024-06-12 11:15:00 -05:00
ekzyis
93713b33df
Optimistic updates via pending sats in item context (#1229)
* Use context for pending sats

* Fix sats going negative on zap undo

We already handle undoing pending sats by wrapping the payment+mutation with try/finally.

* Remove unnecessary ItemContextProvider

* Rename to parentCtx

* Fix hierarchy of ItemContextProvider

If a comment was root and it was zapped, the pending sats contributed to the sats shown in <CommentsHeader>.

This was caused by <CommentsHeader> accessing the root item context for all comments, even for the root comment.

So even if the root comment was zapped, the pending sats contributed to the sats for the comment section.

This wasn't the case for posts since their item context was above the context used by <CommentsHeader>.

This was fixed by moving <ItemProviderContext> down into <Comments> and <Item> instead of declaring it at <ItemFull> which wraps the root item and all comments.

* Optimistic update for poll votes

* prevent twice optimistic zap

* enhance client notifications with skeleton and no redudant queries

* enlarge nwc amount limits

* Disable max amount and daily limit in NWC container

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: keyan <keyan.kousha+huumn@gmail.com>
2024-06-12 08:34:24 -05:00
Tom
569d0448c2
Remove toast error (#1231) 2024-06-11 11:22:20 -05:00
ekzyis
35be035850
More zap undo fixes III (#1228)
* Fix pending state not immediately updated

Before, the bolt wasn't rerendered if the user clicked again within the undo delay since no state changed.

* Fix zap undo pulse only shown on hover
2024-06-06 08:22:05 -05:00
ekzyis
09f9efa189
Remove strike delay (#1227) 2024-06-05 11:49:09 -05:00
ekzyis
79ed07ae74
Embed youtube shorts (#1225) 2024-06-05 09:08:27 -05:00
ekzyis
23c51df283
Revert reverse refs (#1224)
* Remove reverse internal refs

* Formatting
2024-06-05 08:21:01 -05:00
keyan
1dcb6461c7 give sndev logs better default params 2024-06-04 13:07:03 -05:00
keyan
2775b49ce7 fix inconsistency in url handling and don't let parseEmbedURL throw 2024-06-04 12:10:37 -05:00
ekzyis
ea97fbf4a4
Avoid manual optimistic updates for now (#1220)
* Avoid manual optimistic zap updates for now

* Remove manual optimistic updates for pay-bounty and poll
2024-06-04 03:02:34 -05:00
ekzyis
d8fe698963
Fix missing commentId parsing for item mentions (#1219) 2024-06-03 21:54:42 -05:00
keyan
061d3f220d fix local dev missing snl row 2024-06-03 16:55:03 -05:00
keyan
c90eb055c7 fix performance of sub nested resolvers 2024-06-03 16:50:38 -05:00
keyan
7b667821d2 fix notification indicator for item mentions 2024-06-03 16:34:38 -05:00
Keyan
b4c120ab39
Update awards.csv 2024-06-03 14:40:37 -05:00
SatsAllDay
e3571af1e1
Make Polls Anonymous (#1197)
* make polls anonymous

Introduce a `PollBlindVote` DB table that tracks when a user votes in a poll,
but does not track which choice they made.

Alter the `PollVote` DB table to remove the `userId` column, meaning `PollVote`
now tracks poll votes anonymously - it captures votes per poll option,
but does not track which user submitted the vote.

Update the `poll_vote` DB function to work with both tables now.

Update the `item.poll` resolver to calculate `meVoted` based on the `PollBlindVote`
table instead of `PollVote`.

* remove `meVoted` on `PollOption`s

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2024-06-03 13:56:43 -05:00
ekzyis
2597eb56f3
Item mention notifications (#1208)
* Parse internal refs to links

* Item mention notifications

* Also parse item mentions as URLs

* Fix subType determined by referrer item instead of referee item

* Ignore subType

Considering if the item that was referred to was a post or comment made the code more complex than initially necessary.

For example, notifications for /notifications are deduplicated based on item id and the same item could refer to posts and comments, so to include "one of your posts" or "one of your comments" in the title would require splitting notifications based on the type of referred item.

I didn't want to do this but also wanted to have consistent notification titles between push and /notifications, so I use "items" in both places now, even though I think using "items" isn't ideal from a user perspective. I think it might be confusing.

* Fix rootText

* Replace full links to #<id> syntax in push notifications

* Refactor mention code into separate functions
2024-06-03 12:12:42 -05:00
Tom
d454bbdb72
Fix issue with popover on full sn links (#1216)
* Fix issue with popover on full sn links

* allow custom text for internal links

---------

Co-authored-by: keyan <keyan.kousha+huumn@gmail.com>
2024-06-03 12:05:31 -05:00
ekzyis
86b857b8d4
Allow SN users to edit special items forever (#1204)
* Allow SN users to edit special items

* Refactor item edit validation

* Create object for user IDs

* Remove anon from SN_USER_IDS

* Fix isMine and myBio checks

* Don't update author

* remove anon from trust graph

---------

Co-authored-by: keyan <keyan.kousha+huumn@gmail.com>
2024-06-03 11:26:19 -05:00
SatsAllDay
e9aa268996
add fund_user sndev command (#1214)
* add `fund_user` sndev command

* update help and set msats rather than add

---------

Co-authored-by: keyan <keyan.kousha+huumn@gmail.com>
2024-06-03 09:37:58 -05:00
47 changed files with 953 additions and 414 deletions

View File

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

View File

@ -1,4 +1,4 @@
import { ANON_USER_ID, AWS_S3_URL_REGEXP } from '@/lib/constants' import { USER_ID, AWS_S3_URL_REGEXP } from '@/lib/constants'
import { msatsToSats } from '@/lib/format' import { msatsToSats } from '@/lib/format'
export default { export default {
@ -17,7 +17,7 @@ export function uploadIdsFromText (text, { models }) {
export async function imageFeesInfo (s3Keys, { models, me }) { export async function imageFeesInfo (s3Keys, { models, me }) {
// returns info object in this format: // returns info object in this format:
// { bytes24h: int, bytesUnpaid: int, nUnpaid: int, imageFeeMsats: BigInt } // { bytes24h: int, bytesUnpaid: int, nUnpaid: int, imageFeeMsats: BigInt }
const [info] = await models.$queryRawUnsafe('SELECT * FROM image_fees_info($1::INTEGER, $2::INTEGER[])', me ? me.id : ANON_USER_ID, s3Keys) const [info] = await models.$queryRawUnsafe('SELECT * FROM image_fees_info($1::INTEGER, $2::INTEGER[])', me ? me.id : USER_ID.anon, s3Keys)
const imageFee = msatsToSats(info.imageFeeMsats) const imageFee = msatsToSats(info.imageFeeMsats)
const totalFeesMsats = info.nUnpaid * Number(info.imageFeeMsats) const totalFeesMsats = info.nUnpaid * Number(info.imageFeeMsats)
const totalFees = msatsToSats(totalFeesMsats) const totalFees = msatsToSats(totalFeesMsats)

View File

@ -1,5 +1,5 @@
import { GraphQLError } from 'graphql' import { GraphQLError } from 'graphql'
import { ensureProtocol, removeTracking, stripTrailingSlash } from '@/lib/url' import { ensureProtocol, parseInternalLinks, removeTracking, stripTrailingSlash } from '@/lib/url'
import serialize from './serial' import serialize from './serial'
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor' import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
import { getMetadata, metadataRuleSets } from 'page-metadata-parser' import { getMetadata, metadataRuleSets } from 'page-metadata-parser'
@ -8,14 +8,14 @@ import domino from 'domino'
import { import {
ITEM_SPAM_INTERVAL, ITEM_FILTER_THRESHOLD, ITEM_SPAM_INTERVAL, ITEM_FILTER_THRESHOLD,
COMMENT_DEPTH_LIMIT, COMMENT_TYPE_QUERY, COMMENT_DEPTH_LIMIT, COMMENT_TYPE_QUERY,
ANON_USER_ID, ANON_ITEM_SPAM_INTERVAL, POLL_COST, USER_ID, ANON_ITEM_SPAM_INTERVAL, POLL_COST,
ITEM_ALLOW_EDITS, GLOBAL_SEED, ANON_FEE_MULTIPLIER, NOFOLLOW_LIMIT, UNKNOWN_LINK_REL ITEM_ALLOW_EDITS, GLOBAL_SEED, ANON_FEE_MULTIPLIER, NOFOLLOW_LIMIT, UNKNOWN_LINK_REL, SN_USER_IDS
} from '@/lib/constants' } from '@/lib/constants'
import { msatsToSats } from '@/lib/format' import { msatsToSats } from '@/lib/format'
import { parse } from 'tldts' import { parse } from 'tldts'
import uu from 'url-unshort' import uu from 'url-unshort'
import { actSchema, advSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, ssValidate } from '@/lib/validate' import { actSchema, advSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, ssValidate } from '@/lib/validate'
import { notifyItemParents, notifyUserSubscribers, notifyZapped, notifyTerritorySubscribers, notifyMention } from '@/lib/webPush' import { notifyItemParents, notifyUserSubscribers, notifyZapped, notifyTerritorySubscribers, notifyMention, notifyItemMention } from '@/lib/webPush'
import { defaultCommentSort, isJob, deleteItemByAuthor, getDeleteCommand, hasDeleteCommand, getReminderCommand, hasReminderCommand } from '@/lib/item' import { defaultCommentSort, isJob, deleteItemByAuthor, getDeleteCommand, hasDeleteCommand, getReminderCommand, hasReminderCommand } from '@/lib/item'
import { datePivot, whenRange } from '@/lib/time' import { datePivot, whenRange } from '@/lib/time'
import { imageFeesInfo, uploadIdsFromText } from './image' import { imageFeesInfo, uploadIdsFromText } from './image'
@ -825,7 +825,7 @@ export default {
await serialize( await serialize(
models.$queryRawUnsafe(`${SELECT} FROM poll_vote($1::INTEGER, $2::INTEGER) AS "Item"`, Number(id), Number(me.id)), models.$queryRawUnsafe(`${SELECT} FROM poll_vote($1::INTEGER, $2::INTEGER) AS "Item"`, Number(id), Number(me.id)),
{ models, lnd, me, hash, hmac } { models, lnd, me, hash, hmac, verifyPayment: !!hash || !me }
) )
return id return id
@ -859,7 +859,7 @@ export default {
} }
} }
if (idempotent) { if (me && idempotent) {
await serialize( await serialize(
models.$queryRaw` models.$queryRaw`
SELECT SELECT
@ -869,15 +869,15 @@ export default {
WHERE act IN ('TIP', 'FEE') WHERE act IN ('TIP', 'FEE')
AND "itemId" = ${Number(id)}::INTEGER AND "itemId" = ${Number(id)}::INTEGER
AND "userId" = ${me.id}::INTEGER)::INTEGER)`, AND "userId" = ${me.id}::INTEGER)::INTEGER)`,
{ models, lnd, hash, hmac } { models, lnd, hash, hmac, verifyPayment: !!hash }
) )
} else { } else {
await serialize( await serialize(
models.$queryRaw` models.$queryRaw`
SELECT SELECT
item_act(${Number(id)}::INTEGER, item_act(${Number(id)}::INTEGER,
${me?.id || ANON_USER_ID}::INTEGER, ${act}::"ItemActType", ${Number(sats)}::INTEGER)`, ${me?.id || USER_ID.anon}::INTEGER, ${act}::"ItemActType", ${Number(sats)}::INTEGER)`,
{ models, lnd, me, hash, hmac, fee: sats } { models, lnd, me, hash, hmac, fee: sats, verifyPayment: !!hash || !me }
) )
} }
@ -1004,8 +1004,7 @@ export default {
} }
const options = await models.$queryRaw` const options = await models.$queryRaw`
SELECT "PollOption".id, option, count("PollVote"."userId")::INTEGER as count, SELECT "PollOption".id, option, count("PollVote".id)::INTEGER as count
coalesce(bool_or("PollVote"."userId" = ${me?.id}), 'f') as "meVoted"
FROM "PollOption" FROM "PollOption"
LEFT JOIN "PollVote" on "PollVote"."pollOptionId" = "PollOption".id LEFT JOIN "PollVote" on "PollVote"."pollOptionId" = "PollOption".id
WHERE "PollOption"."itemId" = ${item.id} WHERE "PollOption"."itemId" = ${item.id}
@ -1013,9 +1012,16 @@ export default {
ORDER BY "PollOption".id ASC ORDER BY "PollOption".id ASC
` `
const meVoted = await models.pollBlindVote.findFirst({
where: {
userId: me?.id,
itemId: item.id
}
})
const poll = {} const poll = {}
poll.options = options poll.options = options
poll.meVoted = options.some(o => o.meVoted) poll.meVoted = !!meVoted
poll.count = options.reduce((t, o) => t + o.count, 0) poll.count = options.reduce((t, o) => t + o.count, 0)
return poll return poll
@ -1157,7 +1163,7 @@ export default {
return parent.otsHash return parent.otsHash
}, },
deleteScheduledAt: async (item, args, { me, models }) => { deleteScheduledAt: async (item, args, { me, models }) => {
const meId = me?.id ?? ANON_USER_ID const meId = me?.id ?? USER_ID.anon
if (meId !== item.userId) { if (meId !== item.userId) {
// Only query for deleteScheduledAt for your own items to keep DB queries minimized // Only query for deleteScheduledAt for your own items to keep DB queries minimized
return null return null
@ -1166,8 +1172,8 @@ export default {
return deleteJobs[0]?.startafter ?? null return deleteJobs[0]?.startafter ?? null
}, },
reminderScheduledAt: async (item, args, { me, models }) => { reminderScheduledAt: async (item, args, { me, models }) => {
const meId = me?.id ?? ANON_USER_ID const meId = me?.id ?? USER_ID.anon
if (meId !== item.userId || meId === ANON_USER_ID) { if (meId !== item.userId || meId === USER_ID.anon) {
// don't show reminders on an item if it isn't yours // don't show reminders on an item if it isn't yours
// don't support reminders for ANON // don't support reminders for ANON
return null return null
@ -1179,6 +1185,7 @@ export default {
} }
const namePattern = /\B@[\w_]+/gi const namePattern = /\B@[\w_]+/gi
const refPattern = new RegExp(`${process.env.NEXT_PUBLIC_URL}/items/\\d+.*`, 'gi')
export const createMentions = async (item, models) => { export const createMentions = async (item, models) => {
// if we miss a mention, in the rare circumstance there's some kind of // if we miss a mention, in the rare circumstance there's some kind of
@ -1188,9 +1195,25 @@ export const createMentions = async (item, models) => {
return return
} }
// user mentions
try { try {
await createUserMentions(item, models)
} catch (e) {
console.error('user mention failure', e)
}
// item mentions
try {
await createItemMentions(item, models)
} catch (e) {
console.error('item mention failure', e)
}
}
const createUserMentions = async (item, models) => {
const mentions = item.text.match(namePattern)?.map(m => m.slice(1)) const mentions = item.text.match(namePattern)?.map(m => m.slice(1))
if (mentions?.length > 0) { if (!mentions || mentions.length === 0) return
const users = await models.user.findMany({ const users = await models.user.findMany({
where: { where: {
name: { in: mentions }, name: { in: mentions },
@ -1218,19 +1241,66 @@ export const createMentions = async (item, models) => {
notifyMention({ models, userId: user.id, item }) notifyMention({ models, userId: user.id, item })
} }
}) })
}
const createItemMentions = async (item, models) => {
const refs = item.text.match(refPattern)?.map(m => {
try {
const { itemId, commentId } = parseInternalLinks(m)
return Number(commentId || itemId)
} catch (err) {
return null
} }
} catch (e) { }).filter(r => !!r)
console.error('mention failure', e) if (!refs || refs.length === 0) return
const referee = await models.item.findMany({
where: {
id: { in: refs },
// Don't create mentions for your own items
userId: { not: item.userId }
} }
})
referee.forEach(async r => {
const data = {
referrerId: item.id,
refereeId: r.id
}
const mention = await models.itemMention.upsert({
where: {
referrerId_refereeId: data
},
update: data,
create: data
})
// only send if mention is new to avoid duplicates
if (mention.createdAt.getTime() === mention.updatedAt.getTime()) {
notifyItemMention({ models, referrerItem: item, refereeItem: r })
}
})
} }
export const updateItem = async (parent, { sub: subName, forward, options, ...item }, { me, models, lnd, hash, hmac }) => { export const updateItem = async (parent, { sub: subName, forward, options, ...item }, { me, models, lnd, hash, hmac }) => {
// update iff this item belongs to me // update iff this item belongs to me
const old = await models.item.findUnique({ where: { id: Number(item.id) }, include: { sub: true } }) const old = await models.item.findUnique({ where: { id: Number(item.id) }, include: { sub: true } })
if (Number(old.userId) !== Number(me?.id)) {
// author can always edit their own item
const mid = Number(me?.id)
const isMine = Number(old.userId) === mid
// allow admins to edit special items
const allowEdit = ITEM_ALLOW_EDITS.includes(old.id)
const adminEdit = SN_USER_IDS.includes(mid) && allowEdit
if (!isMine && !adminEdit) {
throw new GraphQLError('item does not belong to you', { extensions: { code: 'FORBIDDEN' } }) throw new GraphQLError('item does not belong to you', { extensions: { code: 'FORBIDDEN' } })
} }
if (subName && old.subName !== subName) {
const differentSub = subName && old.subName !== subName
if (differentSub) {
const sub = await models.sub.findUnique({ where: { name: subName } }) const sub = await models.sub.findUnique({ where: { name: subName } })
if (old.freebie) { if (old.freebie) {
if (!sub.allowFreebies) { if (!sub.allowFreebies) {
@ -1244,10 +1314,13 @@ export const updateItem = async (parent, { sub: subName, forward, options, ...it
// in case they lied about their existing boost // in case they lied about their existing boost
await ssValidate(advSchema, { boost: item.boost }, { models, me, existingBoost: old.boost }) await ssValidate(advSchema, { boost: item.boost }, { models, me, existingBoost: old.boost })
// prevent update if it's not explicitly allowed, not their bio, not their job and older than 10 minutes
const user = await models.user.findUnique({ where: { id: me.id } }) const user = await models.user.findUnique({ where: { id: me.id } })
if (!ITEM_ALLOW_EDITS.includes(old.id) && user.bioId !== old.id &&
!isJob(item) && Date.now() > new Date(old.createdAt).getTime() + 10 * 60000) { // prevent update if it's not explicitly allowed, not their bio, not their job and older than 10 minutes
const myBio = user.bioId === old.id
const timer = Date.now() < new Date(old.createdAt).getTime() + 10 * 60_000
if (!allowEdit && !myBio && !timer) {
throw new GraphQLError('item can no longer be editted', { extensions: { code: 'BAD_INPUT' } }) throw new GraphQLError('item can no longer be editted', { extensions: { code: 'BAD_INPUT' } })
} }
@ -1266,7 +1339,7 @@ export const updateItem = async (parent, { sub: subName, forward, options, ...it
} }
} }
item = { subName, userId: me.id, ...item } item = { subName, userId: old.userId, ...item }
const fwdUsers = await getForwardUsers(models, forward) const fwdUsers = await getForwardUsers(models, forward)
const uploadIds = uploadIdsFromText(item.text, { models }) const uploadIds = uploadIdsFromText(item.text, { models })
@ -1275,7 +1348,7 @@ export const updateItem = async (parent, { sub: subName, forward, options, ...it
([item] = await serialize( ([item] = await serialize(
models.$queryRawUnsafe(`${SELECT} FROM update_item($1::JSONB, $2::JSONB, $3::JSONB, $4::INTEGER[]) AS "Item"`, models.$queryRawUnsafe(`${SELECT} FROM update_item($1::JSONB, $2::JSONB, $3::JSONB, $4::INTEGER[]) AS "Item"`,
JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options), uploadIds), JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options), uploadIds),
{ models, lnd, me, hash, hmac, fee: imgFees } { models, lnd, me, hash, hmac, fee: imgFees, verifyPayment: !!hash || !me }
)) ))
await createMentions(item, models) await createMentions(item, models)
@ -1303,7 +1376,7 @@ export const createItem = async (parent, { forward, options, ...item }, { me, mo
item.subName = item.sub item.subName = item.sub
delete item.sub delete item.sub
item.userId = me ? Number(me.id) : ANON_USER_ID item.userId = me ? Number(me.id) : USER_ID.anon
const fwdUsers = await getForwardUsers(models, forward) const fwdUsers = await getForwardUsers(models, forward)
if (item.url && !isJob(item)) { if (item.url && !isJob(item)) {
@ -1332,7 +1405,7 @@ export const createItem = async (parent, { forward, options, ...item }, { me, mo
models.$queryRawUnsafe( models.$queryRawUnsafe(
`${SELECT} FROM create_item($1::JSONB, $2::JSONB, $3::JSONB, '${spamInterval}'::INTERVAL, $4::INTEGER[]) AS "Item"`, `${SELECT} FROM create_item($1::JSONB, $2::JSONB, $3::JSONB, '${spamInterval}'::INTERVAL, $4::INTEGER[]) AS "Item"`,
JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options), uploadIds), JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options), uploadIds),
{ models, lnd, me, hash, hmac, fee } { models, lnd, me, hash, hmac, fee, verifyPayment: !!hash || !me }
)) ))
await createMentions(item, models) await createMentions(item, models)
@ -1367,7 +1440,7 @@ const enqueueDeletionJob = async (item, models) => {
} }
const deleteReminderAndJob = async ({ me, item, models }) => { const deleteReminderAndJob = async ({ me, item, models }) => {
if (me?.id && me.id !== ANON_USER_ID) { if (me?.id && me.id !== USER_ID.anon) {
await models.$transaction([ await models.$transaction([
models.$queryRawUnsafe(` models.$queryRawUnsafe(`
DELETE FROM pgboss.job DELETE FROM pgboss.job
@ -1389,7 +1462,7 @@ const deleteReminderAndJob = async ({ me, item, models }) => {
const createReminderAndJob = async ({ me, item, models }) => { const createReminderAndJob = async ({ me, item, models }) => {
// disallow anon to use reminder // disallow anon to use reminder
if (!me || me.id === ANON_USER_ID) { if (!me || me.id === USER_ID.anon) {
return return
} }
const reminderCommand = getReminderCommand(item.text) const reminderCommand = getReminderCommand(item.text)

View File

@ -140,6 +140,22 @@ export default {
LIMIT ${LIMIT}` LIMIT ${LIMIT}`
) )
} }
// item mentions
if (meFull.noteItemMentions) {
itemDrivenQueries.push(
`SELECT "Referrer".*, "ItemMention".created_at AS "sortTime", 'ItemMention' AS type
FROM "ItemMention"
JOIN "Item" "Referee" ON "ItemMention"."refereeId" = "Referee".id
JOIN "Item" "Referrer" ON "ItemMention"."referrerId" = "Referrer".id
${whereClause(
'"ItemMention".created_at < $2',
'"Referrer"."userId" <> $1',
'"Referee"."userId" = $1'
)}
ORDER BY "sortTime" DESC
LIMIT ${LIMIT}`
)
}
// Inner union to de-dupe item-driven notifications // Inner union to de-dupe item-driven notifications
queries.push( queries.push(
// Only record per item ID // Only record per item ID
@ -157,6 +173,7 @@ export default {
WHEN type = 'Reply' THEN 2 WHEN type = 'Reply' THEN 2
WHEN type = 'FollowActivity' THEN 3 WHEN type = 'FollowActivity' THEN 3
WHEN type = 'TerritoryPost' THEN 4 WHEN type = 'TerritoryPost' THEN 4
WHEN type = 'ItemMention' THEN 5
END ASC END ASC
)` )`
) )
@ -456,6 +473,9 @@ export default {
mention: async (n, args, { models }) => true, mention: async (n, args, { models }) => true,
item: async (n, args, { models, me }) => getItem(n, { id: n.id }, { models, me }) item: async (n, args, { models, me }) => getItem(n, { id: n.id }, { models, me })
}, },
ItemMention: {
item: async (n, args, { models, me }) => getItem(n, { id: n.id }, { models, me })
},
InvoicePaid: { InvoicePaid: {
invoice: async (n, args, { me, models }) => getInvoice(n, { id: n.id }, { me, models }) invoice: async (n, args, { me, models }) => getInvoice(n, { id: n.id }, { me, models })
}, },

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 { ANON_USER_ID } from '@/lib/constants' import { USER_ID } from '@/lib/constants'
import { getItem } from './item' import { getItem } from './item'
import { topUsers } from './user' import { topUsers } from './user'
@ -165,8 +165,8 @@ export default {
await ssValidate(amountSchema, { amount: sats }) await ssValidate(amountSchema, { amount: sats })
await serialize( await serialize(
models.$queryRaw`SELECT donate(${sats}::INTEGER, ${me?.id || ANON_USER_ID}::INTEGER)`, models.$queryRaw`SELECT donate(${sats}::INTEGER, ${me?.id || USER_ID.anon}::INTEGER)`,
{ models, lnd, me, hash, hmac, fee: sats } { models, lnd, me, hash, hmac, fee: sats, verifyPayment: !!hash || !me }
) )
return sats return sats

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 }) { export default async function serialize (trx, { models, lnd, me, hash, hmac, fee, verifyPayment: verify }) {
// wrap first argument in array if not array already // wrap first argument in array if not array already
const isArray = Array.isArray(trx) const isArray = Array.isArray(trx)
if (!isArray) trx = [trx] if (!isArray) trx = [trx]
@ -17,7 +17,7 @@ export default async function serialize (trx, { models, lnd, me, hash, hmac, fee
trx = trx.filter(q => !!q) trx = trx.filter(q => !!q)
let invoice let invoice
if (hash) { if (verify) {
invoice = await verifyPayment(models, hash, hmac, fee) invoice = await verifyPayment(models, hash, hmac, fee)
trx = [ trx = [
models.$executeRaw`SELECT confirm_invoice(${hash}, ${invoice.msatsReceived})`, models.$executeRaw`SELECT confirm_invoice(${hash}, ${invoice.msatsReceived})`,

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 }) { models, lnd, me, hash, hmac, fee: sub.billingCost, verifyPayment: !!hash || !me })
return results[1] return results[1]
}, },
toggleMuteSub: async (parent, { name }, { me, models }) => { toggleMuteSub: async (parent, { name }, { me, models }) => {
@ -368,7 +368,7 @@ export default {
models.sub.update({ where: { name }, data: newSub }), models.sub.update({ where: { name }, data: newSub }),
isTransfer && models.territoryTransfer.create({ data: { subName: name, oldUserId: oldSub.userId, newUserId: me.id } }) isTransfer && models.territoryTransfer.create({ data: { subName: name, oldUserId: oldSub.userId, newUserId: me.id } })
], ],
{ models, lnd, hash, me, hmac, fee: billingCost }) { models, lnd, hash, me, hmac, fee: billingCost, verifyPayment: !!hash || !me })
if (isTransfer) notifyTerritoryTransfer({ models, sub: newSub, to: me }) if (isTransfer) notifyTerritoryTransfer({ models, sub: newSub, to: me })
} }
@ -382,7 +382,10 @@ export default {
return await models.user.findUnique({ where: { id: sub.userId } }) return await models.user.findUnique({ where: { id: sub.userId } })
}, },
meMuteSub: async (sub, args, { models }) => { meMuteSub: async (sub, args, { models }) => {
return sub.meMuteSub || sub.MuteSub?.length > 0 if (sub.meMuteSub !== undefined) {
return sub.meMuteSub
}
return sub.MuteSub?.length > 0
}, },
nposts: async (sub, { when, from, to }, { models }) => { nposts: async (sub, { when, from, to }, { models }) => {
if (typeof sub.nposts !== 'undefined') { if (typeof sub.nposts !== 'undefined') {
@ -395,7 +398,11 @@ export default {
} }
}, },
meSubscription: async (sub, args, { me, models }) => { meSubscription: async (sub, args, { me, models }) => {
return sub.meSubscription || sub.SubSubscription?.length > 0 if (sub.meSubscription !== undefined) {
return sub.meSubscription
}
return sub.SubSubscription?.length > 0
}, },
createdAt: sub => sub.createdAt || sub.created_at createdAt: sub => sub.createdAt || sub.created_at
} }
@ -457,7 +464,7 @@ async function createSub (parent, data, { me, models, lnd, hash, hmac }) {
subName: data.name subName: data.name
} }
}) })
], { models, lnd, me, hash, hmac, fee: billingCost }) ], { models, lnd, me, hash, hmac, fee: billingCost, verifyPayment: !!hash || !me })
return results[1] return results[1]
} catch (error) { } catch (error) {
@ -538,7 +545,7 @@ async function updateSub (parent, { oldName, ...data }, { me, models, lnd, hash,
userId: me.id userId: me.id
} }
}) })
], { models, lnd, me, hash, hmac, fee: proratedCost }) ], { models, lnd, me, hash, hmac, fee: proratedCost, verifyPayment: !!hash || !me })
return results[2] return results[2]
} }
} }

View File

@ -1,5 +1,5 @@
import { GraphQLError } from 'graphql' import { GraphQLError } from 'graphql'
import { ANON_USER_ID, IMAGE_PIXELS_MAX, UPLOAD_SIZE_MAX, UPLOAD_SIZE_MAX_AVATAR, UPLOAD_TYPES_ALLOW } from '@/lib/constants' import { USER_ID, IMAGE_PIXELS_MAX, UPLOAD_SIZE_MAX, UPLOAD_SIZE_MAX_AVATAR, UPLOAD_TYPES_ALLOW } from '@/lib/constants'
import { createPresignedPost } from '@/api/s3' import { createPresignedPost } from '@/api/s3'
export default { export default {
@ -26,7 +26,7 @@ export default {
size, size,
width, width,
height, height,
userId: me?.id || ANON_USER_ID, userId: me?.id || USER_ID.anon,
paid: false paid: false
} }

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 { ANON_USER_ID, DELETE_USER_ID, RESERVED_MAX_USER_ID, SN_NO_REWARDS_IDS } from '@/lib/constants' import { USER_ID, RESERVED_MAX_USER_ID, SN_NO_REWARDS_IDS } from '@/lib/constants'
import { viewGroup } from './growth' import { viewGroup } from './growth'
import { timeUnitForRange, whenRange } from '@/lib/time' import { timeUnitForRange, whenRange } from '@/lib/time'
import assertApiKeyNotPermitted from './apiKey' import assertApiKeyNotPermitted from './apiKey'
@ -217,7 +217,7 @@ export default {
SELECT name SELECT name
FROM users FROM users
WHERE ( WHERE (
id > ${RESERVED_MAX_USER_ID} OR id IN (${ANON_USER_ID}, ${DELETE_USER_ID}) id > ${RESERVED_MAX_USER_ID} OR id IN (${USER_ID.anon}, ${USER_ID.delete})
) )
AND SIMILARITY(name, ${q}) > 0.1 AND SIMILARITY(name, ${q}) > 0.1
ORDER BY SIMILARITY(name, ${q}) DESC ORDER BY SIMILARITY(name, ${q}) DESC
@ -347,6 +347,26 @@ export default {
} }
} }
if (user.noteItemMentions) {
const [newMentions] = await models.$queryRawUnsafe(`
SELECT EXISTS(
SELECT *
FROM "ItemMention"
JOIN "Item" "Referee" ON "ItemMention"."refereeId" = "Referee".id
JOIN "Item" ON "ItemMention"."referrerId" = "Item".id
${whereClause(
'"ItemMention".created_at > $2',
'"Item"."userId" <> $1',
'"Referee"."userId" = $1',
await filterClause(me, models),
muteClause(me)
)})`, me.id, lastChecked)
if (newMentions.exists) {
foundNotes()
return true
}
}
if (user.noteForwardedSats) { if (user.noteForwardedSats) {
const [newFwdSats] = await models.$queryRawUnsafe(` const [newFwdSats] = await models.$queryRawUnsafe(`
SELECT EXISTS( SELECT EXISTS(
@ -518,7 +538,7 @@ export default {
return await models.$queryRaw` return await models.$queryRaw`
SELECT * SELECT *
FROM users FROM users
WHERE (id > ${RESERVED_MAX_USER_ID} OR id IN (${ANON_USER_ID}, ${DELETE_USER_ID})) WHERE (id > ${RESERVED_MAX_USER_ID} OR id IN (${USER_ID.anon}, ${USER_ID.delete}))
AND SIMILARITY(name, ${q}) > ${Number(similarity) || 0.1} ORDER BY SIMILARITY(name, ${q}) DESC LIMIT ${Number(limit) || 5}` AND SIMILARITY(name, ${q}) > ${Number(similarity) || 0.1} ORDER BY SIMILARITY(name, ${q}) DESC LIMIT ${Number(limit) || 5}`
}, },
userStatsActions: async (parent, { when, from, to }, { me, models }) => { userStatsActions: async (parent, { when, from, to }, { me, models }) => {
@ -700,7 +720,7 @@ export default {
} else if (authType === 'nostr') { } else if (authType === 'nostr') {
user = await models.user.update({ where: { id: me.id }, data: { hideNostr: true, nostrAuthPubkey: null } }) user = await models.user.update({ where: { id: me.id }, data: { hideNostr: true, nostrAuthPubkey: null } })
} else if (authType === 'email') { } else if (authType === 'email') {
user = await models.user.update({ where: { id: me.id }, data: { email: null, emailVerified: null } }) user = await models.user.update({ where: { id: me.id }, data: { email: null, emailVerified: null, emailHash: null } })
} else { } else {
throw new GraphQLError('no such account', { extensions: { code: 'BAD_INPUT' } }) throw new GraphQLError('no such account', { extensions: { code: 'BAD_INPUT' } })
} }

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, ANON_USER_ID, BALANCE_LIMIT_MSATS, INVOICE_RETENTION_DAYS, INV_PENDING_LIMIT, USER_IDS_BALANCE_NO_LIMIT, Wallet } from '@/lib/constants' import { ANON_BALANCE_LIMIT_MSATS, ANON_INV_PENDING_LIMIT, USER_ID, BALANCE_LIMIT_MSATS, INVOICE_RETENTION_DAYS, INV_PENDING_LIMIT, USER_IDS_BALANCE_NO_LIMIT, Wallet } from '@/lib/constants'
import { datePivot } from '@/lib/time' import { datePivot } from '@/lib/time'
import assertGofacYourself from './ofac' import assertGofacYourself from './ofac'
import assertApiKeyNotPermitted from './apiKey' import assertApiKeyNotPermitted from './apiKey'
@ -28,7 +28,7 @@ export async function getInvoice (parent, { id }, { me, models, lnd }) {
throw new GraphQLError('invoice not found', { extensions: { code: 'BAD_INPUT' } }) throw new GraphQLError('invoice not found', { extensions: { code: 'BAD_INPUT' } })
} }
if (inv.user.id === ANON_USER_ID) { if (inv.user.id === USER_ID.anon) {
return inv return inv
} }
if (!me) { if (!me) {
@ -339,7 +339,7 @@ export default {
expirePivot = { seconds: Math.min(expireSecs, 180) } expirePivot = { seconds: Math.min(expireSecs, 180) }
invLimit = ANON_INV_PENDING_LIMIT invLimit = ANON_INV_PENDING_LIMIT
balanceLimit = ANON_BALANCE_LIMIT_MSATS balanceLimit = ANON_BALANCE_LIMIT_MSATS
id = ANON_USER_ID id = USER_ID.anon
} }
const user = await models.user.findUnique({ where: { id } }) const user = await models.user.findUnique({ where: { id } })

View File

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

View File

@ -43,6 +43,12 @@ export default gql`
sortTime: Date! sortTime: Date!
} }
type ItemMention {
id: ID!
item: Item!
sortTime: Date!
}
type Invitification { type Invitification {
id: ID! id: ID!
invite: Invite! invite: Invite!
@ -130,7 +136,7 @@ export default gql`
union Notification = Reply | Votification | Mention union Notification = Reply | Votification | Mention
| Invitification | Earn | JobChanged | InvoicePaid | WithdrawlPaid | Referral | Invitification | Earn | JobChanged | InvoicePaid | WithdrawlPaid | Referral
| Streak | FollowActivity | ForwardedVotification | Revenue | SubStatus | Streak | FollowActivity | ForwardedVotification | Revenue | SubStatus
| TerritoryPost | TerritoryTransfer | Reminder | TerritoryPost | TerritoryTransfer | Reminder | ItemMention
type Notifications { type Notifications {
lastChecked: Date lastChecked: Date

View File

@ -95,6 +95,7 @@ export default gql`
noteItemSats: Boolean! noteItemSats: Boolean!
noteJobIndicator: Boolean! noteJobIndicator: Boolean!
noteMentions: Boolean! noteMentions: Boolean!
noteItemMentions: Boolean!
nsfwMode: Boolean! nsfwMode: Boolean!
tipDefault: Int! tipDefault: Int!
turboTipping: Boolean! turboTipping: Boolean!
@ -161,6 +162,7 @@ export default gql`
noteItemSats: Boolean! noteItemSats: Boolean!
noteJobIndicator: Boolean! noteJobIndicator: Boolean!
noteMentions: Boolean! noteMentions: Boolean!
noteItemMentions: Boolean!
nsfwMode: Boolean! nsfwMode: Boolean!
tipDefault: Int! tipDefault: Int!
turboTipping: Boolean! turboTipping: Boolean!

View File

@ -105,3 +105,8 @@ cointastical,issue,#1191,#134,medium,,,,22k,cointastical@stacker.news,2024-05-28
kravhen,pr,#1198,#1180,good-first-issue,,,required linting,18k,nichro@getalby.com,2024-05-28 kravhen,pr,#1198,#1180,good-first-issue,,,required linting,18k,nichro@getalby.com,2024-05-28
OneOneSeven117,issue,#1198,#1180,good-first-issue,,,required linting,2k,OneOneSeven@stacker.news,2024-05-28 OneOneSeven117,issue,#1198,#1180,good-first-issue,,,required linting,2k,OneOneSeven@stacker.news,2024-05-28
tsmith123,pr,#1207,#837,easy,high,1,,180k,stickymarch60@walletofsatoshi.com,2024-05-31 tsmith123,pr,#1207,#837,easy,high,1,,180k,stickymarch60@walletofsatoshi.com,2024-05-31
SatsAllDay,pr,#1214,#1199,good-first-issue,,,,20k,weareallsatoshi@getalby.com,2024-06-03
SatsAllDay,pr,#1197,#1192,medium,,,,250k,weareallsatoshi@getalby.com,2024-06-03
tsmith123,pr,#1216,#1213,easy,,1,,90k,stickymarch60@walletofsatoshi.com,2024-06-03
tsmith123,pr,#1231,#1230,good-first-issue,,,,20k,stickymarch60@walletofsatoshi.com,2024-06-13
felipebueno,issue,#1231,#1230,good-first-issue,,,,2k,felipebueno@getalby.com,2024-06-13

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
108 SatsAllDay pr #1214 #1199 good-first-issue 20k weareallsatoshi@getalby.com 2024-06-03
109 SatsAllDay pr #1197 #1192 medium 250k weareallsatoshi@getalby.com 2024-06-03
110 tsmith123 pr #1216 #1213 easy 1 90k stickymarch60@walletofsatoshi.com 2024-06-03
111 tsmith123 pr #1231 #1230 good-first-issue 20k stickymarch60@walletofsatoshi.com 2024-06-13
112 felipebueno issue #1231 #1230 good-first-issue 2k felipebueno@getalby.com 2024-06-13

View File

@ -138,3 +138,11 @@ export function WalletSecurityBanner () {
</Alert> </Alert>
) )
} }
export function AuthBanner () {
return (
<Alert className={`${styles.banner} mt-0`} key='info' variant='danger'>
Please add a second auth method to avoid losing access to your account.
</Alert>
)
}

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 { ANON_USER_ID, JIT_INVOICE_TIMEOUT_MS } from '@/lib/constants' import { USER_ID, JIT_INVOICE_TIMEOUT_MS } from '@/lib/constants'
import { HAS_NOTIFICATIONS } from '@/fragments/notifications' import { HAS_NOTIFICATIONS } from '@/fragments/notifications'
import Item from './item' import Item, { ItemSkeleton } from './item'
import { RootProvider } from './root' import { RootProvider } from './root'
import Comment from './comment' import Comment from './comment'
@ -25,7 +25,7 @@ export function ClientNotificationProvider ({ children }) {
const me = useMe() const me = useMe()
// anons don't have access to /notifications // anons don't have access to /notifications
// but we'll store client notifications anyway for simplicity's sake // but we'll store client notifications anyway for simplicity's sake
const storageKey = `client-notifications:${me?.id || ANON_USER_ID}` const storageKey = `client-notifications:${me?.id || USER_ID.anon}`
useEffect(() => { useEffect(() => {
const loaded = loadNotifications(storageKey, client) const loaded = loadNotifications(storageKey, client)
@ -103,7 +103,7 @@ function ClientNotification ({ n, message }) {
<small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small> <small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small>
</small> </small>
{!n.item {!n.item
? null ? <ItemSkeleton />
: n.item.title : n.item.title
? <Item item={n.item} /> ? <Item item={n.item} />
: ( : (

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 { ANON_USER_ID, COMMENT_DEPTH_LIMIT, UNKNOWN_LINK_REL } from '@/lib/constants' import { USER_ID, COMMENT_DEPTH_LIMIT, UNKNOWN_LINK_REL } from '@/lib/constants'
import PayBounty from './pay-bounty' import PayBounty from './pay-bounty'
import BountyIcon from '@/svgs/bounty-bag.svg' import BountyIcon from '@/svgs/bounty-bag.svg'
import ActionTooltip from './action-tooltip' import ActionTooltip from './action-tooltip'
@ -25,6 +25,7 @@ import Skull from '@/svgs/death-skull.svg'
import { commentSubTreeRootId } from '@/lib/item' import { commentSubTreeRootId } from '@/lib/item'
import Pin from '@/svgs/pushpin-fill.svg' import Pin from '@/svgs/pushpin-fill.svg'
import LinkToContext from './link-to-context' import LinkToContext from './link-to-context'
import { ItemContextProvider, useItemContext } from './item'
function Parent ({ item, rootText }) { function Parent ({ item, rootText }) {
const root = useRoot() const root = useRoot()
@ -128,25 +129,22 @@ export default function Comment ({
const bottomedOut = depth === COMMENT_DEPTH_LIMIT const bottomedOut = depth === COMMENT_DEPTH_LIMIT
// Don't show OP badge when anon user comments on anon user posts // Don't show OP badge when anon user comments on anon user posts
const op = root.user.name === item.user.name && Number(item.user.id) !== ANON_USER_ID const op = root.user.name === item.user.name && Number(item.user.id) !== USER_ID.anon
? 'OP' ? 'OP'
: root.forwards?.some(f => f.user.name === item.user.name) && Number(item.user.id) !== ANON_USER_ID : root.forwards?.some(f => f.user.name === item.user.name) && Number(item.user.id) !== USER_ID.anon
? 'fwd' ? 'fwd'
: null : null
const bountyPaid = root.bountyPaidTo?.includes(Number(item.id)) const bountyPaid = root.bountyPaidTo?.includes(Number(item.id))
return ( return (
<ItemContextProvider>
<div <div
ref={ref} className={includeParent ? '' : `${styles.comment} ${collapse === 'yep' ? styles.collapsed : ''}`} ref={ref} className={includeParent ? '' : `${styles.comment} ${collapse === 'yep' ? styles.collapsed : ''}`}
onMouseEnter={() => ref.current.classList.add('outline-new-comment-unset')} onMouseEnter={() => ref.current.classList.add('outline-new-comment-unset')}
onTouchStart={() => ref.current.classList.add('outline-new-comment-unset')} onTouchStart={() => ref.current.classList.add('outline-new-comment-unset')}
> >
<div className={`${itemStyles.item} ${styles.item}`}> <div className={`${itemStyles.item} ${styles.item}`}>
{item.outlawed && !me?.privates?.wildWestMode <ZapIcon item={item} pin={pin} me={me} />
? <Skull className={styles.dontLike} width={24} height={24} />
: item.meDontLikeSats > item.meSats
? <DownZap width={24} height={24} className={styles.dontLike} item={item} />
: pin ? <Pin width={22} height={22} className={styles.pin} /> : <UpVote item={item} className={styles.upvote} />}
<div className={`${itemStyles.hunk} ${styles.hunk}`}> <div className={`${itemStyles.hunk} ${styles.hunk}`}>
<div className='d-flex align-items-center'> <div className='d-flex align-items-center'>
{item.user?.meMute && !includeParent && collapse === 'yep' {item.user?.meMute && !includeParent && collapse === 'yep'
@ -244,9 +242,24 @@ export default function Comment ({
) )
)} )}
</div> </div>
</ItemContextProvider>
) )
} }
function ZapIcon ({ item, pin }) {
const me = useMe()
const { pendingSats, pendingDownSats } = useItemContext()
const meSats = item.meSats + pendingSats
const downSats = item.meDontLikeSats + pendingDownSats
return item.outlawed && !me?.privates?.wildWestMode
? <Skull className={styles.dontLike} width={24} height={24} />
: downSats > meSats
? <DownZap width={24} height={24} className={styles.dontLike} item={item} />
: pin ? <Pin width={22} height={22} className={styles.pin} /> : <UpVote item={item} className={styles.upvote} />
}
export function CommentSkeleton ({ skeletonChildren }) { export function CommentSkeleton ({ skeletonChildren }) {
return ( return (
<div className={styles.comment}> <div className={styles.comment}>

View File

@ -6,10 +6,12 @@ import Navbar from 'react-bootstrap/Navbar'
import { numWithUnits } from '@/lib/format' import { numWithUnits } from '@/lib/format'
import { defaultCommentSort } from '@/lib/item' import { defaultCommentSort } from '@/lib/item'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { ItemContextProvider, useItemContext } from './item'
export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats }) { export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats }) {
const router = useRouter() const router = useRouter()
const sort = router.query.sort || defaultCommentSort(pinned, bio, parentCreatedAt) const sort = router.query.sort || defaultCommentSort(pinned, bio, parentCreatedAt)
const { pendingCommentSats } = useItemContext()
const getHandleClick = sort => { const getHandleClick = sort => {
return () => { return () => {
@ -24,7 +26,7 @@ export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, comm
activeKey={sort} activeKey={sort}
> >
<Nav.Item className='text-muted'> <Nav.Item className='text-muted'>
{numWithUnits(commentSats)} {numWithUnits(commentSats + pendingCommentSats)}
</Nav.Item> </Nav.Item>
<div className='ms-auto d-flex'> <div className='ms-auto d-flex'>
<Nav.Item> <Nav.Item>
@ -66,7 +68,7 @@ export default function Comments ({ parentId, pinned, bio, parentCreatedAt, comm
const pins = comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position) const pins = comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position)
return ( return (
<> <ItemContextProvider>
{comments?.length > 0 {comments?.length > 0
? <CommentsHeader ? <CommentsHeader
commentSats={commentSats} parentCreatedAt={parentCreatedAt} commentSats={commentSats} parentCreatedAt={parentCreatedAt}
@ -91,7 +93,7 @@ export default function Comments ({ parentId, pinned, bio, parentCreatedAt, comm
{comments.filter(({ position }) => !position).map(item => ( {comments.filter(({ position }) => !position).map(item => (
<Comment depth={1} key={item.id} item={item} {...props} /> <Comment depth={1} key={item.id} item={item} {...props} />
))} ))}
</> </ItemContextProvider>
) )
} }

View File

@ -4,18 +4,24 @@ import { useToast } from './toast'
import ItemAct from './item-act' import ItemAct from './item-act'
import AccordianItem from './accordian-item' import AccordianItem from './accordian-item'
import Flag from '@/svgs/flag-fill.svg' import Flag from '@/svgs/flag-fill.svg'
import { useMemo } from 'react' import { useCallback, useMemo } from 'react'
import getColor from '@/lib/rainbow' import getColor from '@/lib/rainbow'
import { gql, useMutation } from '@apollo/client' import { gql, useMutation } from '@apollo/client'
import { useItemContext } from './item'
import { useLightning } from './lightning'
export function DownZap ({ item, ...props }) { export function DownZap ({ item, ...props }) {
const { pendingDownSats } = useItemContext()
const { meDontLikeSats } = item const { meDontLikeSats } = item
const style = useMemo(() => (meDontLikeSats
const downSats = meDontLikeSats + pendingDownSats
const style = useMemo(() => (downSats
? { ? {
fill: getColor(meDontLikeSats), fill: getColor(downSats),
filter: `drop-shadow(0 0 6px ${getColor(meDontLikeSats)}90)` filter: `drop-shadow(0 0 6px ${getColor(downSats)}90)`
} }
: undefined), [meDontLikeSats]) : undefined), [downSats])
return ( return (
<DownZapper item={item} As={({ ...oprops }) => <Flag {...props} {...oprops} style={style} />} /> <DownZapper item={item} As={({ ...oprops }) => <Flag {...props} {...oprops} style={style} />} />
) )
@ -24,6 +30,17 @@ export function DownZap ({ item, ...props }) {
function DownZapper ({ item, As, children }) { function DownZapper ({ item, As, children }) {
const toaster = useToast() const toaster = useToast()
const showModal = useShowModal() const showModal = useShowModal()
const strike = useLightning()
const { setPendingDownSats } = useItemContext()
const optimisticUpdate = useCallback((sats, { onClose } = {}) => {
setPendingDownSats(pendingSats => pendingSats + sats)
strike()
onClose?.()
return () => {
setPendingDownSats(pendingSats => pendingSats - sats)
}
}, [])
return ( return (
<As <As
@ -31,7 +48,7 @@ function DownZapper ({ item, As, children }) {
try { try {
showModal(onClose => showModal(onClose =>
<ItemAct <ItemAct
onClose={onClose} item={item} down onClose={onClose} item={item} down optimisticUpdate={optimisticUpdate}
> >
<AccordianItem <AccordianItem
header='what is a downzap?' body={ header='what is a downzap?' body={

View File

@ -33,7 +33,6 @@ import EyeClose from '@/svgs/eye-close-line.svg'
import Info from './info' import Info from './info'
import { InvoiceCanceledError, usePayment } from './payment' import { InvoiceCanceledError, usePayment } from './payment'
import { useMe } from './me' import { useMe } from './me'
import { optimisticUpdate } from '@/lib/apollo'
import { useClientNotifications } from './client-notifications' import { useClientNotifications } from './client-notifications'
import { ActCanceledError } from './item-act' import { ActCanceledError } from './item-act'
@ -121,7 +120,7 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
const imageUploadRef = useRef(null) const imageUploadRef = useRef(null)
const previousTab = useRef(tab) const previousTab = useRef(tab)
const { merge, setDisabled: setSubmitDisabled } = useFeeButton() const { merge, setDisabled: setSubmitDisabled } = useFeeButton()
const toaster = useToast()
const [updateImageFeesInfo] = useLazyQuery(gql` const [updateImageFeesInfo] = useLazyQuery(gql`
query imageFeesInfo($s3Keys: [Int]!) { query imageFeesInfo($s3Keys: [Int]!) {
imageFeesInfo(s3Keys: $s3Keys) { imageFeesInfo(s3Keys: $s3Keys) {
@ -135,7 +134,6 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
nextFetchPolicy: 'no-cache', nextFetchPolicy: 'no-cache',
onError: (err) => { onError: (err) => {
console.error(err) console.error(err)
toaster.danger(`unabled to get image fees: ${err.message || err.toString?.()}`)
}, },
onCompleted: ({ imageFeesInfo }) => { onCompleted: ({ imageFeesInfo }) => {
merge({ merge({
@ -805,7 +803,7 @@ const StorageKeyPrefixContext = createContext()
export function Form ({ export function Form ({
initial, schema, onSubmit, children, initialError, validateImmediately, initial, schema, onSubmit, children, initialError, validateImmediately,
storageKeyPrefix, validateOnChange = true, prepaid, requireSession, innerRef, storageKeyPrefix, validateOnChange = true, prepaid, requireSession, innerRef,
optimisticUpdate: optimisticUpdateArgs, clientNotification, signal, ...props optimisticUpdate, clientNotification, signal, ...props
}) { }) {
const toaster = useToast() const toaster = useToast()
const initialErrorToasted = useRef(false) const initialErrorToasted = useRef(false)
@ -845,9 +843,7 @@ export function Form ({
throw new SessionRequiredError() throw new SessionRequiredError()
} }
if (optimisticUpdateArgs) { revert = optimisticUpdate?.(variables)
revert = optimisticUpdate(optimisticUpdateArgs(variables))
}
await signal?.pause({ me, amount }) await signal?.pause({ me, amount })
@ -866,8 +862,6 @@ export function Form ({
clearLocalStorage(values) clearLocalStorage(values)
} }
} catch (err) { } catch (err) {
revert?.()
if (err instanceof InvoiceCanceledError || err instanceof ActCanceledError) { if (err instanceof InvoiceCanceledError || err instanceof ActCanceledError) {
return return
} }
@ -881,6 +875,7 @@ export function Form ({
cancel?.() cancel?.()
} finally { } finally {
revert?.()
// if we reach this line, the submit either failed or was successful so we can remove the pending notification. // if we reach this line, the submit either failed or was successful so we can remove the pending notification.
// if we don't reach this line, the page was probably reloaded and we can use the pending notification // if we don't reach this line, the page was probably reloaded and we can use the pending notification
// stored in localStorage to handle this case. // stored in localStorage to handle this case.

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 { AD_USER_ID, ANON_USER_ID } from '@/lib/constants' import { USER_ID } from '@/lib/constants'
export default function Hat ({ user, badge, className = 'ms-1', height = 16, width = 16 }) { export default function Hat ({ user, badge, className = 'ms-1', height = 16, width = 16 }) {
if (!user || Number(user.id) === AD_USER_ID) return null if (!user || Number(user.id) === USER_ID.ad) return null
if (Number(user.id) === ANON_USER_ID) { if (Number(user.id) === USER_ID.anon) {
return ( return (
<HatTooltip overlayText='anonymous'> <HatTooltip overlayText='anonymous'>
{badge {badge

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,11 +100,10 @@ export const actUpdate = ({ me, onUpdate }) => (cache, args) => {
onUpdate?.(cache, args) onUpdate?.(cache, args)
} }
export default function ItemAct ({ onClose, item, down, children, abortSignal }) { export default function ItemAct ({ onClose, item, down, children, abortSignal, optimisticUpdate }) {
const inputRef = useRef(null) const inputRef = useRef(null)
const me = useMe() const me = useMe()
const [oValue, setOValue] = useState() const [oValue, setOValue] = useState()
const strike = useLightning()
useEffect(() => { useEffect(() => {
inputRef.current?.focus() inputRef.current?.focus()
@ -120,23 +119,12 @@ export default function ItemAct ({ onClose, item, down, children, abortSignal })
act: down ? 'DONT_LIKE_THIS' : 'TIP', act: down ? 'DONT_LIKE_THIS' : 'TIP',
hash, hash,
hmac hmac
} },
update: actUpdate({ me })
}) })
if (!me) setItemMeAnonSats({ id: item.id, amount }) if (!me) setItemMeAnonSats({ id: item.id, amount })
addCustomTip(Number(amount)) addCustomTip(Number(amount))
}, [me, act, down, item.id, strike]) }, [me, act, down, item.id])
const optimisticUpdate = useCallback(({ amount }) => {
const variables = {
id: item.id,
sats: Number(amount),
act: down ? 'DONT_LIKE_THIS' : 'TIP'
}
const optimisticResponse = { act: { ...variables, path: item.path } }
strike()
onClose()
return { mutation: ACT_MUTATION, variables, optimisticResponse, update: actUpdate({ me }) }
}, [item.id, down, !!me, strike])
return ( return (
<ClientNotifyProvider additionalProps={{ itemId: item.id }}> <ClientNotifyProvider additionalProps={{ itemId: item.id }}>
@ -147,7 +135,7 @@ export default function ItemAct ({ onClose, item, down, children, abortSignal })
}} }}
schema={amountSchema} schema={amountSchema}
prepaid prepaid
optimisticUpdate={optimisticUpdate} optimisticUpdate={({ amount }) => optimisticUpdate(amount, { onClose })}
onSubmit={onSubmit} onSubmit={onSubmit}
clientNotification={ClientNotification.Zap} clientNotification={ClientNotification.Zap}
signal={abortSignal} signal={abortSignal}
@ -252,9 +240,10 @@ export function useZap () {
const toaster = useToast() const toaster = useToast()
const strike = useLightning() const strike = useLightning()
const payment = usePayment() const payment = usePayment()
const { pendingSats } = useItemContext()
return useCallback(async ({ item, mem, abortSignal }) => { return useCallback(async ({ item, abortSignal, optimisticUpdate }) => {
const meSats = (item?.meSats || 0) const meSats = (item?.meSats || 0) + pendingSats
// add current sats to next tip since idempotent zaps use desired total zap not difference // add current sats to next tip since idempotent zaps use desired total zap not difference
const sats = meSats + nextTip(meSats, { ...me?.privates }) const sats = meSats + nextTip(meSats, { ...me?.privates })
@ -262,12 +251,11 @@ export function useZap () {
const variables = { id: item.id, sats, act: 'TIP' } const variables = { id: item.id, sats, act: 'TIP' }
const notifyProps = { itemId: item.id, sats: satsDelta } const notifyProps = { itemId: item.id, sats: satsDelta }
const optimisticResponse = { act: { path: item.path, ...variables } } // const optimisticResponse = { act: { path: item.path, ...variables } }
let revert, cancel, nid let revert, cancel, nid
try { try {
revert = optimisticUpdate({ mutation: ZAP_MUTATION, variables, optimisticResponse, update }) revert = optimisticUpdate?.(satsDelta)
strike()
await abortSignal.pause({ me, amount: satsDelta }) await abortSignal.pause({ me, amount: satsDelta })
@ -277,10 +265,16 @@ export function useZap () {
let hash, hmac; let hash, hmac;
[{ hash, hmac }, cancel] = await payment.request(satsDelta) [{ hash, hmac }, cancel] = await payment.request(satsDelta)
await zap({ variables: { ...variables, hash, hmac } })
await zap({
variables: { ...variables, hash, hmac },
update: (...args) => {
revert?.()
update(...args)
}
})
} catch (error) { } catch (error) {
revert?.() revert?.()
if (error instanceof InvoiceCanceledError || error instanceof ActCanceledError) { if (error instanceof InvoiceCanceledError || error instanceof ActCanceledError) {
return return
} }
@ -296,7 +290,7 @@ export function useZap () {
} finally { } finally {
if (nid) unnotify(nid) if (nid) unnotify(nid)
} }
}, [me?.id, strike, payment, notify, unnotify]) }, [me?.id, strike, payment, notify, unnotify, pendingSats])
} }
export class ActCanceledError extends Error { export class ActCanceledError extends Error {

View File

@ -15,13 +15,14 @@ import BookmarkDropdownItem from './bookmark'
import SubscribeDropdownItem from './subscribe' import SubscribeDropdownItem from './subscribe'
import { CopyLinkDropdownItem, CrosspostDropdownItem } from './share' import { CopyLinkDropdownItem, CrosspostDropdownItem } from './share'
import Hat from './hat' import Hat from './hat'
import { AD_USER_ID } from '@/lib/constants' import { USER_ID } from '@/lib/constants'
import ActionDropdown from './action-dropdown' import ActionDropdown from './action-dropdown'
import MuteDropdownItem from './mute' import MuteDropdownItem from './mute'
import { DropdownItemUpVote } from './upvote' import { DropdownItemUpVote } from './upvote'
import { useRoot } from './root' import { useRoot } from './root'
import { MuteSubDropdownItem, PinSubDropdownItem } from './territory-header' import { MuteSubDropdownItem, PinSubDropdownItem } from './territory-header'
import UserPopover from './user-popover' import UserPopover from './user-popover'
import { useItemContext } from './item'
export default function ItemInfo ({ export default function ItemInfo ({
item, full, commentsText = 'comments', item, full, commentsText = 'comments',
@ -36,6 +37,7 @@ export default function ItemInfo ({
const [hasNewComments, setHasNewComments] = useState(false) const [hasNewComments, setHasNewComments] = useState(false)
const [meTotalSats, setMeTotalSats] = useState(0) const [meTotalSats, setMeTotalSats] = useState(0)
const root = useRoot() const root = useRoot()
const { pendingSats, pendingCommentSats, pendingDownSats } = useItemContext()
const sub = item?.sub || root?.sub const sub = item?.sub || root?.sub
useEffect(() => { useEffect(() => {
@ -45,8 +47,8 @@ export default function ItemInfo ({
}, [item]) }, [item])
useEffect(() => { useEffect(() => {
if (item) setMeTotalSats((item.meSats || 0) + (item.meAnonSats || 0)) if (item) setMeTotalSats((item.meSats || 0) + (item.meAnonSats || 0) + (pendingSats))
}, [item?.meSats, item?.meAnonSats]) }, [item?.meSats, item?.meAnonSats, pendingSats])
// territory founders can pin any post in their territory // territory founders can pin any post in their territory
// and OPs can pin any root reply in their post // and OPs can pin any root reply in their post
@ -56,9 +58,11 @@ export default function ItemInfo ({
const rootReply = item.path.split('.').length === 2 const rootReply = item.path.split('.').length === 2
const canPin = (isPost && mySub) || (myPost && rootReply) const canPin = (isPost && mySub) || (myPost && rootReply)
const downSats = item.meDontLikeSats + pendingDownSats
return ( return (
<div className={className || `${styles.other}`}> <div className={className || `${styles.other}`}>
{!(item.position && (pinnable || !item.subName)) && !(!item.parentId && Number(item.user?.id) === AD_USER_ID) && {!(item.position && (pinnable || !item.subName)) && !(!item.parentId && Number(item.user?.id) === USER_ID.ad) &&
<> <>
<span title={`from ${numWithUnits(item.upvotes, { <span title={`from ${numWithUnits(item.upvotes, {
abbreviate: false, abbreviate: false,
@ -66,11 +70,11 @@ export default function ItemInfo ({
unitPlural: 'stackers' unitPlural: 'stackers'
})} ${item.mine })} ${item.mine
? `\\ ${numWithUnits(item.meSats, { abbreviate: false })} to post` ? `\\ ${numWithUnits(item.meSats, { abbreviate: false })} to post`
: `(${numWithUnits(meTotalSats, { abbreviate: false })}${item.meDontLikeSats : `(${numWithUnits(meTotalSats, { abbreviate: false })}${downSats
? ` & ${numWithUnits(item.meDontLikeSats, { abbreviate: false, unitSingular: 'downsat', unitPlural: 'downsats' })}` ? ` & ${numWithUnits(downSats, { abbreviate: false, unitSingular: 'downsat', unitPlural: 'downsats' })}`
: ''} from me)`} `} : ''} from me)`} `}
> >
{numWithUnits(item.sats)} {numWithUnits(item.sats + pendingSats)}
</span> </span>
<span> \ </span> <span> \ </span>
</>} </>}
@ -88,7 +92,7 @@ export default function ItemInfo ({
`/items/${item.id}?commentsViewedAt=${viewedAt}`, `/items/${item.id}?commentsViewedAt=${viewedAt}`,
`/items/${item.id}`) `/items/${item.id}`)
} }
}} title={numWithUnits(item.commentSats)} className='text-reset position-relative' }} title={numWithUnits(item.commentSats + pendingCommentSats)} className='text-reset position-relative'
> >
{numWithUnits(item.ncomments, { {numWithUnits(item.ncomments, {
abbreviate: false, abbreviate: false,
@ -177,7 +181,7 @@ export default function ItemInfo ({
<CrosspostDropdownItem item={item} />} <CrosspostDropdownItem item={item} />}
{me && !item.position && {me && !item.position &&
!item.mine && !item.deletedAt && !item.mine && !item.deletedAt &&
(item.meDontLikeSats > meTotalSats (downSats > meTotalSats
? <DropdownItemUpVote item={item} /> ? <DropdownItemUpVote item={item} />
: <DontLikeThisDropdownItem item={item} />)} : <DontLikeThisDropdownItem item={item} />)}
{me && sub && !item.mine && !item.outlawed && Number(me.id) === Number(sub.userId) && sub.moderated && {me && sub && !item.mine && !item.outlawed && Number(me.id) === Number(sub.userId) && sub.moderated &&

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 { useRef } from 'react' import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react'
import { AD_USER_ID, UNKNOWN_LINK_REL } from '@/lib/constants' import { USER_ID, UNKNOWN_LINK_REL } from '@/lib/constants'
import Pin from '@/svgs/pushpin-fill.svg' import Pin from '@/svgs/pushpin-fill.svg'
import reactStringReplace from 'react-string-replace' import reactStringReplace from 'react-string-replace'
import PollIcon from '@/svgs/bar-chart-horizontal-fill.svg' import PollIcon from '@/svgs/bar-chart-horizontal-fill.svg'
@ -45,6 +45,52 @@ export function SearchTitle ({ title }) {
}) })
} }
const ItemContext = createContext({
pendingSats: 0,
setPendingSats: undefined,
pendingVote: undefined,
setPendingVote: undefined,
pendingDownSats: 0,
setPendingDownSats: undefined
})
export const ItemContextProvider = ({ children }) => {
const parentCtx = useItemContext()
const [pendingSats, innerSetPendingSats] = useState(0)
const [pendingCommentSats, innerSetPendingCommentSats] = useState(0)
const [pendingVote, setPendingVote] = useState()
const [pendingDownSats, setPendingDownSats] = useState(0)
// cascade comment sats up to root context
const setPendingSats = useCallback((sats) => {
innerSetPendingSats(sats)
parentCtx?.setPendingCommentSats?.(sats)
}, [parentCtx?.setPendingCommentSats])
const setPendingCommentSats = useCallback((sats) => {
innerSetPendingCommentSats(sats)
parentCtx?.setPendingCommentSats?.(sats)
}, [parentCtx?.setPendingCommentSats])
const value = useMemo(() =>
({
pendingSats,
setPendingSats,
pendingCommentSats,
setPendingCommentSats,
pendingVote,
setPendingVote,
pendingDownSats,
setPendingDownSats
}),
[pendingSats, setPendingSats, pendingCommentSats, setPendingCommentSats, pendingVote, setPendingVote, pendingDownSats, setPendingDownSats])
return <ItemContext.Provider value={value}>{children}</ItemContext.Provider>
}
export const useItemContext = () => {
return useContext(ItemContext)
}
export default function Item ({ item, rank, belowTitle, right, full, children, siblingComments, onQuoteReply, pinnable }) { export default function Item ({ item, rank, belowTitle, right, full, children, siblingComments, onQuoteReply, pinnable }) {
const titleRef = useRef() const titleRef = useRef()
const router = useRouter() const router = useRouter()
@ -52,7 +98,7 @@ export default function Item ({ item, rank, belowTitle, right, full, children, s
const image = item.url && item.url.startsWith(process.env.NEXT_PUBLIC_IMGPROXY_URL) const image = item.url && item.url.startsWith(process.env.NEXT_PUBLIC_IMGPROXY_URL)
return ( return (
<> <ItemContextProvider>
{rank {rank
? ( ? (
<div className={styles.rank}> <div className={styles.rank}>
@ -60,13 +106,7 @@ export default function Item ({ item, rank, belowTitle, right, full, children, s
</div>) </div>)
: <div />} : <div />}
<div className={`${styles.item} ${siblingComments ? 'pt-3' : ''}`}> <div className={`${styles.item} ${siblingComments ? 'pt-3' : ''}`}>
{item.position && (pinnable || !item.subName) <ZapIcon item={item} pinnable={pinnable} />
? <Pin width={24} height={24} className={styles.pin} />
: item.meDontLikeSats > item.meSats
? <DownZap width={24} height={24} className={styles.dontLike} item={item} />
: Number(item.user?.id) === AD_USER_ID
? <AdIcon width={24} height={24} className={styles.ad} />
: <UpVote item={item} className={styles.upvote} />}
<div className={styles.hunk}> <div className={styles.hunk}>
<div className={`${styles.main} flex-wrap`}> <div className={`${styles.main} flex-wrap`}>
<Link <Link
@ -99,7 +139,7 @@ export default function Item ({ item, rank, belowTitle, right, full, children, s
full={full} item={item} full={full} item={item}
onQuoteReply={onQuoteReply} onQuoteReply={onQuoteReply}
pinnable={pinnable} pinnable={pinnable}
extraBadges={Number(item?.user?.id) === AD_USER_ID && <Badge className={styles.newComment} bg={null}>AD</Badge>} extraBadges={Number(item?.user?.id) === USER_ID.ad && <Badge className={styles.newComment} bg={null}>AD</Badge>}
/> />
{belowTitle} {belowTitle}
</div> </div>
@ -110,7 +150,7 @@ export default function Item ({ item, rank, belowTitle, right, full, children, s
{children} {children}
</div> </div>
)} )}
</> </ItemContextProvider>
) )
} }
@ -130,7 +170,7 @@ export function ItemSummary ({ item }) {
item={item} item={item}
showUser={false} showUser={false}
showActionDropdown={false} showActionDropdown={false}
extraBadges={item.title && Number(item?.user?.id) === AD_USER_ID && <Badge className={styles.newComment} bg={null}>AD</Badge>} extraBadges={item.title && Number(item?.user?.id) === USER_ID.ad && <Badge className={styles.newComment} bg={null}>AD</Badge>}
/> />
) )
@ -188,6 +228,21 @@ export function ItemSkeleton ({ rank, children, showUpvote = true }) {
) )
} }
function ZapIcon ({ item, pinnable }) {
const { pendingSats, pendingDownSats } = useItemContext()
const meSats = item.meSats + pendingSats
const downSats = item.meDontLikeSats + pendingDownSats
return item.position && (pinnable || !item.subName)
? <Pin width={24} height={24} className={styles.pin} />
: downSats > meSats
? <DownZap width={24} height={24} className={styles.dontLike} item={item} />
: Number(item.user?.id) === USER_ID.ad
? <AdIcon width={24} height={24} className={styles.ad} />
: <UpVote item={item} className={styles.upvote} />
}
function PollIndicator ({ item }) { function PollIndicator ({ item }) {
const hasExpiration = !!item.pollExpiresAt const hasExpiration = !!item.pollExpiresAt
const timeRemaining = timeLeft(new Date(item.pollExpiresAt)) const timeRemaining = timeLeft(new Date(item.pollExpiresAt))

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 { ANON_USER_ID, BALANCE_LIMIT_MSATS, Wallet } from '../../lib/constants' import { USER_ID, BALANCE_LIMIT_MSATS, Wallet } from '../../lib/constants'
import Head from 'next/head' import Head from 'next/head'
import NoteIcon from '../../svgs/notification-4-fill.svg' import NoteIcon from '../../svgs/notification-4-fill.svg'
import { useMe } from '../me' import { useMe } from '../me'
@ -307,7 +307,7 @@ export function AnonDropdown ({ path }) {
<Dropdown className={styles.dropdown} align='end'> <Dropdown className={styles.dropdown} align='end'>
<Dropdown.Toggle className='nav-link nav-item' id='profile' variant='custom'> <Dropdown.Toggle className='nav-link nav-item' id='profile' variant='custom'>
<Nav.Link eventKey='anon' as='span' className='p-0 fw-normal'> <Nav.Link eventKey='anon' as='span' className='p-0 fw-normal'>
@anon<Hat user={{ id: ANON_USER_ID }} /> @anon<Hat user={{ id: USER_ID.anon }} />
</Nav.Link> </Nav.Link>
</Dropdown.Toggle> </Dropdown.Toggle>
<Dropdown.Menu className='p-3'> <Dropdown.Menu className='p-3'>

View File

@ -36,13 +36,8 @@ import { ITEM_FULL } from '@/fragments/items'
function Notification ({ n, fresh }) { function Notification ({ n, fresh }) {
const type = n.__typename const type = n.__typename
// we need to resolve item id to item to show item for client notifications
const { data } = useQuery(ITEM_FULL, { variables: { id: n.itemId }, skip: !n.itemId })
const item = data?.item
const itemN = { item, ...n }
return ( return (
<NotificationLayout nid={nid(n)} {...defaultOnClick(itemN)} fresh={fresh}> <NotificationLayout nid={nid(n)} {...defaultOnClick(n)} fresh={fresh}>
{ {
(type === 'Earn' && <EarnNotification n={n} />) || (type === 'Earn' && <EarnNotification n={n} />) ||
(type === 'Revenue' && <RevenueNotification n={n} />) || (type === 'Revenue' && <RevenueNotification n={n} />) ||
@ -54,6 +49,7 @@ function Notification ({ n, fresh }) {
(type === 'Votification' && <Votification n={n} />) || (type === 'Votification' && <Votification n={n} />) ||
(type === 'ForwardedVotification' && <ForwardedVotification n={n} />) || (type === 'ForwardedVotification' && <ForwardedVotification n={n} />) ||
(type === 'Mention' && <Mention n={n} />) || (type === 'Mention' && <Mention n={n} />) ||
(type === 'ItemMention' && <ItemMention n={n} />) ||
(type === 'JobChanged' && <JobChanged n={n} />) || (type === 'JobChanged' && <JobChanged n={n} />) ||
(type === 'Reply' && <Reply n={n} />) || (type === 'Reply' && <Reply n={n} />) ||
(type === 'SubStatus' && <SubStatus n={n} />) || (type === 'SubStatus' && <SubStatus n={n} />) ||
@ -61,15 +57,26 @@ function Notification ({ n, fresh }) {
(type === 'TerritoryPost' && <TerritoryPost n={n} />) || (type === 'TerritoryPost' && <TerritoryPost n={n} />) ||
(type === 'TerritoryTransfer' && <TerritoryTransfer n={n} />) || (type === 'TerritoryTransfer' && <TerritoryTransfer n={n} />) ||
(type === 'Reminder' && <Reminder n={n} />) || (type === 'Reminder' && <Reminder n={n} />) ||
([ClientTypes.Zap.ERROR, ClientTypes.Zap.PENDING].includes(type) && <ClientZap n={itemN} />) || <ClientNotification n={n} />
([ClientTypes.Reply.ERROR, ClientTypes.Reply.PENDING].includes(type) && <ClientReply n={itemN} />) ||
([ClientTypes.Bounty.ERROR, ClientTypes.Bounty.PENDING].includes(type) && <ClientBounty n={itemN} />) ||
([ClientTypes.PollVote.ERROR, ClientTypes.PollVote.PENDING].includes(type) && <ClientPollVote n={itemN} />)
} }
</NotificationLayout> </NotificationLayout>
) )
} }
function ClientNotification ({ n }) {
// we need to resolve item id to item to show item for client notifications
const { data } = useQuery(ITEM_FULL, { variables: { id: n.itemId }, skip: !n.itemId })
const item = data?.item
const itemN = { item, ...n }
return (
([ClientTypes.Zap.ERROR, ClientTypes.Zap.PENDING].includes(n.__typename) && <ClientZap n={itemN} />) ||
([ClientTypes.Reply.ERROR, ClientTypes.Reply.PENDING].includes(n.__typename) && <ClientReply n={itemN} />) ||
([ClientTypes.Bounty.ERROR, ClientTypes.Bounty.PENDING].includes(n.__typename) && <ClientBounty n={itemN} />) ||
([ClientTypes.PollVote.ERROR, ClientTypes.PollVote.PENDING].includes(n.__typename) && <ClientPollVote n={itemN} />)
)
}
function NotificationLayout ({ children, nid, href, as, fresh }) { function NotificationLayout ({ children, nid, href, as, fresh }) {
const router = useRouter() const router = useRouter()
if (!href) return <div className={fresh ? styles.fresh : ''}>{children}</div> if (!href) return <div className={fresh ? styles.fresh : ''}>{children}</div>
@ -391,6 +398,26 @@ function Mention ({ n }) {
) )
} }
function ItemMention ({ n }) {
return (
<>
<small className='fw-bold text-info ms-2'>
your item was mentioned in
</small>
<div>
{n.item?.title
? <Item item={n.item} />
: (
<div className='pb-2'>
<RootProvider root={n.item.root}>
<Comment item={n.item} noReply includeParent rootText='replying on:' clickToContext />
</RootProvider>
</div>)}
</div>
</>
)
}
function JobChanged ({ n }) { function JobChanged ({ n }) {
return ( return (
<> <>

View File

@ -6,9 +6,8 @@ import { useMe } from './me'
import { numWithUnits } from '@/lib/format' import { numWithUnits } from '@/lib/format'
import { useShowModal } from './modal' import { useShowModal } from './modal'
import { useRoot } from './root' import { useRoot } from './root'
import { useAct, actUpdate, ACT_MUTATION } from './item-act' import { useAct, actUpdate } from './item-act'
import { InvoiceCanceledError, usePayment } from './payment' import { InvoiceCanceledError, usePayment } from './payment'
import { optimisticUpdate } from '@/lib/apollo'
import { useLightning } from './lightning' import { useLightning } from './lightning'
import { useToast } from './toast' import { useToast } from './toast'
import { Types as ClientNotification, useClientNotifications } from './client-notifications' import { Types as ClientNotification, useClientNotifications } from './client-notifications'
@ -45,30 +44,21 @@ export default function PayBounty ({ children, item }) {
const notifyProps = { itemId: item.id, sats } const notifyProps = { itemId: item.id, sats }
const optimisticResponse = { act: { ...variables, path: item.path } } const optimisticResponse = { act: { ...variables, path: item.path } }
let revert, cancel, nid let cancel, nid
try { try {
revert = optimisticUpdate({
mutation: ACT_MUTATION,
variables,
optimisticResponse,
update: actUpdate({ me, onUpdate: onUpdate(onComplete) })
})
if (me) { if (me) {
nid = notify(ClientNotification.Bounty.PENDING, notifyProps) nid = notify(ClientNotification.Bounty.PENDING, notifyProps)
} }
let hash, hmac; let hash, hmac;
[{ hash, hmac }, cancel] = await payment.request(sats) [{ hash, hmac }, cancel] = await payment.request(sats)
await act({ await act({
variables: { hash, hmac, ...variables }, variables: { hash, hmac, ...variables },
optimisticResponse: { optimisticResponse,
act: variables update: actUpdate({ me, onUpdate: onUpdate(onComplete) })
}
}) })
} catch (error) { } catch (error) {
revert?.()
if (error instanceof InvoiceCanceledError) { if (error instanceof InvoiceCanceledError) {
return return
} }

View File

@ -4,14 +4,13 @@ import { fixedDecimal, numWithUnits } from '@/lib/format'
import { timeLeft } from '@/lib/time' import { timeLeft } from '@/lib/time'
import { useMe } from './me' import { useMe } from './me'
import styles from './poll.module.css' import styles from './poll.module.css'
import Check from '@/svgs/checkbox-circle-fill.svg'
import { signIn } from 'next-auth/react' import { signIn } from 'next-auth/react'
import ActionTooltip from './action-tooltip' import ActionTooltip from './action-tooltip'
import { POLL_COST } from '@/lib/constants' import { POLL_COST } from '@/lib/constants'
import { InvoiceCanceledError, usePayment } from './payment' import { InvoiceCanceledError, usePayment } from './payment'
import { optimisticUpdate } from '@/lib/apollo'
import { useToast } from './toast' import { useToast } from './toast'
import { Types as ClientNotification, useClientNotifications } from './client-notifications' import { Types as ClientNotification, useClientNotifications } from './client-notifications'
import { useItemContext } from './item'
export default function Poll ({ item }) { export default function Poll ({ item }) {
const me = useMe() const me = useMe()
@ -22,6 +21,7 @@ export default function Poll ({ item }) {
const [pollVote] = useMutation(POLL_VOTE_MUTATION) const [pollVote] = useMutation(POLL_VOTE_MUTATION)
const toaster = useToast() const toaster = useToast()
const { notify, unnotify } = useClientNotifications() const { notify, unnotify } = useClientNotifications()
const { pendingVote, setPendingVote } = useItemContext()
const update = (cache, { data: { pollVote } }) => { const update = (cache, { data: { pollVote } }) => {
cache.modify({ cache.modify({
@ -40,9 +40,6 @@ export default function Poll ({ item }) {
fields: { fields: {
count (existingCount) { count (existingCount) {
return existingCount + 1 return existingCount + 1
},
meVoted () {
return true
} }
} }
}) })
@ -59,9 +56,9 @@ export default function Poll ({ item }) {
const variables = { id: v.id } const variables = { id: v.id }
const notifyProps = { itemId: item.id } const notifyProps = { itemId: item.id }
const optimisticResponse = { pollVote: v.id } const optimisticResponse = { pollVote: v.id }
let revert, cancel, nid let cancel, nid
try { try {
revert = optimisticUpdate({ mutation: POLL_VOTE_MUTATION, variables, optimisticResponse, update }) setPendingVote(v.id)
if (me) { if (me) {
nid = notify(ClientNotification.PollVote.PENDING, notifyProps) nid = notify(ClientNotification.PollVote.PENDING, notifyProps)
@ -69,10 +66,9 @@ export default function Poll ({ item }) {
let hash, hmac; let hash, hmac;
[{ hash, hmac }, cancel] = await payment.request(item.pollCost || POLL_COST) [{ hash, hmac }, cancel] = await payment.request(item.pollCost || POLL_COST)
await pollVote({ variables: { hash, hmac, ...variables } })
} catch (error) {
revert?.()
await pollVote({ variables: { hash, hmac, ...variables }, optimisticResponse, update })
} catch (error) {
if (error instanceof InvoiceCanceledError) { if (error instanceof InvoiceCanceledError) {
return return
} }
@ -86,6 +82,7 @@ export default function Poll ({ item }) {
cancel?.() cancel?.()
} finally { } finally {
setPendingVote(undefined)
if (nid) unnotify(nid) if (nid) unnotify(nid)
} }
} }
@ -100,7 +97,8 @@ export default function Poll ({ item }) {
const hasExpiration = !!item.pollExpiresAt const hasExpiration = !!item.pollExpiresAt
const timeRemaining = timeLeft(new Date(item.pollExpiresAt)) const timeRemaining = timeLeft(new Date(item.pollExpiresAt))
const mine = item.user.id === me?.id const mine = item.user.id === me?.id
const showPollButton = (!hasExpiration || timeRemaining) && !item.poll.meVoted && !mine const showPollButton = (!hasExpiration || timeRemaining) && !item.poll.meVoted && !mine && !pendingVote
const pollCount = item.poll.count + (pendingVote ? 1 : 0)
return ( return (
<div className={styles.pollBox}> <div className={styles.pollBox}>
{item.poll.options.map(v => {item.poll.options.map(v =>
@ -108,10 +106,12 @@ export default function Poll ({ item }) {
? <PollButton key={v.id} v={v} /> ? <PollButton key={v.id} v={v} />
: <PollResult : <PollResult
key={v.id} v={v} key={v.id} v={v}
progress={item.poll.count ? fixedDecimal(v.count * 100 / item.poll.count, 1) : 0} progress={pollCount
? fixedDecimal((v.count + (pendingVote === v.id ? 1 : 0)) * 100 / pollCount, 1)
: 0}
/>)} />)}
<div className='text-muted mt-1'> <div className='text-muted mt-1'>
{numWithUnits(item.poll.count, { unitSingular: 'vote', unitPlural: 'votes' })} {numWithUnits(pollCount, { unitSingular: 'vote', unitPlural: 'votes' })}
{hasExpiration && ` \\ ${timeRemaining ? `${timeRemaining} left` : 'poll ended'}`} {hasExpiration && ` \\ ${timeRemaining ? `${timeRemaining} left` : 'poll ended'}`}
</div> </div>
</div> </div>
@ -121,7 +121,7 @@ export default function Poll ({ item }) {
function PollResult ({ v, progress }) { function PollResult ({ v, progress }) {
return ( return (
<div className={styles.pollResult}> <div className={styles.pollResult}>
<span className={styles.pollOption}>{v.option}{v.meVoted && <Check className='fill-grey ms-1 align-self-center flex-shrink-0' width={16} height={16} />}</span> <span className={styles.pollOption}>{v.option}</span>
<span className='ms-auto me-2 align-self-center'>{progress}%</span> <span className='ms-auto me-2 align-self-center'>{progress}%</span>
<div className={styles.pollProgress} style={{ width: `${progress}%` }} /> <div className={styles.pollProgress} style={{ width: `${progress}%` }} />
</div> </div>

View File

@ -186,6 +186,7 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
} catch { } catch {
// ignore invalid URLs // ignore invalid URLs
} }
const internalURL = process.env.NEXT_PUBLIC_URL const internalURL = process.env.NEXT_PUBLIC_URL
if (!!text && !/^https?:\/\//.test(text)) { if (!!text && !/^https?:\/\//.test(text)) {
if (props['data-footnote-ref'] || typeof props['data-footnote-backref'] !== 'undefined') { if (props['data-footnote-ref'] || typeof props['data-footnote-backref'] !== 'undefined') {
@ -210,6 +211,19 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
</UserPopover> </UserPopover>
) )
} else if (href.startsWith('/') || url?.origin === internalURL) { } else if (href.startsWith('/') || url?.origin === internalURL) {
try {
const { linkText } = parseInternalLinks(href)
if (linkText) {
return (
<ItemPopover id={linkText.replace('#', '').split('/')[0]}>
<Link href={href}>{text}</Link>
</ItemPopover>
)
}
} catch {
// ignore errors like invalid URLs
}
return ( return (
<Link <Link
id={props.id} id={props.id}
@ -226,7 +240,7 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
} }
try { try {
const linkText = parseInternalLinks(href) const { linkText } = parseInternalLinks(href)
if (linkText) { if (linkText) {
return ( return (
<ItemPopover id={linkText.replace('#', '').split('/')[0]}> <ItemPopover id={linkText.replace('#', '').split('/')[0]}>
@ -244,7 +258,6 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
paddingRight: '15px' paddingRight: '15px'
} }
try {
const { provider, id, meta } = parseEmbedUrl(href) const { provider, id, meta } = parseEmbedUrl(href)
// Youtube video embed // Youtube video embed
if (provider === 'youtube') { if (provider === 'youtube') {
@ -275,9 +288,6 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
</div> </div>
) )
} }
} catch {
// ignore invalid URLs
}
// assume the link is an image which will fallback to link if it's not // assume the link is an image which will fallback to link if it's not
return <Img src={href} rel={rel ?? UNKNOWN_LINK_REL} {...props}>{children}</Img> return <Img src={href} rel={rel ?? UNKNOWN_LINK_REL} {...props}>{children}</Img>

View File

@ -12,6 +12,8 @@ import Popover from 'react-bootstrap/Popover'
import { useShowModal } from './modal' import { useShowModal } from './modal'
import { numWithUnits } from '@/lib/format' import { numWithUnits } from '@/lib/format'
import { Dropdown } from 'react-bootstrap' import { Dropdown } from 'react-bootstrap'
import { useLightning } from './lightning'
import { useItemContext } from './item'
const UpvotePopover = ({ target, show, handleClose }) => { const UpvotePopover = ({ target, show, handleClose }) => {
const me = useMe() const me = useMe()
@ -54,12 +56,23 @@ const TipPopover = ({ target, show, handleClose }) => (
export function DropdownItemUpVote ({ item }) { export function DropdownItemUpVote ({ item }) {
const showModal = useShowModal() const showModal = useShowModal()
const { setPendingSats } = useItemContext()
const strike = useLightning()
const optimisticUpdate = useCallback((sats, { onClose } = {}) => {
setPendingSats(pendingSats => pendingSats + sats)
strike()
onClose?.()
return () => {
setPendingSats(pendingSats => pendingSats - sats)
}
}, [])
return ( return (
<Dropdown.Item <Dropdown.Item
onClick={async () => { onClick={async () => {
showModal(onClose => showModal(onClose =>
<ItemAct onClose={onClose} item={item} />) <ItemAct onClose={onClose} item={item} optimisticUpdate={optimisticUpdate} />)
}} }}
> >
<span className='text-success'>zap</span> <span className='text-success'>zap</span>
@ -96,8 +109,9 @@ export default function UpVote ({ item, className }) {
setWalkthrough(upvotePopover: $upvotePopover, tipPopover: $tipPopover) setWalkthrough(upvotePopover: $upvotePopover, tipPopover: $tipPopover)
}` }`
) )
const strike = useLightning()
const [controller, setController] = useState(null) const [controller, setController] = useState()
const { pendingSats, setPendingSats } = useItemContext()
const pending = controller?.started && !controller.done const pending = controller?.started && !controller.done
const setVoteShow = useCallback((yes) => { const setVoteShow = useCallback((yes) => {
@ -134,7 +148,7 @@ export default function UpVote ({ item, className }) {
[item?.mine, item?.meForward, item?.deletedAt]) [item?.mine, item?.meForward, item?.deletedAt])
const [meSats, overlayText, color, nextColor] = useMemo(() => { const [meSats, overlayText, color, nextColor] = useMemo(() => {
const meSats = (item?.meSats || item?.meAnonSats || 0) const meSats = (item?.meSats || item?.meAnonSats || 0) + pendingSats
// what should our next tip be? // what should our next tip be?
const sats = nextTip(meSats, { ...me?.privates }) const sats = nextTip(meSats, { ...me?.privates })
@ -142,7 +156,16 @@ export default function UpVote ({ item, className }) {
return [ return [
meSats, me ? numWithUnits(sats, { abbreviate: false }) : 'zap it', meSats, me ? numWithUnits(sats, { abbreviate: false }) : 'zap it',
getColor(meSats), getColor(meSats + sats)] getColor(meSats), getColor(meSats + sats)]
}, [item?.meSats, item?.meAnonSats, me?.privates?.tipDefault, me?.privates?.turboDefault]) }, [item?.meSats, item?.meAnonSats, pendingSats, me?.privates?.tipDefault, me?.privates?.turboDefault])
const optimisticUpdate = useCallback((sats, { onClose } = {}) => {
setPendingSats(pendingSats => pendingSats + sats)
strike()
onClose?.()
return () => {
setPendingSats(pendingSats => pendingSats - sats)
}
}, [])
const handleModalClosed = () => { const handleModalClosed = () => {
setHover(false) setHover(false)
@ -160,13 +183,16 @@ export default function UpVote ({ item, className }) {
if (pending) { if (pending) {
controller.abort() controller.abort()
setController(null)
return return
} }
const c = new ZapUndoController() const c = new ZapUndoController()
setController(c) setController(c)
showModal(onClose => showModal(onClose =>
<ItemAct onClose={onClose} item={item} abortSignal={c.signal} />, { onClose: handleModalClosed }) <ItemAct
onClose={onClose} item={item} abortSignal={c.signal} optimisticUpdate={optimisticUpdate}
/>, { onClose: handleModalClosed })
} }
const handleShortPress = async () => { const handleShortPress = async () => {
@ -186,18 +212,19 @@ export default function UpVote ({ item, className }) {
if (pending) { if (pending) {
controller.abort() controller.abort()
setController(null)
return return
} }
const c = new ZapUndoController() const c = new ZapUndoController()
setController(c) setController(c)
await zap({ item, me, abortSignal: c.signal }) await zap({ item, me, abortSignal: c.signal, optimisticUpdate })
} else { } else {
showModal(onClose => <ItemAct onClose={onClose} item={item} />, { onClose: handleModalClosed }) showModal(onClose => <ItemAct onClose={onClose} item={item} optimisticUpdate={optimisticUpdate} />, { onClose: handleModalClosed })
} }
} }
const fillColor = hover ? nextColor : color const fillColor = hover || pending ? nextColor : color
return ( return (
<div ref={ref} className='upvoteParent'> <div ref={ref} className='upvoteParent'>
@ -222,7 +249,7 @@ export default function UpVote ({ item, className }) {
${meSats ? styles.voted : ''} ${meSats ? styles.voted : ''}
${pending ? styles.pending : ''}` ${pending ? styles.pending : ''}`
} }
style={meSats || hover style={meSats || hover || pending
? { ? {
fill: fillColor, fill: fillColor,
filter: `drop-shadow(0 0 6px ${fillColor}90)` filter: `drop-shadow(0 0 6px ${fillColor}90)`

View File

@ -498,6 +498,10 @@ services:
- 'stacker_lnd' - 'stacker_lnd'
- '--lnd-port' - '--lnd-port'
- '10009' - '10009'
- '--max-amount'
- '0'
- '--daily-limit'
- '0'
lnbits: lnbits:
image: lnbits/lnbits:0.12.5 image: lnbits/lnbits:0.12.5
container_name: lnbits container_name: lnbits

View File

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

View File

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

View File

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

View File

@ -255,17 +255,3 @@ function getClient (uri) {
} }
}) })
} }
export function optimisticUpdate ({ mutation, variables, optimisticResponse, update }) {
const { cache, queryManager } = getApolloClient()
const mutationId = String(queryManager.mutationIdCounter++)
queryManager.markMutationOptimistic(optimisticResponse, {
mutationId,
document: mutation,
variables,
update
})
return () => cache.removeOptimistic(mutationId)
}

View File

@ -36,8 +36,16 @@ export const ITEM_SPAM_INTERVAL = '10m'
export const ANON_ITEM_SPAM_INTERVAL = '0' export const ANON_ITEM_SPAM_INTERVAL = '0'
export const INV_PENDING_LIMIT = 100 export const INV_PENDING_LIMIT = 100
export const BALANCE_LIMIT_MSATS = 100000000 // 100k sat export const BALANCE_LIMIT_MSATS = 100000000 // 100k sat
export const SN_USER_IDS = [616, 6030, 4502, 27] export const USER_ID = {
export const SN_NO_REWARDS_IDS = [27, 4502] k00b: 616,
ek: 6030,
sn: 4502,
anon: 27,
ad: 9,
delete: 106
}
export const SN_USER_IDS = [USER_ID.k00b, USER_ID.ek, USER_ID.sn]
export const SN_NO_REWARDS_IDS = [USER_ID.anon, USER_ID.sn]
export const ANON_INV_PENDING_LIMIT = 1000 export const ANON_INV_PENDING_LIMIT = 1000
export const ANON_BALANCE_LIMIT_MSATS = 0 // disable export const ANON_BALANCE_LIMIT_MSATS = 0 // disable
export const MAX_POLL_NUM_CHOICES = 10 export const MAX_POLL_NUM_CHOICES = 10
@ -54,17 +62,14 @@ export const ITEM_TYPES_USER = ['all', 'posts', 'comments', 'bounties', 'links',
export const ITEM_TYPES = ['all', 'posts', 'comments', 'bounties', 'links', 'discussions', 'polls', 'freebies', 'bios', 'jobs'] export const ITEM_TYPES = ['all', 'posts', 'comments', 'bounties', 'links', 'discussions', 'polls', 'freebies', 'bios', 'jobs']
export const ITEM_TYPES_UNIVERSAL = ['all', 'posts', 'comments', 'freebies'] export const ITEM_TYPES_UNIVERSAL = ['all', 'posts', 'comments', 'freebies']
export const OLD_ITEM_DAYS = 3 export const OLD_ITEM_DAYS = 3
export const ANON_USER_ID = 27
export const DELETE_USER_ID = 106
export const AD_USER_ID = 9
export const ANON_FEE_MULTIPLIER = 100 export const ANON_FEE_MULTIPLIER = 100
export const SSR = typeof window === 'undefined' export const SSR = typeof window === 'undefined'
export const MAX_FORWARDS = 5 export const MAX_FORWARDS = 5
export const LNURLP_COMMENT_MAX_LENGTH = 1000 export const LNURLP_COMMENT_MAX_LENGTH = 1000
export const RESERVED_MAX_USER_ID = 615 export const RESERVED_MAX_USER_ID = 615
export const GLOBAL_SEED = 616 export const GLOBAL_SEED = USER_ID.k00b
export const FREEBIE_BASE_COST_THRESHOLD = 10 export const FREEBIE_BASE_COST_THRESHOLD = 10
export const USER_IDS_BALANCE_NO_LIMIT = [...SN_USER_IDS, AD_USER_ID] export const USER_IDS_BALANCE_NO_LIMIT = [...SN_USER_IDS, USER_ID.anon, USER_ID.ad]
// WIP ultimately subject to this list: https://ofac.treasury.gov/sanctions-programs-and-country-information // WIP ultimately subject to this list: https://ofac.treasury.gov/sanctions-programs-and-country-information
// From lawyers: north korea, cuba, iran, ukraine, syria // From lawyers: north korea, cuba, iran, ukraine, syria

View File

@ -1,10 +1,23 @@
export function ensureProtocol (value) { export function ensureProtocol (value) {
if (!value) return value if (!value) return value
value = value.trim() value = value.trim()
if (!/^([a-z0-9]+:\/\/|mailto:)/.test(value)) { let url
value = 'http://' + value
} try {
url = new URL(value)
} catch {
try {
url = new URL('http://' + value)
} catch {
return value return value
}
}
// remove trailing slash if new URL() added it
if (url.href.endsWith('/') && !value.endsWith('/')) {
return url.href.slice(0, -1)
}
return url.href
} }
export function isExternal (url) { export function isExternal (url) {
@ -26,14 +39,22 @@ export function removeTracking (value) {
/** /**
* parse links like https://stacker.news/items/123456 as #123456 * parse links like https://stacker.news/items/123456 as #123456
*/ */
export function isItemPath (pathname) {
if (!pathname) return false
const [page, id] = pathname.split('/').filter(part => !!part)
return page === 'items' && /^[0-9]+$/.test(id)
}
export function parseInternalLinks (href) { export function parseInternalLinks (href) {
const url = new URL(href) const url = new URL(href)
const internalURL = process.env.NEXT_PUBLIC_URL const internalURL = process.env.NEXT_PUBLIC_URL
const { pathname, searchParams } = url const { pathname, searchParams } = url
// ignore empty parts which exist due to pathname starting with '/' // ignore empty parts which exist due to pathname starting with '/'
const emptyPart = part => !!part if (isItemPath(pathname) && url.origin === internalURL) {
const parts = pathname.split('/').filter(emptyPart) const parts = pathname.split('/').filter(part => !!part)
if (parts[0] === 'items' && /^[0-9]+$/.test(parts[1]) && url.origin === internalURL) {
const itemId = parts[1] const itemId = parts[1]
// check for valid item page due to referral links like /items/123456/r/ekzyis // check for valid item page due to referral links like /items/123456/r/ekzyis
const itemPages = ['edit', 'ots', 'related'] const itemPages = ['edit', 'ots', 'related']
@ -44,18 +65,22 @@ export function parseInternalLinks (href) {
// and not #2 // and not #2
// since commentId will be ignored anyway // since commentId will be ignored anyway
const linkText = `#${itemId}/${itemPage}` const linkText = `#${itemId}/${itemPage}`
return linkText return { itemId, linkText }
} }
const commentId = searchParams.get('commentId') const commentId = searchParams.get('commentId')
const linkText = `#${commentId || itemId}` const linkText = `#${commentId || itemId}`
return linkText return { itemId, commentId, linkText }
} }
return {}
} }
export function parseEmbedUrl (href) { export function parseEmbedUrl (href) {
try {
const { hostname, pathname, searchParams } = new URL(href) const { hostname, pathname, searchParams } = new URL(href)
if (hostname.endsWith('youtube.com') && pathname.includes('/watch')) { if (hostname.endsWith('youtube.com')) {
if (pathname.includes('/watch')) {
return { return {
provider: 'youtube', provider: 'youtube',
id: searchParams.get('v'), id: searchParams.get('v'),
@ -66,6 +91,15 @@ export function parseEmbedUrl (href) {
} }
} }
if (pathname.includes('/shorts')) {
const id = pathname.split('/').slice(-1).join()
return {
provider: 'youtube',
id
}
}
}
if (hostname.endsWith('youtu.be') && pathname.length > 1) { if (hostname.endsWith('youtu.be') && pathname.length > 1) {
return { return {
provider: 'youtube', provider: 'youtube',
@ -86,6 +120,9 @@ export function parseEmbedUrl (href) {
} }
} }
} }
} catch {
// ignore
}
// Important to return empty object as default // Important to return empty object as default
return {} return {}

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 actual = parseInternalLinks(href) const { linkText: 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 { ANON_USER_ID, COMMENT_DEPTH_LIMIT, FOUND_BLURBS, LOST_BLURBS } from './constants' import { USER_ID, COMMENT_DEPTH_LIMIT, FOUND_BLURBS, LOST_BLURBS } from './constants'
import { msatsToSats, numWithUnits } from './format' import { msatsToSats, numWithUnits } from './format'
import models from '@/api/models' import models from '@/api/models'
import { isMuted } from '@/lib/user' import { isMuted } from '@/lib/user'
@ -38,6 +38,7 @@ const createUserFilter = (tag) => {
const tagMap = { const tagMap = {
REPLY: 'noteAllDescendants', REPLY: 'noteAllDescendants',
MENTION: 'noteMentions', MENTION: 'noteMentions',
ITEM_MENTION: 'noteItemMentions',
TIP: 'noteItemSats', TIP: 'noteItemSats',
FORWARDEDTIP: 'noteForwardedSats', FORWARDEDTIP: 'noteForwardedSats',
REFERRAL: 'noteInvites', REFERRAL: 'noteInvites',
@ -179,7 +180,7 @@ export const notifyTerritorySubscribers = async ({ models, item }) => {
export const notifyItemParents = async ({ models, item, me }) => { export const notifyItemParents = async ({ models, item, me }) => {
try { try {
const user = await models.user.findUnique({ where: { id: me?.id || ANON_USER_ID } }) const user = await models.user.findUnique({ where: { id: me?.id || USER_ID.anon } })
const parents = await models.$queryRawUnsafe( const parents = await models.$queryRawUnsafe(
'SELECT DISTINCT p."userId" FROM "Item" i JOIN "Item" p ON p.path @> i.path WHERE i.id = $1 and p."userId" <> $2 ' + 'SELECT DISTINCT p."userId" FROM "Item" i JOIN "Item" p ON p.path @> i.path WHERE i.id = $1 and p."userId" <> $2 ' +
'AND NOT EXISTS (SELECT 1 FROM "Mute" m WHERE m."muterId" = p."userId" AND m."mutedId" = $2)', 'AND NOT EXISTS (SELECT 1 FROM "Mute" m WHERE m."muterId" = p."userId" AND m."mutedId" = $2)',
@ -262,6 +263,27 @@ export const notifyMention = async ({ models, userId, item }) => {
} }
} }
export const notifyItemMention = async ({ models, referrerItem, refereeItem }) => {
try {
const muted = await isMuted({ models, muterId: refereeItem.userId, mutedId: referrerItem.userId })
if (!muted) {
const referrer = await models.user.findUnique({ where: { id: referrerItem.userId } })
// replace full links to #<id> syntax as rendered on site
const body = referrerItem.text.replace(new RegExp(`${process.env.NEXT_PUBLIC_URL}/items/(\\d+)`, 'gi'), '#$1')
await sendUserNotification(refereeItem.userId, {
title: `@${referrer.name} mentioned one of your items`,
body,
item: referrerItem,
tag: 'ITEM_MENTION'
})
}
} catch (err) {
console.error(err)
}
}
export const notifyReferral = async (userId) => { export const notifyReferral = async (userId) => {
try { try {
await sendUserNotification(userId, { title: 'someone joined via one of your referral links', tag: 'REFERRAL' }) await sendUserNotification(userId, { title: 'someone joined via one of your referral links', tag: 'REFERRAL' })

View File

@ -30,6 +30,7 @@ import { INVOICE_RETENTION_DAYS, ZAP_UNDO_DELAY_MS } from '@/lib/constants'
import { OverlayTrigger, Tooltip } from 'react-bootstrap' import { OverlayTrigger, Tooltip } from 'react-bootstrap'
import { useField } from 'formik' import { useField } from 'formik'
import styles from './settings.module.css' import styles from './settings.module.css'
import { AuthBanner } from '@/components/banners'
export const getServerSideProps = getGetServerSideProps({ query: SETTINGS, authRequired: true }) export const getServerSideProps = getGetServerSideProps({ query: SETTINGS, authRequired: true })
@ -106,7 +107,7 @@ export default function Settings ({ ssrData }) {
return ( return (
<Layout> <Layout>
<div className='pb-3 w-100 mt-2' style={{ maxWidth: '600px' }}> <div className='pb-3 w-100 mt-2' style={{ maxWidth: '600px' }}>
{hasOnlyOneAuthMethod(settings?.authMethods) && <div className={styles.alert}>Please add a second auth method to avoid losing access to your account.</div>} {hasOnlyOneAuthMethod(settings?.authMethods) && <AuthBanner />}
<SettingsHeader /> <SettingsHeader />
<Form <Form
initial={{ initial={{
@ -120,6 +121,7 @@ export default function Settings ({ ssrData }) {
noteEarning: settings?.noteEarning, noteEarning: settings?.noteEarning,
noteAllDescendants: settings?.noteAllDescendants, noteAllDescendants: settings?.noteAllDescendants,
noteMentions: settings?.noteMentions, noteMentions: settings?.noteMentions,
noteItemMentions: settings?.noteItemMentions,
noteDeposits: settings?.noteDeposits, noteDeposits: settings?.noteDeposits,
noteWithdrawals: settings?.noteWithdrawals, noteWithdrawals: settings?.noteWithdrawals,
noteInvites: settings?.noteInvites, noteInvites: settings?.noteInvites,
@ -280,6 +282,11 @@ export default function Settings ({ ssrData }) {
name='noteMentions' name='noteMentions'
groupClassName='mb-0' groupClassName='mb-0'
/> />
<Checkbox
label='someone mentions one of my items'
name='noteItemMentions'
groupClassName='mb-0'
/>
<Checkbox <Checkbox
label='there is a new job' label='there is a new job'
name='noteJobIndicator' name='noteJobIndicator'
@ -1001,10 +1008,9 @@ const ZapUndosField = () => {
zap undos zap undos
<Info> <Info>
<ul className='fw-bold'> <ul className='fw-bold'>
<li>An undo button is shown after every zap that exceeds or is equal to the threshold</li> <li>After every zap that exceeds or is equal to the threshold, the bolt will pulse</li>
<li>The button is shown for {ZAP_UNDO_DELAY_MS / 1000} seconds</li> <li>You can undo the zap if you click the bolt while it's pulsing</li>
<li>The button is only shown for zaps from the custodial wallet</li> <li>The bolt will pulse for {ZAP_UNDO_DELAY_MS / 1000} seconds</li>
<li>Use a budget or manual approval with attached wallets</li>
</ul> </ul>
</Info> </Info>
</div> </div>

View File

@ -0,0 +1,92 @@
-- CreateTable
CREATE TABLE "PollBlindVote" (
"id" SERIAL NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"itemId" INTEGER NOT NULL,
"userId" INTEGER NOT NULL,
CONSTRAINT "PollBlindVote_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "PollBlindVote.userId_index" ON "PollBlindVote"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "PollBlindVote.itemId_userId_unique" ON "PollBlindVote"("itemId", "userId");
-- AddForeignKey
ALTER TABLE "PollBlindVote" ADD CONSTRAINT "PollBlindVote_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PollBlindVote" ADD CONSTRAINT "PollBlindVote_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- migrate existing poll votes
INSERT INTO "PollBlindVote" ("itemId", "userId")
SELECT "itemId", "userId" FROM "PollVote";
/*
Warnings:
- You are about to drop the column `userId` on the `PollVote` table. All the data in the column will be lost.
*/
-- DropForeignKey
ALTER TABLE "PollVote" DROP CONSTRAINT "PollVote_userId_fkey";
-- DropIndex
DROP INDEX "PollVote.itemId_userId_unique";
-- DropIndex
DROP INDEX "PollVote.userId_index";
-- AlterTable
ALTER TABLE "PollVote" DROP COLUMN "userId";
-- update `poll_vote` function to update both "PollVote" and "PollBlindVote" tables
-- create poll vote
-- if user hasn't already voted
-- charges user item.pollCost
-- adds POLL to ItemAct
-- adds PollVote
-- adds PollBlindVote
CREATE OR REPLACE FUNCTION poll_vote(option_id INTEGER, user_id INTEGER)
RETURNS "Item"
LANGUAGE plpgsql
AS $$
DECLARE
item "Item";
option "PollOption";
BEGIN
PERFORM ASSERT_SERIALIZED();
SELECT * INTO option FROM "PollOption" where id = option_id;
IF option IS NULL THEN
RAISE EXCEPTION 'INVALID_POLL_OPTION';
END IF;
SELECT * INTO item FROM "Item" where id = option."itemId";
IF item IS NULL THEN
RAISE EXCEPTION 'POLL_DOES_NOT_EXIST';
END IF;
IF item."userId" = user_id THEN
RAISE EXCEPTION 'POLL_OWNER_CANT_VOTE';
END IF;
-- no longer check `PollVote` to see if a user has voted. Instead, check `PollBlindVote`
IF EXISTS (SELECT 1 FROM "PollBlindVote" WHERE "itemId" = item.id AND "userId" = user_id) THEN
RAISE EXCEPTION 'POLL_VOTE_ALREADY_EXISTS';
END IF;
PERFORM item_act(item.id, user_id, 'POLL', item."pollCost");
INSERT INTO "PollVote" (created_at, updated_at, "itemId", "pollOptionId")
VALUES (now_utc(), now_utc(), item.id, option_id);
INSERT INTO "PollBlindVote" (created_at, updated_at, "itemId", "userId")
VALUES (now_utc(), now_utc(), item.id, user_id);
RETURN item;
END;
$$;

View File

@ -0,0 +1,31 @@
-- CreateTable
CREATE TABLE "ItemMention" (
"id" SERIAL NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"referrerId" INTEGER NOT NULL,
"refereeId" INTEGER NOT NULL,
CONSTRAINT "ItemMention_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ItemMention.created_at_index" ON "ItemMention"("created_at");
-- CreateIndex
CREATE INDEX "ItemMention.referrerId_index" ON "ItemMention"("referrerId");
-- CreateIndex
CREATE INDEX "ItemMention.refereeId_index" ON "ItemMention"("refereeId");
-- CreateIndex
CREATE UNIQUE INDEX "ItemMention.referrerId_refereeId_unique" ON "ItemMention"("referrerId", "refereeId");
-- AddForeignKey
ALTER TABLE "ItemMention" ADD CONSTRAINT "ItemMention_referrerId_fkey" FOREIGN KEY ("referrerId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ItemMention" ADD CONSTRAINT "ItemMention_refereeId_fkey" FOREIGN KEY ("refereeId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AlterTable
ALTER TABLE "users" ADD COLUMN "noteItemMentions" BOOLEAN NOT NULL DEFAULT true;

View File

@ -44,6 +44,7 @@ model User {
noteInvites Boolean @default(true) noteInvites Boolean @default(true)
noteItemSats Boolean @default(true) noteItemSats Boolean @default(true)
noteMentions Boolean @default(true) noteMentions Boolean @default(true)
noteItemMentions Boolean @default(true)
noteForwardedSats Boolean @default(true) noteForwardedSats Boolean @default(true)
lastCheckedJobs DateTime? lastCheckedJobs DateTime?
noteJobIndicator Boolean @default(true) noteJobIndicator Boolean @default(true)
@ -79,7 +80,6 @@ model User {
actions ItemAct[] actions ItemAct[]
mentions Mention[] mentions Mention[]
messages Message[] messages Message[]
PollVote PollVote[]
PushSubscriptions PushSubscription[] PushSubscriptions PushSubscription[]
ReferralAct ReferralAct[] ReferralAct ReferralAct[]
Streak Streak[] Streak Streak[]
@ -125,6 +125,7 @@ model User {
Replies Reply[] Replies Reply[]
walletLogs WalletLog[] walletLogs WalletLog[]
Reminder Reminder[] Reminder Reminder[]
PollBlindVote PollBlindVote[]
@@index([photoId]) @@index([photoId])
@@index([createdAt], map: "users.created_at_index") @@index([createdAt], map: "users.created_at_index")
@ -411,6 +412,8 @@ model Item {
user User @relation("UserItems", fields: [userId], references: [id], onDelete: Cascade) user User @relation("UserItems", fields: [userId], references: [id], onDelete: Cascade)
actions ItemAct[] actions ItemAct[]
mentions Mention[] mentions Mention[]
referrer ItemMention[] @relation("referrer")
referee ItemMention[] @relation("referee")
PollOption PollOption[] PollOption PollOption[]
PollVote PollVote[] PollVote PollVote[]
ThreadSubscription ThreadSubscription[] ThreadSubscription ThreadSubscription[]
@ -424,6 +427,7 @@ model Item {
Ancestors Reply[] @relation("AncestorReplyItem") Ancestors Reply[] @relation("AncestorReplyItem")
Replies Reply[] Replies Reply[]
Reminder Reminder[] Reminder Reminder[]
PollBlindVote PollBlindVote[]
@@index([uploadId]) @@index([uploadId])
@@index([lastZapAt]) @@index([lastZapAt])
@ -501,16 +505,25 @@ model PollVote {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
userId Int
itemId Int itemId Int
pollOptionId Int pollOptionId Int
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade) item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
pollOption PollOption @relation(fields: [pollOptionId], references: [id], onDelete: Cascade) pollOption PollOption @relation(fields: [pollOptionId], references: [id], onDelete: Cascade)
@@index([pollOptionId], map: "PollVote.pollOptionId_index")
}
model PollBlindVote {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
itemId Int
userId Int
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([itemId, userId], map: "PollVote.itemId_userId_unique") @@unique([itemId, userId], map: "PollBlindVote.itemId_userId_unique")
@@index([pollOptionId], map: "PollVote.pollOptionId_index") @@index([userId], map: "PollBlindVote.userId_index")
@@index([userId], map: "PollVote.userId_index")
} }
enum BillingType { enum BillingType {
@ -661,6 +674,21 @@ model Mention {
@@index([userId], map: "Mention.userId_index") @@index([userId], map: "Mention.userId_index")
} }
model ItemMention {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
referrerId Int
refereeId Int
referrerItem Item @relation("referrer", fields: [referrerId], references: [id], onDelete: Cascade)
refereeItem Item @relation("referee", fields: [refereeId], references: [id], onDelete: Cascade)
@@unique([referrerId, refereeId], map: "ItemMention.referrerId_refereeId_unique")
@@index([createdAt], map: "ItemMention.created_at_index")
@@index([referrerId], map: "ItemMention.referrerId_index")
@@index([refereeId], map: "ItemMention.refereeId_index")
}
model Invoice { model Invoice {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")

43
sndev
View File

@ -159,6 +159,11 @@ OPTIONS"
sndev__logs() { sndev__logs() {
shift shift
if [ $# -eq 1 ]; then
docker__compose logs -t --tail=1000 -f "$@"
exit 0
fi
docker__compose logs "$@" docker__compose logs "$@"
} }
@ -219,6 +224,43 @@ USAGE
echo "$help" echo "$help"
} }
sndev__fund_user() {
shift
if [ -z "$1" ]; then
echo "<nym> argument required"
sndev__help_fund_user
exit 1
fi
if [ -z "$2" ]; then
echo "<msats> argument required"
sndev__help_fund_user
exit 2
fi
re='^[0-9]+$'
if ! [[ $2 =~ $re ]]; then
echo "<msats> is not a positive integer"
sndev__help_fund_user
exit 3
fi
docker__exec db psql -U sn -d stackernews -q <<EOF
UPDATE users set msats = $2 where name = '$1';
EOF
}
sndev__help_fund_user() {
help="
fund a nym without using an LN invoice (local only)
USAGE
$ sndev fund_user <nym> <msats>
<nym> - the name of the user you want to fund
<msats> - the amount of millisatoshis to set the account to. Must be >= 0
"
echo "$help"
}
sndev__fund() { sndev__fund() {
shift shift
docker__stacker_lnd -t payinvoice "$@" docker__stacker_lnd -t payinvoice "$@"
@ -516,6 +558,7 @@ COMMANDS
sn: sn:
login login as a nym login login as a nym
fund_user fund a nym without using an LN invoice
lnd: lnd:
fund pay a bolt11 for funding fund pay a bolt11 for funding

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', 'REFERRAL', 'INVITE', 'FOLLOW', 'TERRITORY_POST'] const AMOUNT_TAGS = ['REPLY', 'MENTION', 'ITEM_MENTION', 'REFERRAL', 'INVITE', 'FOLLOW', 'TERRITORY_POST']
// tags that need to know the sum of sats of notifications with same tag for merging // tags that need to know the sum of sats of notifications with same tag for merging
const SUM_SATS_TAGS = ['DEPOSIT', 'WITHDRAWAL'] const SUM_SATS_TAGS = ['DEPOSIT', 'WITHDRAWAL']
// this should reflect the amount of notifications that were already merged before // this should reflect the amount of notifications that were already merged before
@ -143,6 +143,8 @@ const mergeAndShowNotification = async (sw, payload, currentNotifications, tag,
title = `you have ${amount} new replies` title = `you have ${amount} new replies`
} else if (compareTag === 'MENTION') { } else if (compareTag === 'MENTION') {
title = `you were mentioned ${amount} times` title = `you were mentioned ${amount} times`
} else if (compareTag === 'ITEM_MENTION') {
title = `your items were mentioned ${amount} times`
} else if (compareTag === 'REFERRAL') { } else if (compareTag === 'REFERRAL') {
title = `${amount} stackers joined via your referral links` title = `${amount} stackers joined via your referral links`
} else if (compareTag === 'INVITE') { } else if (compareTag === 'INVITE') {

View File

@ -1,5 +1,5 @@
import { deleteObjects } from '@/api/s3' import { deleteObjects } from '@/api/s3'
import { ANON_USER_ID } from '@/lib/constants' import { USER_ID } from '@/lib/constants'
export async function deleteUnusedImages ({ models }) { export async function deleteUnusedImages ({ models }) {
// delete all images in database and S3 which weren't paid in the last 24 hours // delete all images in database and S3 which weren't paid in the last 24 hours
@ -14,7 +14,7 @@ export async function deleteUnusedImages ({ models }) {
AND NOT EXISTS (SELECT * FROM users WHERE "photoId" = "Upload".id) AND NOT EXISTS (SELECT * FROM users WHERE "photoId" = "Upload".id)
AND NOT EXISTS (SELECT * FROM "Item" WHERE "uploadId" = "Upload".id) AND NOT EXISTS (SELECT * FROM "Item" WHERE "uploadId" = "Upload".id)
)) ))
AND created_at < date_trunc('hour', now() - CASE WHEN "userId" = ${ANON_USER_ID} THEN interval '1 hour' ELSE interval '24 hours' END)` AND created_at < date_trunc('hour', now() - CASE WHEN "userId" = ${USER_ID.anon} THEN interval '1 hour' ELSE interval '24 hours' END)`
const s3Keys = unpaidImages.map(({ id }) => id) const s3Keys = unpaidImages.map(({ id }) => id)
if (s3Keys.length === 0) { if (s3Keys.length === 0) {

View File

@ -1,5 +1,5 @@
import * as math from 'mathjs' import * as math from 'mathjs'
import { ANON_USER_ID, SN_USER_IDS } from '@/lib/constants.js' import { USER_ID, SN_USER_IDS } from '@/lib/constants.js'
export async function trust ({ boss, models }) { export async function trust ({ boss, models }) {
try { try {
@ -127,7 +127,7 @@ async function getGraph (models) {
FROM "ItemAct" FROM "ItemAct"
JOIN "Item" ON "Item".id = "ItemAct"."itemId" AND "ItemAct".act IN ('FEE', 'TIP', 'DONT_LIKE_THIS') JOIN "Item" ON "Item".id = "ItemAct"."itemId" AND "ItemAct".act IN ('FEE', 'TIP', 'DONT_LIKE_THIS')
AND "Item"."parentId" IS NULL AND NOT "Item".bio AND "Item"."userId" <> "ItemAct"."userId" AND "Item"."parentId" IS NULL AND NOT "Item".bio AND "Item"."userId" <> "ItemAct"."userId"
JOIN users ON "ItemAct"."userId" = users.id AND users.id <> ${ANON_USER_ID} JOIN users ON "ItemAct"."userId" = users.id AND users.id <> ${USER_ID.anon}
GROUP BY user_id, name, item_id, user_at, against GROUP BY user_id, name, item_id, user_at, against
HAVING CASE WHEN HAVING CASE WHEN
"ItemAct".act = 'DONT_LIKE_THIS' THEN sum("ItemAct".msats) > ${AGAINST_MSAT_MIN} "ItemAct".act = 'DONT_LIKE_THIS' THEN sum("ItemAct".msats) > ${AGAINST_MSAT_MIN}