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:
parent
512b08997e
commit
77c87dae80
|
@ -1231,7 +1231,7 @@ export const createMentions = async (item, models) => {
|
||||||
|
|
||||||
// only send if mention is new to avoid duplicates
|
// only send if mention is new to avoid duplicates
|
||||||
if (mention.createdAt.getTime() === mention.updatedAt.getTime()) {
|
if (mention.createdAt.getTime() === mention.updatedAt.getTime()) {
|
||||||
notifyMention(user.id, item)
|
notifyMention({ models, userId: user.id, item })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,7 @@ import { viewGroup } from './growth'
|
||||||
import { timeUnitForRange, whenRange } from '@/lib/time'
|
import { timeUnitForRange, whenRange } from '@/lib/time'
|
||||||
import assertApiKeyNotPermitted from './apiKey'
|
import assertApiKeyNotPermitted from './apiKey'
|
||||||
import { hashEmail } from '@/lib/crypto'
|
import { hashEmail } from '@/lib/crypto'
|
||||||
|
import { isMuted } from '@/lib/user'
|
||||||
|
|
||||||
const contributors = new Set()
|
const contributors = new Set()
|
||||||
|
|
||||||
|
@ -701,9 +702,16 @@ export default {
|
||||||
subscribeUserPosts: async (parent, { id }, { me, models }) => {
|
subscribeUserPosts: async (parent, { id }, { me, models }) => {
|
||||||
const lookupData = { followerId: Number(me.id), followeeId: Number(id) }
|
const lookupData = { followerId: Number(me.id), followeeId: Number(id) }
|
||||||
const existing = await models.userSubscription.findUnique({ where: { followerId_followeeId: lookupData } })
|
const existing = await models.userSubscription.findUnique({ where: { followerId_followeeId: lookupData } })
|
||||||
|
const muted = await isMuted({ models, muterId: me?.id, mutedId: id })
|
||||||
if (existing) {
|
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() } })
|
await models.userSubscription.update({ where: { followerId_followeeId: lookupData }, data: { postsSubscribedAt: existing.postsSubscribedAt ? null : new Date() } })
|
||||||
} else {
|
} 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() } })
|
await models.userSubscription.create({ data: { ...lookupData, postsSubscribedAt: new Date() } })
|
||||||
}
|
}
|
||||||
return { id }
|
return { id }
|
||||||
|
@ -711,9 +719,16 @@ export default {
|
||||||
subscribeUserComments: async (parent, { id }, { me, models }) => {
|
subscribeUserComments: async (parent, { id }, { me, models }) => {
|
||||||
const lookupData = { followerId: Number(me.id), followeeId: Number(id) }
|
const lookupData = { followerId: Number(me.id), followeeId: Number(id) }
|
||||||
const existing = await models.userSubscription.findUnique({ where: { followerId_followeeId: lookupData } })
|
const existing = await models.userSubscription.findUnique({ where: { followerId_followeeId: lookupData } })
|
||||||
|
const muted = await isMuted({ models, muterId: me?.id, mutedId: id })
|
||||||
if (existing) {
|
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() } })
|
await models.userSubscription.update({ where: { followerId_followeeId: lookupData }, data: { commentsSubscribedAt: existing.commentsSubscribedAt ? null : new Date() } })
|
||||||
} else {
|
} 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() } })
|
await models.userSubscription.create({ data: { ...lookupData, commentsSubscribedAt: new Date() } })
|
||||||
}
|
}
|
||||||
return { id }
|
return { id }
|
||||||
|
@ -725,6 +740,18 @@ export default {
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await models.mute.delete({ where })
|
await models.mute.delete({ where })
|
||||||
} else {
|
} 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 } })
|
await models.mute.create({ data: { ...lookupData } })
|
||||||
}
|
}
|
||||||
return { id }
|
return { id }
|
||||||
|
@ -782,16 +809,7 @@ export default {
|
||||||
if (!me) return false
|
if (!me) return false
|
||||||
if (typeof user.meMute !== 'undefined') return user.meMute
|
if (typeof user.meMute !== 'undefined') return user.meMute
|
||||||
|
|
||||||
const mute = await models.mute.findUnique({
|
return await isMuted({ models, muterId: me.id, mutedId: user.id })
|
||||||
where: {
|
|
||||||
muterId_mutedId: {
|
|
||||||
muterId: Number(me.id),
|
|
||||||
mutedId: Number(user.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return !!mute
|
|
||||||
},
|
},
|
||||||
since: async (user, args, { models }) => {
|
since: async (user, args, { models }) => {
|
||||||
// get the user's first item
|
// get the user's first item
|
||||||
|
|
|
@ -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
|
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
|
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
|
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,???
|
||||||
|
|
|
|
@ -47,7 +47,7 @@ export default function MuteDropdownItem ({ user: { name, id, meMute } }) {
|
||||||
toaster.success(`${meMute ? 'un' : ''}muted ${name}`)
|
toaster.success(`${meMute ? 'un' : ''}muted ${name}`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
toaster.danger(`failed to ${meMute ? 'un' : ''}mute ${name}`)
|
toaster.danger(err.message ?? `failed to ${meMute ? 'un' : ''}mute ${name}`)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
@ -51,7 +51,7 @@ export default function SubscribeUserDropdownItem ({ user, target = 'posts' }) {
|
||||||
toaster.success(meSubscription ? 'unsubscribed' : 'subscribed')
|
toaster.success(meSubscription ? 'unsubscribed' : 'subscribed')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
toaster.danger(meSubscription ? 'failed to unsubscribe' : 'failed to subscribe')
|
toaster.danger(err.message ?? (meSubscription ? 'failed to unsubscribe' : 'failed to subscribe'))
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -3,6 +3,7 @@ import removeMd from 'remove-markdown'
|
||||||
import { ANON_USER_ID, COMMENT_DEPTH_LIMIT, FOUND_BLURBS, LOST_BLURBS } from './constants'
|
import { ANON_USER_ID, COMMENT_DEPTH_LIMIT, FOUND_BLURBS, LOST_BLURBS } from './constants'
|
||||||
import { msatsToSats, numWithUnits } from './format'
|
import { msatsToSats, numWithUnits } from './format'
|
||||||
import models from '@/api/models'
|
import models from '@/api/models'
|
||||||
|
import { isMuted } from '@/lib/user'
|
||||||
|
|
||||||
const webPushEnabled = process.env.NODE_ENV === 'production' ||
|
const webPushEnabled = process.env.NODE_ENV === 'production' ||
|
||||||
(process.env.VAPID_MAILTO && process.env.NEXT_PUBLIC_VAPID_PUBKEY && process.env.VAPID_PRIVKEY)
|
(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 }) => {
|
export const notifyUserSubscribers = async ({ models, item }) => {
|
||||||
try {
|
try {
|
||||||
const isPost = !!item.title
|
const isPost = !!item.title
|
||||||
const userSubs = await models.userSubscription.findMany({
|
const userSubsExcludingMutes = await models.$queryRawUnsafe(`
|
||||||
where: {
|
SELECT "UserSubscription"."followerId", "UserSubscription"."followeeId", users.name as "followeeName"
|
||||||
followeeId: Number(item.userId),
|
FROM "UserSubscription"
|
||||||
[isPost ? 'postsSubscribedAt' : 'commentsSubscribedAt']: { not: null }
|
INNER JOIN users ON users.id = "UserSubscription"."followeeId"
|
||||||
},
|
WHERE "followeeId" = $1 AND ${isPost ? '"postsSubscribedAt"' : '"commentsSubscribedAt"'} IS NOT NULL
|
||||||
include: {
|
AND NOT EXISTS (SELECT 1 FROM "Mute" WHERE "Mute"."muterId" = "UserSubscription"."followerId" AND "Mute"."mutedId" = $1)
|
||||||
followee: true
|
`, Number(item.userId))
|
||||||
}
|
|
||||||
})
|
|
||||||
const subType = isPost ? 'POST' : 'COMMENT'
|
const subType = isPost ? 'POST' : 'COMMENT'
|
||||||
const tag = `FOLLOW-${item.userId}-${subType}`
|
const tag = `FOLLOW-${item.userId}-${subType}`
|
||||||
await Promise.allSettled(userSubs.map(({ followerId, followee }) => sendUserNotification(followerId, {
|
await Promise.allSettled(userSubsExcludingMutes.map(({ followerId, followeeName }) => sendUserNotification(followerId, {
|
||||||
title: `@${followee.name} ${isPost ? 'created a post' : 'replied to a post'}`,
|
title: `@${followeeName} ${isPost ? 'created a post' : 'replied to a post'}`,
|
||||||
body: isPost ? item.title : item.text,
|
body: isPost ? item.title : item.text,
|
||||||
item,
|
item,
|
||||||
data: { followeeName: followee.name, subType },
|
data: { followeeName, subType },
|
||||||
tag
|
tag
|
||||||
})))
|
})))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
@ -152,17 +151,17 @@ export const notifyTerritorySubscribers = async ({ models, item }) => {
|
||||||
// only notify on posts in subs
|
// only notify on posts in subs
|
||||||
if (!isPost || !subName) return
|
if (!isPost || !subName) return
|
||||||
|
|
||||||
const territorySubs = await models.subSubscription.findMany({
|
const territorySubsExcludingMuted = await models.$queryRawUnsafe(`
|
||||||
where: {
|
SELECT "userId" FROM "SubSubscription"
|
||||||
subName
|
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 author = await models.user.findUnique({ where: { id: item.userId } })
|
||||||
|
|
||||||
const tag = `TERRITORY_POST-${subName}`
|
const tag = `TERRITORY_POST-${subName}`
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
territorySubs
|
territorySubsExcludingMuted
|
||||||
// don't send push notification to author itself
|
// don't send push notification to author itself
|
||||||
.filter(({ userId }) => userId !== author.id)
|
.filter(({ userId }) => userId !== author.id)
|
||||||
.map(({ userId }) =>
|
.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 {
|
try {
|
||||||
await sendUserNotification(userId, {
|
const muted = await isMuted({ models, muterId: userId, mutedId: item.userId })
|
||||||
title: 'you were mentioned',
|
if (!muted) {
|
||||||
body: item.text,
|
await sendUserNotification(userId, {
|
||||||
item,
|
title: 'you were mentioned',
|
||||||
tag: 'MENTION'
|
body: item.text,
|
||||||
})
|
item,
|
||||||
|
tag: 'MENTION'
|
||||||
|
})
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue