Compare commits

...

9 Commits

Author SHA1 Message Date
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
37 changed files with 540 additions and 113 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

@ -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)

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,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 }
)

View File

@ -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
}

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 }) => {

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,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

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

View File

@ -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)

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'
@ -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))

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

@ -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,

View File

@ -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>}
/>
)

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

@ -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 (
<>

View File

@ -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>

View File

@ -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}

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

@ -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
View 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]
}
}
}

View File

@ -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']

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

@ -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'

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")

38
sndev
View File

@ -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

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}