honor mutes when sending push notifications (#1145)

* honor mutes when sending push notifications for:
* territory subscriptions
* mentions
* user subscriptions

Also, don't allow you to mute a subscribed user, or vice versa

* refactor mute detection for more code reuse

update mute/subscribe error messages for consistency

* variable rename

* move `isMuted` to shared user lib, reuse in user resolver and webpush

* update awards.csv

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
This commit is contained in:
SatsAllDay 2024-05-12 14:55:56 -04:00 committed by GitHub
parent 512b08997e
commit 77c87dae80
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 72 additions and 39 deletions

View File

@ -1231,7 +1231,7 @@ export const createMentions = async (item, models) => {
// only send if mention is new to avoid duplicates
if (mention.createdAt.getTime() === mention.updatedAt.getTime()) {
notifyMention(user.id, item)
notifyMention({ models, userId: user.id, item })
}
})
}

View File

@ -10,6 +10,7 @@ import { viewGroup } from './growth'
import { timeUnitForRange, whenRange } from '@/lib/time'
import assertApiKeyNotPermitted from './apiKey'
import { hashEmail } from '@/lib/crypto'
import { isMuted } from '@/lib/user'
const contributors = new Set()
@ -701,9 +702,16 @@ export default {
subscribeUserPosts: async (parent, { id }, { me, models }) => {
const lookupData = { followerId: Number(me.id), followeeId: Number(id) }
const existing = await models.userSubscription.findUnique({ where: { followerId_followeeId: lookupData } })
const muted = await isMuted({ models, muterId: me?.id, mutedId: id })
if (existing) {
if (muted && !existing.postsSubscribedAt) {
throw new GraphQLError("you can't subscribe to a stacker that you've muted", { extensions: { code: 'BAD_INPUT' } })
}
await models.userSubscription.update({ where: { followerId_followeeId: lookupData }, data: { postsSubscribedAt: existing.postsSubscribedAt ? null : new Date() } })
} else {
if (muted) {
throw new GraphQLError("you can't subscribe to a stacker that you've muted", { extensions: { code: 'BAD_INPUT' } })
}
await models.userSubscription.create({ data: { ...lookupData, postsSubscribedAt: new Date() } })
}
return { id }
@ -711,9 +719,16 @@ export default {
subscribeUserComments: async (parent, { id }, { me, models }) => {
const lookupData = { followerId: Number(me.id), followeeId: Number(id) }
const existing = await models.userSubscription.findUnique({ where: { followerId_followeeId: lookupData } })
const muted = await isMuted({ models, muterId: me?.id, mutedId: id })
if (existing) {
if (muted && !existing.commentsSubscribedAt) {
throw new GraphQLError("you can't subscribe to a stacker that you've muted", { extensions: { code: 'BAD_INPUT' } })
}
await models.userSubscription.update({ where: { followerId_followeeId: lookupData }, data: { commentsSubscribedAt: existing.commentsSubscribedAt ? null : new Date() } })
} else {
if (muted) {
throw new GraphQLError("you can't subscribe to a stacker that you've muted", { extensions: { code: 'BAD_INPUT' } })
}
await models.userSubscription.create({ data: { ...lookupData, commentsSubscribedAt: new Date() } })
}
return { id }
@ -725,6 +740,18 @@ export default {
if (existing) {
await models.mute.delete({ where })
} else {
// check to see if current user is subscribed to the target user, and disallow mute if so
const subscription = await models.userSubscription.findUnique({
where: {
followerId_followeeId: {
followerId: Number(me.id),
followeeId: Number(id)
}
}
})
if (subscription.postsSubscribedAt || subscription.commentsSubscribedAt) {
throw new GraphQLError("you can't mute a stacker to whom you've subscribed", { extensions: { code: 'BAD_INPUT' } })
}
await models.mute.create({ data: { ...lookupData } })
}
return { id }
@ -782,16 +809,7 @@ export default {
if (!me) return false
if (typeof user.meMute !== 'undefined') return user.meMute
const mute = await models.mute.findUnique({
where: {
muterId_mutedId: {
muterId: Number(me.id),
mutedId: Number(user.id)
}
}
})
return !!mute
return await isMuted({ models, muterId: me.id, mutedId: user.id })
},
since: async (user, args, { models }) => {
// get the user's first item

View File

@ -79,4 +79,5 @@ felipebueno,pr,#1094,,,,2,,80k,felipebueno@getalby.com,2024-05-06
benalleng,helpfulness,#1127,#927,good-first-issue,,,,2k,benalleng@mutiny.plus,2024-05-04
itsrealfake,pr,#1135,#1016,good-first-issue,,,nonideal solution,10k,itsrealfake2@stacker.news,2024-05-06
SatsAllDay,issue,#1135,#1016,good-first-issue,,,,1k,weareallsatoshi@getalby.com,2024-05-04
s373nZ,issue,#1136,#1107,medium,high,,,50k,se7enz@minibits.cash,2024-05-05
s373nZ,issue,#1136,#1107,medium,high,,,50k,???,???
SatsAllDay,pr,#1145,#717,medium,,,,250k,weareallsatoshi@zeuspay.com,???

1 name type pr id issue ids difficulty priority changes requested notes amount receive method date paid
79 benalleng helpfulness #1127 #927 good-first-issue 2k benalleng@mutiny.plus 2024-05-04
80 itsrealfake pr #1135 #1016 good-first-issue nonideal solution 10k itsrealfake2@stacker.news 2024-05-06
81 SatsAllDay issue #1135 #1016 good-first-issue 1k weareallsatoshi@getalby.com 2024-05-04
82 s373nZ issue #1136 #1107 medium high 50k se7enz@minibits.cash ??? 2024-05-05 ???
83 SatsAllDay pr #1145 #717 medium 250k weareallsatoshi@zeuspay.com ???

View File

@ -47,7 +47,7 @@ export default function MuteDropdownItem ({ user: { name, id, meMute } }) {
toaster.success(`${meMute ? 'un' : ''}muted ${name}`)
} catch (err) {
console.error(err)
toaster.danger(`failed to ${meMute ? 'un' : ''}mute ${name}`)
toaster.danger(err.message ?? `failed to ${meMute ? 'un' : ''}mute ${name}`)
}
}}
>

View File

@ -51,7 +51,7 @@ export default function SubscribeUserDropdownItem ({ user, target = 'posts' }) {
toaster.success(meSubscription ? 'unsubscribed' : 'subscribed')
} catch (err) {
console.error(err)
toaster.danger(meSubscription ? 'failed to unsubscribe' : 'failed to subscribe')
toaster.danger(err.message ?? (meSubscription ? 'failed to unsubscribe' : 'failed to subscribe'))
}
}}
>

12
lib/user.js Normal file
View File

@ -0,0 +1,12 @@
export const isMuted = async ({ models, muterId, mutedId }) => {
const mute = await models.mute.findUnique({
where: {
muterId_mutedId: {
muterId: Number(muterId),
mutedId: Number(mutedId)
}
}
})
return !!mute
}

View File

@ -3,6 +3,7 @@ import removeMd from 'remove-markdown'
import { ANON_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'
const webPushEnabled = process.env.NODE_ENV === 'production' ||
(process.env.VAPID_MAILTO && process.env.NEXT_PUBLIC_VAPID_PUBKEY && process.env.VAPID_PRIVKEY)
@ -121,22 +122,20 @@ export async function replyToSubscription (subscriptionId, notification) {
export const notifyUserSubscribers = async ({ models, item }) => {
try {
const isPost = !!item.title
const userSubs = await models.userSubscription.findMany({
where: {
followeeId: Number(item.userId),
[isPost ? 'postsSubscribedAt' : 'commentsSubscribedAt']: { not: null }
},
include: {
followee: true
}
})
const userSubsExcludingMutes = await models.$queryRawUnsafe(`
SELECT "UserSubscription"."followerId", "UserSubscription"."followeeId", users.name as "followeeName"
FROM "UserSubscription"
INNER JOIN users ON users.id = "UserSubscription"."followeeId"
WHERE "followeeId" = $1 AND ${isPost ? '"postsSubscribedAt"' : '"commentsSubscribedAt"'} IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM "Mute" WHERE "Mute"."muterId" = "UserSubscription"."followerId" AND "Mute"."mutedId" = $1)
`, Number(item.userId))
const subType = isPost ? 'POST' : 'COMMENT'
const tag = `FOLLOW-${item.userId}-${subType}`
await Promise.allSettled(userSubs.map(({ followerId, followee }) => sendUserNotification(followerId, {
title: `@${followee.name} ${isPost ? 'created a post' : 'replied to a post'}`,
await Promise.allSettled(userSubsExcludingMutes.map(({ followerId, followeeName }) => sendUserNotification(followerId, {
title: `@${followeeName} ${isPost ? 'created a post' : 'replied to a post'}`,
body: isPost ? item.title : item.text,
item,
data: { followeeName: followee.name, subType },
data: { followeeName, subType },
tag
})))
} catch (err) {
@ -152,17 +151,17 @@ export const notifyTerritorySubscribers = async ({ models, item }) => {
// only notify on posts in subs
if (!isPost || !subName) return
const territorySubs = await models.subSubscription.findMany({
where: {
subName
}
})
const territorySubsExcludingMuted = await models.$queryRawUnsafe(`
SELECT "userId" FROM "SubSubscription"
WHERE "subName" = $1
AND NOT EXISTS (SELECT 1 FROM "Mute" m WHERE m."muterId" = "SubSubscription"."userId" AND m."mutedId" = $2)
`, subName, Number(item.userId))
const author = await models.user.findUnique({ where: { id: item.userId } })
const tag = `TERRITORY_POST-${subName}`
await Promise.allSettled(
territorySubs
territorySubsExcludingMuted
// don't send push notification to author itself
.filter(({ userId }) => userId !== author.id)
.map(({ userId }) =>
@ -247,14 +246,17 @@ export const notifyZapped = async ({ models, id }) => {
}
}
export const notifyMention = async (userId, item) => {
export const notifyMention = async ({ models, userId, item }) => {
try {
await sendUserNotification(userId, {
title: 'you were mentioned',
body: item.text,
item,
tag: 'MENTION'
})
const muted = await isMuted({ models, muterId: userId, mutedId: item.userId })
if (!muted) {
await sendUserNotification(userId, {
title: 'you were mentioned',
body: item.text,
item,
tag: 'MENTION'
})
}
} catch (err) {
console.error(err)
}