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
54 changed files with 1214 additions and 465 deletions

View File

@ -1,8 +1,8 @@
export default {
Query: {
snl: async (parent, _, { models }) => {
const { live } = await models.snl.findFirst()
return live
const snl = await models.snl.findFirst()
return !!snl?.live
}
},
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'
export default {
@ -17,7 +17,7 @@ export function uploadIdsFromText (text, { models }) {
export async function imageFeesInfo (s3Keys, { models, me }) {
// returns info object in this format:
// { 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 totalFeesMsats = info.nUnpaid * Number(info.imageFeeMsats)
const totalFees = msatsToSats(totalFeesMsats)

View File

@ -1,5 +1,5 @@
import { GraphQLError } from 'graphql'
import { ensureProtocol, removeTracking, stripTrailingSlash } from '@/lib/url'
import { ensureProtocol, parseInternalLinks, removeTracking, stripTrailingSlash } from '@/lib/url'
import serialize from './serial'
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
import { getMetadata, metadataRuleSets } from 'page-metadata-parser'
@ -8,14 +8,14 @@ import domino from 'domino'
import {
ITEM_SPAM_INTERVAL, ITEM_FILTER_THRESHOLD,
COMMENT_DEPTH_LIMIT, COMMENT_TYPE_QUERY,
ANON_USER_ID, ANON_ITEM_SPAM_INTERVAL, POLL_COST,
ITEM_ALLOW_EDITS, GLOBAL_SEED, ANON_FEE_MULTIPLIER, NOFOLLOW_LIMIT, UNKNOWN_LINK_REL
USER_ID, ANON_ITEM_SPAM_INTERVAL, POLL_COST,
ITEM_ALLOW_EDITS, GLOBAL_SEED, ANON_FEE_MULTIPLIER, NOFOLLOW_LIMIT, UNKNOWN_LINK_REL, SN_USER_IDS
} from '@/lib/constants'
import { msatsToSats } from '@/lib/format'
import { parse } from 'tldts'
import uu from 'url-unshort'
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 { datePivot, whenRange } from '@/lib/time'
import { imageFeesInfo, uploadIdsFromText } from './image'
@ -825,7 +825,7 @@ export default {
await serialize(
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
@ -859,7 +859,7 @@ export default {
}
}
if (idempotent) {
if (me && idempotent) {
await serialize(
models.$queryRaw`
SELECT
@ -869,15 +869,15 @@ export default {
WHERE act IN ('TIP', 'FEE')
AND "itemId" = ${Number(id)}::INTEGER
AND "userId" = ${me.id}::INTEGER)::INTEGER)`,
{ models, lnd, hash, hmac }
{ models, lnd, hash, hmac, verifyPayment: !!hash }
)
} else {
await serialize(
models.$queryRaw`
SELECT
item_act(${Number(id)}::INTEGER,
${me?.id || ANON_USER_ID}::INTEGER, ${act}::"ItemActType", ${Number(sats)}::INTEGER)`,
{ models, lnd, me, hash, hmac, fee: sats }
${me?.id || USER_ID.anon}::INTEGER, ${act}::"ItemActType", ${Number(sats)}::INTEGER)`,
{ models, lnd, me, hash, hmac, fee: sats, verifyPayment: !!hash || !me }
)
}
@ -1004,8 +1004,7 @@ export default {
}
const options = await models.$queryRaw`
SELECT "PollOption".id, option, count("PollVote"."userId")::INTEGER as count,
coalesce(bool_or("PollVote"."userId" = ${me?.id}), 'f') as "meVoted"
SELECT "PollOption".id, option, count("PollVote".id)::INTEGER as count
FROM "PollOption"
LEFT JOIN "PollVote" on "PollVote"."pollOptionId" = "PollOption".id
WHERE "PollOption"."itemId" = ${item.id}
@ -1013,9 +1012,16 @@ export default {
ORDER BY "PollOption".id ASC
`
const meVoted = await models.pollBlindVote.findFirst({
where: {
userId: me?.id,
itemId: item.id
}
})
const poll = {}
poll.options = options
poll.meVoted = options.some(o => o.meVoted)
poll.meVoted = !!meVoted
poll.count = options.reduce((t, o) => t + o.count, 0)
return poll
@ -1157,7 +1163,7 @@ export default {
return parent.otsHash
},
deleteScheduledAt: async (item, args, { me, models }) => {
const meId = me?.id ?? ANON_USER_ID
const meId = me?.id ?? USER_ID.anon
if (meId !== item.userId) {
// Only query for deleteScheduledAt for your own items to keep DB queries minimized
return null
@ -1166,8 +1172,8 @@ export default {
return deleteJobs[0]?.startafter ?? null
},
reminderScheduledAt: async (item, args, { me, models }) => {
const meId = me?.id ?? ANON_USER_ID
if (meId !== item.userId || meId === ANON_USER_ID) {
const meId = me?.id ?? USER_ID.anon
if (meId !== item.userId || meId === USER_ID.anon) {
// don't show reminders on an item if it isn't yours
// don't support reminders for ANON
return null
@ -1179,6 +1185,7 @@ export default {
}
const namePattern = /\B@[\w_]+/gi
const refPattern = new RegExp(`${process.env.NEXT_PUBLIC_URL}/items/\\d+.*`, 'gi')
export const createMentions = async (item, models) => {
// if we miss a mention, in the rare circumstance there's some kind of
@ -1188,49 +1195,112 @@ export const createMentions = async (item, models) => {
return
}
// user mentions
try {
const mentions = item.text.match(namePattern)?.map(m => m.slice(1))
if (mentions?.length > 0) {
const users = await models.user.findMany({
where: {
name: { in: mentions },
// Don't create mentions when mentioning yourself
id: { not: item.userId }
}
})
users.forEach(async user => {
const data = {
itemId: item.id,
userId: user.id
}
const mention = await models.mention.upsert({
where: {
itemId_userId: data
},
update: data,
create: data
})
// only send if mention is new to avoid duplicates
if (mention.createdAt.getTime() === mention.updatedAt.getTime()) {
notifyMention({ models, userId: user.id, item })
}
})
}
await createUserMentions(item, models)
} catch (e) {
console.error('mention failure', 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))
if (!mentions || mentions.length === 0) return
const users = await models.user.findMany({
where: {
name: { in: mentions },
// Don't create mentions when mentioning yourself
id: { not: item.userId }
}
})
users.forEach(async user => {
const data = {
itemId: item.id,
userId: user.id
}
const mention = await models.mention.upsert({
where: {
itemId_userId: data
},
update: data,
create: data
})
// only send if mention is new to avoid duplicates
if (mention.createdAt.getTime() === mention.updatedAt.getTime()) {
notifyMention({ models, userId: user.id, item })
}
})
}
const createItemMentions = async (item, models) => {
const refs = item.text.match(refPattern)?.map(m => {
try {
const { itemId, commentId } = parseInternalLinks(m)
return Number(commentId || itemId)
} catch (err) {
return null
}
}).filter(r => !!r)
if (!refs || refs.length === 0) return
const referee = await models.item.findMany({
where: {
id: { in: refs },
// Don't create mentions for your own items
userId: { not: item.userId }
}
})
referee.forEach(async r => {
const data = {
referrerId: item.id,
refereeId: r.id
}
const mention = await models.itemMention.upsert({
where: {
referrerId_refereeId: data
},
update: data,
create: data
})
// only send if mention is new to avoid duplicates
if (mention.createdAt.getTime() === mention.updatedAt.getTime()) {
notifyItemMention({ models, referrerItem: item, refereeItem: r })
}
})
}
export const updateItem = async (parent, { sub: subName, forward, options, ...item }, { me, models, lnd, hash, hmac }) => {
// update iff this item belongs to me
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' } })
}
if (subName && old.subName !== subName) {
const differentSub = subName && old.subName !== subName
if (differentSub) {
const sub = await models.sub.findUnique({ where: { name: subName } })
if (old.freebie) {
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
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 } })
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' } })
}
@ -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 uploadIds = uploadIdsFromText(item.text, { models })
@ -1275,7 +1348,7 @@ export const updateItem = async (parent, { sub: subName, forward, options, ...it
([item] = await serialize(
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),
{ models, lnd, me, hash, hmac, fee: imgFees }
{ models, lnd, me, hash, hmac, fee: imgFees, verifyPayment: !!hash || !me }
))
await createMentions(item, models)
@ -1303,7 +1376,7 @@ export const createItem = async (parent, { forward, options, ...item }, { me, mo
item.subName = 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)
if (item.url && !isJob(item)) {
@ -1332,7 +1405,7 @@ export const createItem = async (parent, { forward, options, ...item }, { me, mo
models.$queryRawUnsafe(
`${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),
{ models, lnd, me, hash, hmac, fee }
{ models, lnd, me, hash, hmac, fee, verifyPayment: !!hash || !me }
))
await createMentions(item, models)
@ -1367,7 +1440,7 @@ const enqueueDeletionJob = async (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([
models.$queryRawUnsafe(`
DELETE FROM pgboss.job
@ -1389,7 +1462,7 @@ const deleteReminderAndJob = async ({ me, item, models }) => {
const createReminderAndJob = async ({ me, item, models }) => {
// disallow anon to use reminder
if (!me || me.id === ANON_USER_ID) {
if (!me || me.id === USER_ID.anon) {
return
}
const reminderCommand = getReminderCommand(item.text)

View File

@ -140,6 +140,22 @@ export default {
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
queries.push(
// Only record per item ID
@ -157,6 +173,7 @@ export default {
WHEN type = 'Reply' THEN 2
WHEN type = 'FollowActivity' THEN 3
WHEN type = 'TerritoryPost' THEN 4
WHEN type = 'ItemMention' THEN 5
END ASC
)`
)
@ -456,6 +473,9 @@ export default {
mention: async (n, args, { models }) => true,
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: {
invoice: async (n, args, { me, models }) => getInvoice(n, { id: n.id }, { me, models })
},

View File

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

View File

@ -7,7 +7,7 @@ import { createHmac } from './wallet'
import { msatsToSats, numWithUnits } from '@/lib/format'
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
const isArray = Array.isArray(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)
let invoice
if (hash) {
if (verify) {
invoice = await verifyPayment(models, hash, hmac, fee)
trx = [
models.$executeRaw`SELECT confirm_invoice(${hash}, ${invoice.msatsReceived})`,

View File

@ -248,7 +248,7 @@ export default {
const results = await serialize(
queries,
{ models, lnd, me, hash, hmac, fee: sub.billingCost })
{ models, lnd, me, hash, hmac, fee: sub.billingCost, verifyPayment: !!hash || !me })
return results[1]
},
toggleMuteSub: async (parent, { name }, { me, models }) => {
@ -368,7 +368,7 @@ export default {
models.sub.update({ where: { name }, data: newSub }),
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 })
}
@ -382,7 +382,10 @@ export default {
return await models.user.findUnique({ where: { id: sub.userId } })
},
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 }) => {
if (typeof sub.nposts !== 'undefined') {
@ -395,7 +398,11 @@ export default {
}
},
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
}
@ -457,7 +464,7 @@ async function createSub (parent, data, { me, models, lnd, hash, hmac }) {
subName: data.name
}
})
], { models, lnd, me, hash, hmac, fee: billingCost })
], { models, lnd, me, hash, hmac, fee: billingCost, verifyPayment: !!hash || !me })
return results[1]
} catch (error) {
@ -538,7 +545,7 @@ async function updateSub (parent, { oldName, ...data }, { me, models, lnd, hash,
userId: me.id
}
})
], { models, lnd, me, hash, hmac, fee: proratedCost })
], { models, lnd, me, hash, hmac, fee: proratedCost, verifyPayment: !!hash || !me })
return results[2]
}
}

View File

@ -1,5 +1,5 @@
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'
export default {
@ -26,7 +26,7 @@ export default {
size,
width,
height,
userId: me?.id || ANON_USER_ID,
userId: me?.id || USER_ID.anon,
paid: false
}

View File

@ -5,7 +5,7 @@ import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
import { msatsToSats } from '@/lib/format'
import { bioSchema, emailSchema, settingsSchema, ssValidate, userSchema } from '@/lib/validate'
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 { timeUnitForRange, whenRange } from '@/lib/time'
import assertApiKeyNotPermitted from './apiKey'
@ -217,7 +217,7 @@ export default {
SELECT name
FROM users
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
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) {
const [newFwdSats] = await models.$queryRawUnsafe(`
SELECT EXISTS(
@ -518,7 +538,7 @@ export default {
return await models.$queryRaw`
SELECT *
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}`
},
userStatsActions: async (parent, { when, from, to }, { me, models }) => {
@ -700,7 +720,7 @@ export default {
} else if (authType === 'nostr') {
user = await models.user.update({ where: { id: me.id }, data: { hideNostr: true, nostrAuthPubkey: null } })
} 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 {
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 { msatsToSats, msatsToSatsDecimal, ensureB64 } from '@/lib/format'
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 assertGofacYourself from './ofac'
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' } })
}
if (inv.user.id === ANON_USER_ID) {
if (inv.user.id === USER_ID.anon) {
return inv
}
if (!me) {
@ -339,7 +339,7 @@ export default {
expirePivot = { seconds: Math.min(expireSecs, 180) }
invLimit = ANON_INV_PENDING_LIMIT
balanceLimit = ANON_BALANCE_LIMIT_MSATS
id = ANON_USER_ID
id = USER_ID.anon
}
const user = await models.user.findUnique({ where: { id } })

View File

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

View File

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

View File

@ -95,6 +95,7 @@ export default gql`
noteItemSats: Boolean!
noteJobIndicator: Boolean!
noteMentions: Boolean!
noteItemMentions: Boolean!
nsfwMode: Boolean!
tipDefault: Int!
turboTipping: Boolean!
@ -161,6 +162,7 @@ export default gql`
noteItemSats: Boolean!
noteJobIndicator: Boolean!
noteMentions: Boolean!
noteItemMentions: Boolean!
nsfwMode: Boolean!
tipDefault: Int!
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
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
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>
)
}
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 { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
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 Item from './item'
import Item, { ItemSkeleton } from './item'
import { RootProvider } from './root'
import Comment from './comment'
@ -25,7 +25,7 @@ export function ClientNotificationProvider ({ children }) {
const me = useMe()
// anons don't have access to /notifications
// 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(() => {
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>
{!n.item
? null
? <ItemSkeleton />
: n.item.title
? <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 { useRouter } from 'next/router'
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 BountyIcon from '@/svgs/bounty-bag.svg'
import ActionTooltip from './action-tooltip'
@ -25,6 +25,7 @@ import Skull from '@/svgs/death-skull.svg'
import { commentSubTreeRootId } from '@/lib/item'
import Pin from '@/svgs/pushpin-fill.svg'
import LinkToContext from './link-to-context'
import { ItemContextProvider, useItemContext } from './item'
function Parent ({ item, rootText }) {
const root = useRoot()
@ -128,125 +129,137 @@ export default function Comment ({
const bottomedOut = depth === COMMENT_DEPTH_LIMIT
// 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'
: 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'
: null
const bountyPaid = root.bountyPaidTo?.includes(Number(item.id))
return (
<div
ref={ref} className={includeParent ? '' : `${styles.comment} ${collapse === 'yep' ? styles.collapsed : ''}`}
onMouseEnter={() => ref.current.classList.add('outline-new-comment-unset')}
onTouchStart={() => ref.current.classList.add('outline-new-comment-unset')}
>
<div className={`${itemStyles.item} ${styles.item}`}>
{item.outlawed && !me?.privates?.wildWestMode
? <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='d-flex align-items-center'>
{item.user?.meMute && !includeParent && collapse === 'yep'
? (
<span
className={`${itemStyles.other} ${styles.other} pointer`} onClick={() => {
setCollapse('nope')
window.localStorage.setItem(`commentCollapse:${item.id}`, 'nope')
}}
>reply from someone you muted
</span>)
: <ItemInfo
item={item}
commentsText='replies'
commentTextSingular='reply'
className={`${itemStyles.other} ${styles.other}`}
embellishUser={op && <><span> </span><Badge bg={op === 'fwd' ? 'secondary' : 'boost'} className={`${styles.op} bg-opacity-75`}>{op}</Badge></>}
onQuoteReply={quoteReply}
nested={!includeParent}
extraInfo={
<>
{includeParent && <Parent item={item} rootText={rootText} />}
{bountyPaid &&
<ActionTooltip notForm overlayText={`${numWithUnits(root.bounty)} paid`}>
<BountyIcon className={`${styles.bountyIcon} ${'fill-success vertical-align-middle'}`} height={16} width={16} />
</ActionTooltip>}
</>
<ItemContextProvider>
<div
ref={ref} className={includeParent ? '' : `${styles.comment} ${collapse === 'yep' ? styles.collapsed : ''}`}
onMouseEnter={() => ref.current.classList.add('outline-new-comment-unset')}
onTouchStart={() => ref.current.classList.add('outline-new-comment-unset')}
>
<div className={`${itemStyles.item} ${styles.item}`}>
<ZapIcon item={item} pin={pin} me={me} />
<div className={`${itemStyles.hunk} ${styles.hunk}`}>
<div className='d-flex align-items-center'>
{item.user?.meMute && !includeParent && collapse === 'yep'
? (
<span
className={`${itemStyles.other} ${styles.other} pointer`} onClick={() => {
setCollapse('nope')
window.localStorage.setItem(`commentCollapse:${item.id}`, 'nope')
}}
>reply from someone you muted
</span>)
: <ItemInfo
item={item}
commentsText='replies'
commentTextSingular='reply'
className={`${itemStyles.other} ${styles.other}`}
embellishUser={op && <><span> </span><Badge bg={op === 'fwd' ? 'secondary' : 'boost'} className={`${styles.op} bg-opacity-75`}>{op}</Badge></>}
onQuoteReply={quoteReply}
nested={!includeParent}
extraInfo={
<>
{includeParent && <Parent item={item} rootText={rootText} />}
{bountyPaid &&
<ActionTooltip notForm overlayText={`${numWithUnits(root.bounty)} paid`}>
<BountyIcon className={`${styles.bountyIcon} ${'fill-success vertical-align-middle'}`} height={16} width={16} />
</ActionTooltip>}
</>
}
onEdit={e => { setEdit(!edit) }}
editText={edit ? 'cancel' : 'edit'}
/>}
onEdit={e => { setEdit(!edit) }}
editText={edit ? 'cancel' : 'edit'}
/>}
{!includeParent && (collapse === 'yep'
? <Eye
className={styles.collapser} height={10} width={10} onClick={() => {
setCollapse('nope')
window.localStorage.setItem(`commentCollapse:${item.id}`, 'nope')
{!includeParent && (collapse === 'yep'
? <Eye
className={styles.collapser} height={10} width={10} onClick={() => {
setCollapse('nope')
window.localStorage.setItem(`commentCollapse:${item.id}`, 'nope')
}}
/>
: <EyeClose
className={styles.collapser} height={10} width={10} onClick={() => {
setCollapse('yep')
window.localStorage.setItem(`commentCollapse:${item.id}`, 'yep')
}}
/>)}
{topLevel && (
<span className='d-flex ms-auto align-items-center'>
<Share title={item?.title} path={`/items/${item?.id}`} />
</span>
)}
</div>
{edit
? (
<CommentEdit
comment={item}
onSuccess={() => {
setEdit(!edit)
}}
/>
: <EyeClose
className={styles.collapser} height={10} width={10} onClick={() => {
setCollapse('yep')
window.localStorage.setItem(`commentCollapse:${item.id}`, 'yep')
}}
/>)}
{topLevel && (
<span className='d-flex ms-auto align-items-center'>
<Share title={item?.title} path={`/items/${item?.id}`} />
</span>
)}
)
: (
<div className={styles.text} ref={textRef}>
{item.searchText
? <SearchText text={item.searchText} />
: (
<Text itemId={item.id} topLevel={topLevel} rel={item.rel ?? UNKNOWN_LINK_REL} outlawed={item.outlawed} imgproxyUrls={item.imgproxyUrls}>
{item.outlawed && !me?.privates?.wildWestMode
? '*stackers have outlawed this. turn on wild west mode in your [settings](/settings) to see outlawed content.*'
: truncate ? truncateString(item.text) : item.text}
</Text>)}
</div>
)}
</div>
{edit
? (
<CommentEdit
comment={item}
onSuccess={() => {
setEdit(!edit)
}}
/>
)
: (
<div className={styles.text} ref={textRef}>
{item.searchText
? <SearchText text={item.searchText} />
: (
<Text itemId={item.id} topLevel={topLevel} rel={item.rel ?? UNKNOWN_LINK_REL} outlawed={item.outlawed} imgproxyUrls={item.imgproxyUrls}>
{item.outlawed && !me?.privates?.wildWestMode
? '*stackers have outlawed this. turn on wild west mode in your [settings](/settings) to see outlawed content.*'
: truncate ? truncateString(item.text) : item.text}
</Text>)}
</div>
)}
</div>
</div>
{collapse !== 'yep' && (
bottomedOut
? <div className={styles.children}><ReplyOnAnotherPage item={item} /></div>
: (
<div className={styles.children}>
{item.outlawed && !me?.privates?.wildWestMode
? <div className='py-2' />
: !noReply &&
<Reply depth={depth + 1} item={item} replyOpen={replyOpen} onCancelQuote={cancelQuote} onQuoteReply={quoteReply} quote={quote}>
{root.bounty && !bountyPaid && <PayBounty item={item} />}
</Reply>}
{children}
<div className={styles.comments}>
{item.comments && !noComments
? item.comments.map((item) => (
<Comment depth={depth + 1} key={item.id} item={item} />
))
: null}
{collapse !== 'yep' && (
bottomedOut
? <div className={styles.children}><ReplyOnAnotherPage item={item} /></div>
: (
<div className={styles.children}>
{item.outlawed && !me?.privates?.wildWestMode
? <div className='py-2' />
: !noReply &&
<Reply depth={depth + 1} item={item} replyOpen={replyOpen} onCancelQuote={cancelQuote} onQuoteReply={quoteReply} quote={quote}>
{root.bounty && !bountyPaid && <PayBounty item={item} />}
</Reply>}
{children}
<div className={styles.comments}>
{item.comments && !noComments
? item.comments.map((item) => (
<Comment depth={depth + 1} key={item.id} item={item} />
))
: null}
</div>
</div>
</div>
)
)}
</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 }) {
return (
<div className={styles.comment}>

View File

@ -6,10 +6,12 @@ import Navbar from 'react-bootstrap/Navbar'
import { numWithUnits } from '@/lib/format'
import { defaultCommentSort } from '@/lib/item'
import { useRouter } from 'next/router'
import { ItemContextProvider, useItemContext } from './item'
export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats }) {
const router = useRouter()
const sort = router.query.sort || defaultCommentSort(pinned, bio, parentCreatedAt)
const { pendingCommentSats } = useItemContext()
const getHandleClick = sort => {
return () => {
@ -24,7 +26,7 @@ export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, comm
activeKey={sort}
>
<Nav.Item className='text-muted'>
{numWithUnits(commentSats)}
{numWithUnits(commentSats + pendingCommentSats)}
</Nav.Item>
<div className='ms-auto d-flex'>
<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)
return (
<>
<ItemContextProvider>
{comments?.length > 0
? <CommentsHeader
commentSats={commentSats} parentCreatedAt={parentCreatedAt}
@ -91,7 +93,7 @@ export default function Comments ({ parentId, pinned, bio, parentCreatedAt, comm
{comments.filter(({ position }) => !position).map(item => (
<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 AccordianItem from './accordian-item'
import Flag from '@/svgs/flag-fill.svg'
import { useMemo } from 'react'
import { useCallback, useMemo } from 'react'
import getColor from '@/lib/rainbow'
import { gql, useMutation } from '@apollo/client'
import { useItemContext } from './item'
import { useLightning } from './lightning'
export function DownZap ({ item, ...props }) {
const { pendingDownSats } = useItemContext()
const { meDontLikeSats } = item
const style = useMemo(() => (meDontLikeSats
const downSats = meDontLikeSats + pendingDownSats
const style = useMemo(() => (downSats
? {
fill: getColor(meDontLikeSats),
filter: `drop-shadow(0 0 6px ${getColor(meDontLikeSats)}90)`
fill: getColor(downSats),
filter: `drop-shadow(0 0 6px ${getColor(downSats)}90)`
}
: undefined), [meDontLikeSats])
: undefined), [downSats])
return (
<DownZapper item={item} As={({ ...oprops }) => <Flag {...props} {...oprops} style={style} />} />
)
@ -24,6 +30,17 @@ export function DownZap ({ item, ...props }) {
function DownZapper ({ item, As, children }) {
const toaster = useToast()
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 (
<As
@ -31,7 +48,7 @@ function DownZapper ({ item, As, children }) {
try {
showModal(onClose =>
<ItemAct
onClose={onClose} item={item} down
onClose={onClose} item={item} down optimisticUpdate={optimisticUpdate}
>
<AccordianItem
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 { InvoiceCanceledError, usePayment } from './payment'
import { useMe } from './me'
import { optimisticUpdate } from '@/lib/apollo'
import { useClientNotifications } from './client-notifications'
import { ActCanceledError } from './item-act'
@ -121,7 +120,7 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
const imageUploadRef = useRef(null)
const previousTab = useRef(tab)
const { merge, setDisabled: setSubmitDisabled } = useFeeButton()
const toaster = useToast()
const [updateImageFeesInfo] = useLazyQuery(gql`
query imageFeesInfo($s3Keys: [Int]!) {
imageFeesInfo(s3Keys: $s3Keys) {
@ -135,7 +134,6 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
nextFetchPolicy: 'no-cache',
onError: (err) => {
console.error(err)
toaster.danger(`unabled to get image fees: ${err.message || err.toString?.()}`)
},
onCompleted: ({ imageFeesInfo }) => {
merge({
@ -805,7 +803,7 @@ const StorageKeyPrefixContext = createContext()
export function Form ({
initial, schema, onSubmit, children, initialError, validateImmediately,
storageKeyPrefix, validateOnChange = true, prepaid, requireSession, innerRef,
optimisticUpdate: optimisticUpdateArgs, clientNotification, signal, ...props
optimisticUpdate, clientNotification, signal, ...props
}) {
const toaster = useToast()
const initialErrorToasted = useRef(false)
@ -845,9 +843,7 @@ export function Form ({
throw new SessionRequiredError()
}
if (optimisticUpdateArgs) {
revert = optimisticUpdate(optimisticUpdateArgs(variables))
}
revert = optimisticUpdate?.(variables)
await signal?.pause({ me, amount })
@ -866,8 +862,6 @@ export function Form ({
clearLocalStorage(values)
}
} catch (err) {
revert?.()
if (err instanceof InvoiceCanceledError || err instanceof ActCanceledError) {
return
}
@ -881,6 +875,7 @@ export function Form ({
cancel?.()
} finally {
revert?.()
// 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
// 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 AnonIcon from '@/svgs/spy-fill.svg'
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 }) {
if (!user || Number(user.id) === AD_USER_ID) return null
if (Number(user.id) === ANON_USER_ID) {
if (!user || Number(user.id) === USER_ID.ad) return null
if (Number(user.id) === USER_ID.anon) {
return (
<HatTooltip overlayText='anonymous'>
{badge

View File

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

View File

@ -15,13 +15,14 @@ import BookmarkDropdownItem from './bookmark'
import SubscribeDropdownItem from './subscribe'
import { CopyLinkDropdownItem, CrosspostDropdownItem } from './share'
import Hat from './hat'
import { AD_USER_ID } from '@/lib/constants'
import { USER_ID } from '@/lib/constants'
import ActionDropdown from './action-dropdown'
import MuteDropdownItem from './mute'
import { DropdownItemUpVote } from './upvote'
import { useRoot } from './root'
import { MuteSubDropdownItem, PinSubDropdownItem } from './territory-header'
import UserPopover from './user-popover'
import { useItemContext } from './item'
export default function ItemInfo ({
item, full, commentsText = 'comments',
@ -36,6 +37,7 @@ export default function ItemInfo ({
const [hasNewComments, setHasNewComments] = useState(false)
const [meTotalSats, setMeTotalSats] = useState(0)
const root = useRoot()
const { pendingSats, pendingCommentSats, pendingDownSats } = useItemContext()
const sub = item?.sub || root?.sub
useEffect(() => {
@ -45,8 +47,8 @@ export default function ItemInfo ({
}, [item])
useEffect(() => {
if (item) setMeTotalSats((item.meSats || 0) + (item.meAnonSats || 0))
}, [item?.meSats, item?.meAnonSats])
if (item) setMeTotalSats((item.meSats || 0) + (item.meAnonSats || 0) + (pendingSats))
}, [item?.meSats, item?.meAnonSats, pendingSats])
// territory founders can pin any post in their territory
// 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 canPin = (isPost && mySub) || (myPost && rootReply)
const downSats = item.meDontLikeSats + pendingDownSats
return (
<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, {
abbreviate: false,
@ -66,11 +70,11 @@ export default function ItemInfo ({
unitPlural: 'stackers'
})} ${item.mine
? `\\ ${numWithUnits(item.meSats, { abbreviate: false })} to post`
: `(${numWithUnits(meTotalSats, { abbreviate: false })}${item.meDontLikeSats
? ` & ${numWithUnits(item.meDontLikeSats, { abbreviate: false, unitSingular: 'downsat', unitPlural: 'downsats' })}`
: `(${numWithUnits(meTotalSats, { abbreviate: false })}${downSats
? ` & ${numWithUnits(downSats, { abbreviate: false, unitSingular: 'downsat', unitPlural: 'downsats' })}`
: ''} from me)`} `}
>
{numWithUnits(item.sats)}
{numWithUnits(item.sats + pendingSats)}
</span>
<span> \ </span>
</>}
@ -88,7 +92,7 @@ export default function ItemInfo ({
`/items/${item.id}?commentsViewedAt=${viewedAt}`,
`/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, {
abbreviate: false,
@ -177,7 +181,7 @@ export default function ItemInfo ({
<CrosspostDropdownItem item={item} />}
{me && !item.position &&
!item.mine && !item.deletedAt &&
(item.meDontLikeSats > meTotalSats
(downSats > meTotalSats
? <DropdownItemUpVote item={item} />
: <DontLikeThisDropdownItem item={item} />)}
{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 styles from './item.module.css'
import UpVote from './upvote'
import { useRef } from 'react'
import { AD_USER_ID, UNKNOWN_LINK_REL } from '@/lib/constants'
import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react'
import { USER_ID, UNKNOWN_LINK_REL } from '@/lib/constants'
import Pin from '@/svgs/pushpin-fill.svg'
import reactStringReplace from 'react-string-replace'
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 }) {
const titleRef = useRef()
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)
return (
<>
<ItemContextProvider>
{rank
? (
<div className={styles.rank}>
@ -60,13 +106,7 @@ export default function Item ({ item, rank, belowTitle, right, full, children, s
</div>)
: <div />}
<div className={`${styles.item} ${siblingComments ? 'pt-3' : ''}`}>
{item.position && (pinnable || !item.subName)
? <Pin width={24} height={24} className={styles.pin} />
: item.meDontLikeSats > item.meSats
? <DownZap width={24} height={24} className={styles.dontLike} item={item} />
: Number(item.user?.id) === AD_USER_ID
? <AdIcon width={24} height={24} className={styles.ad} />
: <UpVote item={item} className={styles.upvote} />}
<ZapIcon item={item} pinnable={pinnable} />
<div className={styles.hunk}>
<div className={`${styles.main} flex-wrap`}>
<Link
@ -99,7 +139,7 @@ export default function Item ({ item, rank, belowTitle, right, full, children, s
full={full} item={item}
onQuoteReply={onQuoteReply}
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}
</div>
@ -110,7 +150,7 @@ export default function Item ({ item, rank, belowTitle, right, full, children, s
{children}
</div>
)}
</>
</ItemContextProvider>
)
}
@ -130,7 +170,7 @@ export function ItemSummary ({ item }) {
item={item}
showUser={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 }) {
const hasExpiration = !!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 Price from '../price'
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 NoteIcon from '../../svgs/notification-4-fill.svg'
import { useMe } from '../me'
@ -307,7 +307,7 @@ export function AnonDropdown ({ path }) {
<Dropdown className={styles.dropdown} align='end'>
<Dropdown.Toggle className='nav-link nav-item' id='profile' variant='custom'>
<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>
</Dropdown.Toggle>
<Dropdown.Menu className='p-3'>

View File

@ -36,13 +36,8 @@ import { ITEM_FULL } from '@/fragments/items'
function Notification ({ n, fresh }) {
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 (
<NotificationLayout nid={nid(n)} {...defaultOnClick(itemN)} fresh={fresh}>
<NotificationLayout nid={nid(n)} {...defaultOnClick(n)} fresh={fresh}>
{
(type === 'Earn' && <EarnNotification n={n} />) ||
(type === 'Revenue' && <RevenueNotification n={n} />) ||
@ -54,6 +49,7 @@ function Notification ({ n, fresh }) {
(type === 'Votification' && <Votification n={n} />) ||
(type === 'ForwardedVotification' && <ForwardedVotification n={n} />) ||
(type === 'Mention' && <Mention n={n} />) ||
(type === 'ItemMention' && <ItemMention n={n} />) ||
(type === 'JobChanged' && <JobChanged n={n} />) ||
(type === 'Reply' && <Reply n={n} />) ||
(type === 'SubStatus' && <SubStatus n={n} />) ||
@ -61,15 +57,26 @@ function Notification ({ n, fresh }) {
(type === 'TerritoryPost' && <TerritoryPost n={n} />) ||
(type === 'TerritoryTransfer' && <TerritoryTransfer n={n} />) ||
(type === 'Reminder' && <Reminder n={n} />) ||
([ClientTypes.Zap.ERROR, ClientTypes.Zap.PENDING].includes(type) && <ClientZap n={itemN} />) ||
([ClientTypes.Reply.ERROR, ClientTypes.Reply.PENDING].includes(type) && <ClientReply n={itemN} />) ||
([ClientTypes.Bounty.ERROR, ClientTypes.Bounty.PENDING].includes(type) && <ClientBounty n={itemN} />) ||
([ClientTypes.PollVote.ERROR, ClientTypes.PollVote.PENDING].includes(type) && <ClientPollVote n={itemN} />)
<ClientNotification n={n} />
}
</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 }) {
const router = useRouter()
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 }) {
return (
<>

View File

@ -6,9 +6,8 @@ import { useMe } from './me'
import { numWithUnits } from '@/lib/format'
import { useShowModal } from './modal'
import { useRoot } from './root'
import { useAct, actUpdate, ACT_MUTATION } from './item-act'
import { useAct, actUpdate } from './item-act'
import { InvoiceCanceledError, usePayment } from './payment'
import { optimisticUpdate } from '@/lib/apollo'
import { useLightning } from './lightning'
import { useToast } from './toast'
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 optimisticResponse = { act: { ...variables, path: item.path } }
let revert, cancel, nid
let cancel, nid
try {
revert = optimisticUpdate({
mutation: ACT_MUTATION,
variables,
optimisticResponse,
update: actUpdate({ me, onUpdate: onUpdate(onComplete) })
})
if (me) {
nid = notify(ClientNotification.Bounty.PENDING, notifyProps)
}
let hash, hmac;
[{ hash, hmac }, cancel] = await payment.request(sats)
await act({
variables: { hash, hmac, ...variables },
optimisticResponse: {
act: variables
}
optimisticResponse,
update: actUpdate({ me, onUpdate: onUpdate(onComplete) })
})
} catch (error) {
revert?.()
if (error instanceof InvoiceCanceledError) {
return
}

View File

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

View File

@ -186,6 +186,7 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
} catch {
// ignore invalid URLs
}
const internalURL = process.env.NEXT_PUBLIC_URL
if (!!text && !/^https?:\/\//.test(text)) {
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>
)
} 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 (
<Link
id={props.id}
@ -226,7 +240,7 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
}
try {
const linkText = parseInternalLinks(href)
const { linkText } = parseInternalLinks(href)
if (linkText) {
return (
<ItemPopover id={linkText.replace('#', '').split('/')[0]}>
@ -244,39 +258,35 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
paddingRight: '15px'
}
try {
const { provider, id, meta } = parseEmbedUrl(href)
// Youtube video embed
if (provider === 'youtube') {
return (
<div style={videoWrapperStyles}>
<YouTube
videoId={id} className={styles.videoContainer} opts={{
playerVars: {
start: meta?.start || 0
}
}}
const { provider, id, meta } = parseEmbedUrl(href)
// Youtube video embed
if (provider === 'youtube') {
return (
<div style={videoWrapperStyles}>
<YouTube
videoId={id} className={styles.videoContainer} opts={{
playerVars: {
start: meta?.start || 0
}
}}
/>
</div>
)
}
// Rumble video embed
if (provider === 'rumble') {
return (
<div style={videoWrapperStyles}>
<div className={styles.videoContainer}>
<iframe
title='Rumble Video'
allowFullScreen=''
src={meta?.href}
/>
</div>
)
}
// Rumble video embed
if (provider === 'rumble') {
return (
<div style={videoWrapperStyles}>
<div className={styles.videoContainer}>
<iframe
title='Rumble Video'
allowFullScreen=''
src={meta?.href}
/>
</div>
</div>
)
}
} catch {
// ignore invalid URLs
</div>
)
}
// assume the link is an image which will fallback to link if it's not

View File

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

View File

@ -9,7 +9,7 @@ import { Status } from './webln'
export const isConfigured = status => [Status.Enabled, Status.Locked, Status.Error, true].includes(status)
export function WalletCard ({ title, badges, provider, status }) {
export function WalletCard ({ title, badges, provider, status, href }) {
const configured = isConfigured(status)
let indicator = styles.disabled
switch (status) {
@ -42,14 +42,13 @@ export function WalletCard ({ title, badges, provider, status }) {
</Badge>)}
</Card.Subtitle>
</Card.Body>
{provider &&
<Link href={`/settings/wallets/${provider}`}>
<Card.Footer className={styles.attach}>
{configured
? <>configure<Gear width={14} height={14} /></>
: <>attach<Plug width={14} height={14} /></>}
</Card.Footer>
</Link>}
<Link href={href}>
<Card.Footer className={styles.attach}>
{configured
? <>configure<Gear width={14} height={14} /></>
: <>attach<Plug width={14} height={14} /></>}
</Card.Footer>
</Link>
</Card>
)
}

View File

@ -0,0 +1,70 @@
import { WalletSecurityBanner } from './banners'
import { Form } from './form'
import { CenterLayout } from './layout'
export default function WalletConfigurator ({ config }) {
const initial = config.provider
return (
<CenterLayout>
<h2 className='pb-2'>{config.title}</h2>
<h6 className='text-muted text-center pb-3'>use {config.title} for payments</h6>
<WalletSecurityBanner />
<Form
initial={{
url: url || '',
adminKey: adminKey || '',
isDefault: isDefault || false
}}
schema={lnbitsSchema}
onSubmit={async ({ isDefault, ...values }) => {
try {
await saveConfig(values)
if (isDefault) setProvider(lnbits)
toaster.success('saved settings')
router.push('/settings/wallets')
} catch (err) {
console.error(err)
toaster.danger('failed to attach: ' + err.message || err.toString?.())
}
}}
>
<ClientInput
initialValue={url}
label='lnbits url'
name='url'
required
autoFocus
/>
<PasswordInput
initialValue={adminKey}
label='admin key'
name='adminKey'
newPass
required
/>
<ClientCheckbox
disabled={!configured || isDefault || enabledProviders.length === 1}
initialValue={isDefault}
label='default payment method'
name='isDefault'
/>
<WalletButtonBar
status={status} onDelete={async () => {
try {
await clearConfig()
toaster.success('saved settings')
router.push('/settings/wallets')
} catch (err) {
console.error(err)
toaster.danger('failed to detach: ' + err.message || err.toString?.())
}
}}
/>
</Form>
<div className='mt-3 w-100'>
<WalletLogs wallet={Wallet.LNbits} embedded />
</div>
</CenterLayout>
)
}

View File

@ -2,6 +2,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState }
import { LNbitsProvider, useLNbits } from './lnbits'
import { NWCProvider, useNWC } from './nwc'
import { LNCProvider, useLNC } from './lnc'
import lnbits from './lnbits2'
const WebLNContext = createContext({})

View File

@ -0,0 +1,22 @@
import { createContext, useContext, useMemo, useState } from 'react'
import lnbits from './lnbits2'
const storageKey = 'webln:providers'
const WebLNContext = createContext({})
export function useWebLN () {
const { provider } = useContext(WebLNContext)
return provider
}
export function RawWebLNProvider ({ children }) {
const [provider, setProvider] = useState()
const value = useMemo(() => ({ provider, setProvider }), [])
return (
<WebLNContext.Provider value={value}>
{children}
</WebLNContext.Provider>
)
}

View File

@ -67,8 +67,7 @@ const getPayment = async (baseUrl, adminKey, paymentHash) => {
export function LNbitsProvider ({ children }) {
const me = useMe()
const [url, setUrl] = useState('')
const [adminKey, setAdminKey] = useState('')
const [config, setConfig] = useState({})
const [status, setStatus] = useState()
const { logger } = useWalletLogger(Wallet.LNbits)
@ -78,7 +77,7 @@ export function LNbitsProvider ({ children }) {
}
const getInfo = useCallback(async () => {
const response = await getWallet(url, adminKey)
const response = await getWallet(config.url, config.adminKey)
return {
node: {
alias: response.name,
@ -92,9 +91,11 @@ export function LNbitsProvider ({ children }) {
version: '1.0',
supports: ['lightning']
}
}, [url, adminKey])
}, [config])
const sendPayment = useCallback(async (bolt11) => {
const { url, adminKey } = config
const hash = bolt11Tags(bolt11).payment_hash
logger.info('sending payment:', `payment_hash=${hash}`)
@ -111,7 +112,7 @@ export function LNbitsProvider ({ children }) {
logger.error('payment failed:', `payment_hash=${hash}`, err.message || err.toString?.())
throw err
}
}, [logger, url, adminKey])
}, [logger, config])
const loadConfig = useCallback(async () => {
let configStr = window.localStorage.getItem(storageKey)
@ -129,20 +130,17 @@ export function LNbitsProvider ({ children }) {
}
const config = JSON.parse(configStr)
const { url, adminKey } = config
setUrl(url)
setAdminKey(adminKey)
setConfig(config)
logger.info(
'loaded wallet config: ' +
'adminKey=****** ' +
`url=${url}`)
`url=${config.url}`)
try {
// validate config by trying to fetch wallet
logger.info('trying to fetch wallet')
await getWallet(url, adminKey)
await getWallet(config.url, config.adminKey)
logger.ok('wallet found')
setStatus(Status.Enabled)
logger.ok('wallet enabled')
@ -156,8 +154,7 @@ export function LNbitsProvider ({ children }) {
const saveConfig = useCallback(async (config) => {
// immediately store config so it's not lost even if config is invalid
setUrl(config.url)
setAdminKey(config.adminKey)
setConfig(config)
// XXX This is insecure, XSS vulns could lead to loss of funds!
// -> check how mutiny encrypts their wallet and/or check if we can leverage web workers
@ -186,8 +183,7 @@ export function LNbitsProvider ({ children }) {
const clearConfig = useCallback(() => {
window.localStorage.removeItem(storageKey)
setUrl('')
setAdminKey('')
setConfig({})
setStatus(undefined)
}, [])
@ -196,8 +192,8 @@ export function LNbitsProvider ({ children }) {
}, [])
const value = useMemo(
() => ({ name: 'LNbits', url, adminKey, status, saveConfig, clearConfig, getInfo, sendPayment }),
[url, adminKey, status, saveConfig, clearConfig, getInfo, sendPayment])
() => ({ name: 'LNbits', ...config, status, saveConfig, clearConfig, getInfo, sendPayment }),
[config, status, saveConfig, clearConfig, getInfo, sendPayment])
return (
<LNbitsContext.Provider value={value}>
{children}

124
components/webln/lnbits2.js Normal file
View File

@ -0,0 +1,124 @@
import { bolt11Tags } from '@/lib/bolt11'
export const name = 'LNbits'
export const config = {
provider: {
url: {
label: 'lnbits url',
type: 'text'
},
adminKey: {
label: 'admin key',
type: 'password'
}
},
card: {
title: 'LNbits',
badges: ['send only', 'non-custodialish'],
href: '/settings/wallets/lnbits'
}
}
export function getInfo ({ config, logger }) {
return async function () {
const response = await getWallet(config.url, config.adminKey)
return {
node: {
alias: response.name,
pubkey: ''
},
methods: [
'getInfo',
'getBalance',
'sendPayment'
],
version: '1.0',
supports: ['lightning']
}
}
}
export function sendPayment ({ config, logger }) {
return async function (bolt11) {
const { url, adminKey } = config
const hash = bolt11Tags(bolt11).payment_hash
logger.info('sending payment:', `payment_hash=${hash}`)
try {
const response = await postPayment(url, adminKey, bolt11)
const checkResponse = await getPayment(url, adminKey, response.payment_hash)
if (!checkResponse.preimage) {
throw new Error('No preimage')
}
const preimage = checkResponse.preimage
logger.ok('payment successful:', `payment_hash=${hash}`, `preimage=${preimage}`)
return { preimage }
} catch (err) {
logger.error('payment failed:', `payment_hash=${hash}`, err.message || err.toString?.())
throw err
}
}
}
async function getWallet (baseUrl, adminKey) {
const url = baseUrl.replace(/\/+$/, '')
const path = '/api/v1/wallet'
const headers = new Headers()
headers.append('Accept', 'application/json')
headers.append('Content-Type', 'application/json')
headers.append('X-Api-Key', adminKey)
const res = await fetch(url + path, { method: 'GET', headers })
if (!res.ok) {
const errBody = await res.json()
throw new Error(errBody.detail)
}
const wallet = await res.json()
return wallet
}
async function postPayment (baseUrl, adminKey, bolt11) {
const url = baseUrl.replace(/\/+$/, '')
const path = '/api/v1/payments'
const headers = new Headers()
headers.append('Accept', 'application/json')
headers.append('Content-Type', 'application/json')
headers.append('X-Api-Key', adminKey)
const body = JSON.stringify({ bolt11, out: true })
const res = await fetch(url + path, { method: 'POST', headers, body })
if (!res.ok) {
const errBody = await res.json()
throw new Error(errBody.detail)
}
const payment = await res.json()
return payment
}
async function getPayment (baseUrl, adminKey, paymentHash) {
const url = baseUrl.replace(/\/+$/, '')
const path = `/api/v1/payments/${paymentHash}`
const headers = new Headers()
headers.append('Accept', 'application/json')
headers.append('Content-Type', 'application/json')
headers.append('X-Api-Key', adminKey)
const res = await fetch(url + path, { method: 'GET', headers })
if (!res.ok) {
const errBody = await res.json()
throw new Error(errBody.detail)
}
const payment = await res.json()
return payment
}

View File

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

View File

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

View File

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

View File

@ -38,6 +38,7 @@ export const ME = gql`
noteItemSats
noteJobIndicator
noteMentions
noteItemMentions
sats
tipDefault
tipPopover
@ -73,6 +74,7 @@ export const SETTINGS_FIELDS = gql`
noteEarning
noteAllDescendants
noteMentions
noteItemMentions
noteDeposits
noteWithdrawals
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 INV_PENDING_LIMIT = 100
export const BALANCE_LIMIT_MSATS = 100000000 // 100k sat
export const SN_USER_IDS = [616, 6030, 4502, 27]
export const SN_NO_REWARDS_IDS = [27, 4502]
export const USER_ID = {
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_BALANCE_LIMIT_MSATS = 0 // disable
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_UNIVERSAL = ['all', 'posts', 'comments', 'freebies']
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 SSR = typeof window === 'undefined'
export const MAX_FORWARDS = 5
export const LNURLP_COMMENT_MAX_LENGTH = 1000
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 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
// From lawyers: north korea, cuba, iran, ukraine, syria

View File

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

View File

@ -24,7 +24,7 @@ describe('internal links', () => {
'parses %p as %p',
(href, expected) => {
process.env.NEXT_PUBLIC_URL = 'https://stacker.news'
const actual = parseInternalLinks(href)
const { linkText: actual } = parseInternalLinks(href)
expect(actual).toBe(expected)
}
)

View File

@ -1,6 +1,6 @@
import webPush from 'web-push'
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 models from '@/api/models'
import { isMuted } from '@/lib/user'
@ -38,6 +38,7 @@ const createUserFilter = (tag) => {
const tagMap = {
REPLY: 'noteAllDescendants',
MENTION: 'noteMentions',
ITEM_MENTION: 'noteItemMentions',
TIP: 'noteItemSats',
FORWARDEDTIP: 'noteForwardedSats',
REFERRAL: 'noteInvites',
@ -179,7 +180,7 @@ export const notifyTerritorySubscribers = async ({ models, item }) => {
export const notifyItemParents = async ({ models, item, me }) => {
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(
'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)',
@ -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) => {
try {
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 { useField } from 'formik'
import styles from './settings.module.css'
import { AuthBanner } from '@/components/banners'
export const getServerSideProps = getGetServerSideProps({ query: SETTINGS, authRequired: true })
@ -106,7 +107,7 @@ export default function Settings ({ ssrData }) {
return (
<Layout>
<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 />
<Form
initial={{
@ -120,6 +121,7 @@ export default function Settings ({ ssrData }) {
noteEarning: settings?.noteEarning,
noteAllDescendants: settings?.noteAllDescendants,
noteMentions: settings?.noteMentions,
noteItemMentions: settings?.noteItemMentions,
noteDeposits: settings?.noteDeposits,
noteWithdrawals: settings?.noteWithdrawals,
noteInvites: settings?.noteInvites,
@ -280,6 +282,11 @@ export default function Settings ({ ssrData }) {
name='noteMentions'
groupClassName='mb-0'
/>
<Checkbox
label='someone mentions one of my items'
name='noteItemMentions'
groupClassName='mb-0'
/>
<Checkbox
label='there is a new job'
name='noteJobIndicator'
@ -1001,10 +1008,9 @@ const ZapUndosField = () => {
zap undos
<Info>
<ul className='fw-bold'>
<li>An undo button is shown after every zap that exceeds or is equal to the threshold</li>
<li>The button is shown for {ZAP_UNDO_DELAY_MS / 1000} seconds</li>
<li>The button is only shown for zaps from the custodial wallet</li>
<li>Use a budget or manual approval with attached wallets</li>
<li>After every zap that exceeds or is equal to the threshold, the bolt will pulse</li>
<li>You can undo the zap if you click the bolt while it's pulsing</li>
<li>The bolt will pulse for {ZAP_UNDO_DELAY_MS / 1000} seconds</li>
</ul>
</Info>
</div>

View File

@ -2,28 +2,32 @@ import { getGetServerSideProps } from '@/api/ssrApollo'
import Layout from '@/components/layout'
import styles from '@/styles/wallet.module.css'
import { WalletCard } from '@/components/wallet-card'
import { LightningAddressWalletCard } from './lightning-address'
import { LNbitsCard } from './lnbits'
import { NWCCard } from './nwc'
import { LNDCard } from './lnd'
import { CLNCard } from './cln'
// import { LightningAddressWalletCard } from './lightning-address'
// import { LNbitsCard } from './lnbits'
// import { NWCCard } from './nwc'
// import { LNDCard } from './lnd'
// import { CLNCard } from './cln'
import { WALLETS } from '@/fragments/wallet'
import { useQuery } from '@apollo/client'
import PageLoading from '@/components/page-loading'
import { LNCCard } from './lnc'
// import { useQuery } from '@apollo/client'
// import PageLoading from '@/components/page-loading'
// import { LNCCard } from './lnc'
import Link from 'next/link'
import { Wallet as W } from '@/lib/constants'
// import { Wallet as W } from '@/lib/constants'
import { config as lnbitsConfig } from '@/components/webln/lnbits2'
// TODO: load configs without individual imports?
const walletConfigs = [lnbitsConfig]
export const getServerSideProps = getGetServerSideProps({ query: WALLETS, authRequired: true })
export default function Wallet ({ ssrData }) {
const { data } = useQuery(WALLETS)
if (!data && !ssrData) return <PageLoading />
const { wallets } = data || ssrData
const lnd = wallets.find(w => w.type === W.LND.type)
const lnaddr = wallets.find(w => w.type === W.LnAddr.type)
const cln = wallets.find(w => w.type === W.CLN.type)
// const { data } = useQuery(WALLETS)
//
// if (!data && !ssrData) return <PageLoading />
// const { wallets } = data || ssrData
// const lnd = wallets.find(w => w.type === W.LND.type)
// const lnaddr = wallets.find(w => w.type === W.LnAddr.type)
// const cln = wallets.find(w => w.type === W.CLN.type)
return (
<Layout>
@ -36,15 +40,9 @@ export default function Wallet ({ ssrData }) {
</Link>
</div>
<div className={styles.walletGrid}>
<LightningAddressWalletCard wallet={lnaddr} />
<LNDCard wallet={lnd} />
<CLNCard wallet={cln} />
<LNbitsCard />
<NWCCard />
<LNCCard />
<WalletCard title='coming soon' badges={['probably']} />
<WalletCard title='coming soon' badges={['we hope']} />
<WalletCard title='coming soon' badges={['tm']} />
{walletConfigs.map((config, i) => (
<WalletCard key={i} {...config.card} />
))}
</div>
</div>
</Layout>

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

43
sndev
View File

@ -159,6 +159,11 @@ OPTIONS"
sndev__logs() {
shift
if [ $# -eq 1 ]; then
docker__compose logs -t --tail=1000 -f "$@"
exit 0
fi
docker__compose logs "$@"
}
@ -219,6 +224,43 @@ USAGE
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() {
shift
docker__stacker_lnd -t payinvoice "$@"
@ -516,6 +558,7 @@ COMMANDS
sn:
login login as a nym
fund_user fund a nym without using an LN invoice
lnd:
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
// ---
// 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
const SUM_SATS_TAGS = ['DEPOSIT', 'WITHDRAWAL']
// 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`
} else if (compareTag === 'MENTION') {
title = `you were mentioned ${amount} times`
} else if (compareTag === 'ITEM_MENTION') {
title = `your items were mentioned ${amount} times`
} else if (compareTag === 'REFERRAL') {
title = `${amount} stackers joined via your referral links`
} else if (compareTag === 'INVITE') {

View File

@ -1,5 +1,5 @@
import { deleteObjects } from '@/api/s3'
import { ANON_USER_ID } from '@/lib/constants'
import { USER_ID } from '@/lib/constants'
export async function deleteUnusedImages ({ models }) {
// 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 "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)
if (s3Keys.length === 0) {

View File

@ -1,5 +1,5 @@
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 }) {
try {
@ -127,7 +127,7 @@ async function getGraph (models) {
FROM "ItemAct"
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"
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
HAVING CASE WHEN
"ItemAct".act = 'DONT_LIKE_THIS' THEN sum("ItemAct".msats) > ${AGAINST_MSAT_MIN}