Compare commits
9 Commits
3d3dc52cec
...
061d3f220d
Author | SHA1 | Date | |
---|---|---|---|
|
061d3f220d | ||
|
c90eb055c7 | ||
|
7b667821d2 | ||
|
b4c120ab39 | ||
|
e3571af1e1 | ||
|
2597eb56f3 | ||
|
d454bbdb72 | ||
|
86b857b8d4 | ||
|
e9aa268996 |
@ -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: {
|
||||
|
@ -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)
|
||||
|
@ -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'
|
||||
@ -876,7 +876,7 @@ export default {
|
||||
models.$queryRaw`
|
||||
SELECT
|
||||
item_act(${Number(id)}::INTEGER,
|
||||
${me?.id || ANON_USER_ID}::INTEGER, ${act}::"ItemActType", ${Number(sats)}::INTEGER)`,
|
||||
${me?.id || USER_ID.anon}::INTEGER, ${act}::"ItemActType", ${Number(sats)}::INTEGER)`,
|
||||
{ models, lnd, me, hash, hmac, fee: sats }
|
||||
)
|
||||
}
|
||||
@ -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/)(?<id>\\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,110 @@ 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 => {
|
||||
if (m.startsWith('#')) return Number(m.slice(1))
|
||||
// is not #<id> syntax but full URL
|
||||
return Number(m.split('/').slice(-1)[0])
|
||||
})
|
||||
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 +1312,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 +1337,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 })
|
||||
@ -1303,7 +1374,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)) {
|
||||
@ -1367,7 +1438,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 +1460,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)
|
||||
|
@ -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 })
|
||||
},
|
||||
|
@ -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,7 +165,7 @@ export default {
|
||||
await ssValidate(amountSchema, { amount: sats })
|
||||
|
||||
await serialize(
|
||||
models.$queryRaw`SELECT donate(${sats}::INTEGER, ${me?.id || ANON_USER_ID}::INTEGER)`,
|
||||
models.$queryRaw`SELECT donate(${sats}::INTEGER, ${me?.id || USER_ID.anon}::INTEGER)`,
|
||||
{ models, lnd, me, hash, hmac, fee: sats }
|
||||
)
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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 }) => {
|
||||
|
@ -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 } })
|
||||
|
@ -46,7 +46,6 @@ export default gql`
|
||||
id: ID,
|
||||
option: String!
|
||||
count: Int!
|
||||
meVoted: Boolean!
|
||||
}
|
||||
|
||||
type Poll {
|
||||
|
@ -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
|
||||
|
@ -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!
|
||||
|
@ -105,3 +105,6 @@ 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
|
||||
|
|
@ -2,7 +2,7 @@ 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 { RootProvider } from './root'
|
||||
@ -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)
|
||||
|
@ -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'
|
||||
@ -128,9 +128,9 @@ 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))
|
||||
|
@ -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
|
||||
|
@ -15,7 +15,7 @@ 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'
|
||||
@ -58,7 +58,7 @@ export default function ItemInfo ({
|
||||
|
||||
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,
|
||||
|
@ -2,7 +2,7 @@ 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 { 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'
|
||||
@ -64,7 +64,7 @@ export default function Item ({ item, rank, belowTitle, right, full, children, s
|
||||
? <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
|
||||
: Number(item.user?.id) === USER_ID.ad
|
||||
? <AdIcon width={24} height={24} className={styles.ad} />
|
||||
: <UpVote item={item} className={styles.upvote} />}
|
||||
<div className={styles.hunk}>
|
||||
@ -99,7 +99,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>
|
||||
@ -130,7 +130,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>}
|
||||
/>
|
||||
)
|
||||
|
||||
|
@ -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'>
|
||||
|
@ -54,6 +54,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} />) ||
|
||||
@ -391,6 +392,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 (
|
||||
<>
|
||||
|
@ -4,7 +4,6 @@ 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'
|
||||
@ -40,9 +39,6 @@ export default function Poll ({ item }) {
|
||||
fields: {
|
||||
count (existingCount) {
|
||||
return existingCount + 1
|
||||
},
|
||||
meVoted () {
|
||||
return true
|
||||
}
|
||||
}
|
||||
})
|
||||
@ -121,7 +117,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>
|
||||
|
@ -23,6 +23,7 @@ import { UNKNOWN_LINK_REL } from '@/lib/constants'
|
||||
import isEqual from 'lodash/isEqual'
|
||||
import UserPopover from './user-popover'
|
||||
import ItemPopover from './item-popover'
|
||||
import ref from '@/lib/remark-ref2link'
|
||||
|
||||
export function SearchText ({ text }) {
|
||||
return (
|
||||
@ -186,6 +187,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 +212,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}
|
||||
@ -284,7 +299,7 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
|
||||
},
|
||||
img: Img
|
||||
}}
|
||||
remarkPlugins={[gfm, mention, sub]}
|
||||
remarkPlugins={[gfm, mention, sub, ref]}
|
||||
rehypePlugins={[rehypeInlineCodeProperty]}
|
||||
>
|
||||
{children}
|
||||
|
@ -126,7 +126,6 @@ export const POLL_FIELDS = gql`
|
||||
id
|
||||
option
|
||||
count
|
||||
meVoted
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
@ -25,6 +25,14 @@ export const NOTIFICATIONS = gql`
|
||||
text
|
||||
}
|
||||
}
|
||||
... on ItemMention {
|
||||
id
|
||||
sortTime
|
||||
item {
|
||||
...ItemFullFields
|
||||
text
|
||||
}
|
||||
}
|
||||
... on Votification {
|
||||
id
|
||||
sortTime
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
26
lib/remark-ref2link.js
Normal file
26
lib/remark-ref2link.js
Normal file
@ -0,0 +1,26 @@
|
||||
import { findAndReplace } from 'mdast-util-find-and-replace'
|
||||
|
||||
const refRegex = /#(\d+(\/(edit|related|ots))?)/gi
|
||||
|
||||
export default function ref (options) {
|
||||
return function transformer (tree) {
|
||||
findAndReplace(
|
||||
tree,
|
||||
[
|
||||
[refRegex, replaceRef]
|
||||
],
|
||||
{ ignore: ['link', 'linkReference'] }
|
||||
)
|
||||
}
|
||||
|
||||
function replaceRef (value, itemId, match) {
|
||||
const node = { type: 'text', value }
|
||||
|
||||
return {
|
||||
type: 'link',
|
||||
title: null,
|
||||
url: `/items/${itemId}`,
|
||||
children: [node]
|
||||
}
|
||||
}
|
||||
}
|
14
lib/url.js
14
lib/url.js
@ -26,14 +26,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']
|
||||
|
@ -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' })
|
||||
|
@ -120,6 +120,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 +281,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'
|
||||
|
@ -0,0 +1,92 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "PollBlindVote" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"itemId" INTEGER NOT NULL,
|
||||
"userId" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "PollBlindVote_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PollBlindVote.userId_index" ON "PollBlindVote"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PollBlindVote.itemId_userId_unique" ON "PollBlindVote"("itemId", "userId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PollBlindVote" ADD CONSTRAINT "PollBlindVote_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PollBlindVote" ADD CONSTRAINT "PollBlindVote_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- migrate existing poll votes
|
||||
INSERT INTO "PollBlindVote" ("itemId", "userId")
|
||||
SELECT "itemId", "userId" FROM "PollVote";
|
||||
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `userId` on the `PollVote` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "PollVote" DROP CONSTRAINT "PollVote_userId_fkey";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "PollVote.itemId_userId_unique";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "PollVote.userId_index";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "PollVote" DROP COLUMN "userId";
|
||||
|
||||
-- update `poll_vote` function to update both "PollVote" and "PollBlindVote" tables
|
||||
-- create poll vote
|
||||
-- if user hasn't already voted
|
||||
-- charges user item.pollCost
|
||||
-- adds POLL to ItemAct
|
||||
-- adds PollVote
|
||||
-- adds PollBlindVote
|
||||
CREATE OR REPLACE FUNCTION poll_vote(option_id INTEGER, user_id INTEGER)
|
||||
RETURNS "Item"
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
item "Item";
|
||||
option "PollOption";
|
||||
BEGIN
|
||||
PERFORM ASSERT_SERIALIZED();
|
||||
|
||||
SELECT * INTO option FROM "PollOption" where id = option_id;
|
||||
IF option IS NULL THEN
|
||||
RAISE EXCEPTION 'INVALID_POLL_OPTION';
|
||||
END IF;
|
||||
|
||||
SELECT * INTO item FROM "Item" where id = option."itemId";
|
||||
IF item IS NULL THEN
|
||||
RAISE EXCEPTION 'POLL_DOES_NOT_EXIST';
|
||||
END IF;
|
||||
|
||||
IF item."userId" = user_id THEN
|
||||
RAISE EXCEPTION 'POLL_OWNER_CANT_VOTE';
|
||||
END IF;
|
||||
|
||||
-- no longer check `PollVote` to see if a user has voted. Instead, check `PollBlindVote`
|
||||
IF EXISTS (SELECT 1 FROM "PollBlindVote" WHERE "itemId" = item.id AND "userId" = user_id) THEN
|
||||
RAISE EXCEPTION 'POLL_VOTE_ALREADY_EXISTS';
|
||||
END IF;
|
||||
|
||||
PERFORM item_act(item.id, user_id, 'POLL', item."pollCost");
|
||||
|
||||
INSERT INTO "PollVote" (created_at, updated_at, "itemId", "pollOptionId")
|
||||
VALUES (now_utc(), now_utc(), item.id, option_id);
|
||||
|
||||
INSERT INTO "PollBlindVote" (created_at, updated_at, "itemId", "userId")
|
||||
VALUES (now_utc(), now_utc(), item.id, user_id);
|
||||
|
||||
RETURN item;
|
||||
END;
|
||||
$$;
|
31
prisma/migrations/20240529105359_item_mentions/migration.sql
Normal file
31
prisma/migrations/20240529105359_item_mentions/migration.sql
Normal file
@ -0,0 +1,31 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "ItemMention" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"referrerId" INTEGER NOT NULL,
|
||||
"refereeId" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "ItemMention_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ItemMention.created_at_index" ON "ItemMention"("created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ItemMention.referrerId_index" ON "ItemMention"("referrerId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ItemMention.refereeId_index" ON "ItemMention"("refereeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ItemMention.referrerId_refereeId_unique" ON "ItemMention"("referrerId", "refereeId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ItemMention" ADD CONSTRAINT "ItemMention_referrerId_fkey" FOREIGN KEY ("referrerId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ItemMention" ADD CONSTRAINT "ItemMention_refereeId_fkey" FOREIGN KEY ("refereeId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "noteItemMentions" BOOLEAN NOT NULL DEFAULT true;
|
@ -44,6 +44,7 @@ model User {
|
||||
noteInvites Boolean @default(true)
|
||||
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")
|
||||
|
38
sndev
38
sndev
@ -219,6 +219,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 +553,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
|
||||
|
@ -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') {
|
||||
|
@ -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) {
|
||||
|
@ -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}
|
||||
|
Loading…
x
Reference in New Issue
Block a user