Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

honor mutes when sending push notifications #1145

Merged
merged 6 commits into from
May 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion api/resolvers/item.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I considered altering the surrounding code in this context to only invoke this notifyMention function for users that didn't mute OP, but instead I kept that logic within notifyMention for consistency with the other notifyXXX functions. It's slightly dangerous to assume that the caller of notifyXXX is doing the mute filtering, IMO.

It would probably be more performant to do it here, instead of doing a DB call for each mentioned user, so I am open to refactoring if we think that's a major concern. But for consistency and all that, I did it this way.

}
})
}
Expand Down
38 changes: 28 additions & 10 deletions api/resolvers/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -701,19 +702,33 @@ 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 }
},
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 }
Expand All @@ -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 }
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion awards.csv
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,5 @@ felipebueno,pr,#1094,,,,2,,80k,[email protected],2024-05-06
benalleng,helpfulness,#1127,#927,good-first-issue,,,,2k,[email protected],2024-05-04
itsrealfake,pr,#1135,#1016,good-first-issue,,,nonideal solution,10k,[email protected],2024-05-06
SatsAllDay,issue,#1135,#1016,good-first-issue,,,,1k,[email protected],2024-05-04
s373nZ,issue,#1136,#1107,medium,high,,,50k,[email protected],2024-05-05
s373nZ,issue,#1136,#1107,medium,high,,,50k,???,???
SatsAllDay,pr,#1145,#717,medium,,,,250k,[email protected],???
2 changes: 1 addition & 1 deletion components/mute.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`)
}
}}
>
Expand Down
2 changes: 1 addition & 1 deletion components/subscribeUser.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
}
}}
>
Expand Down
12 changes: 12 additions & 0 deletions lib/user.js
Original file line number Diff line number Diff line change
@@ -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
}
52 changes: 27 additions & 25 deletions lib/webPush.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand All @@ -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 }) =>
Expand Down Expand Up @@ -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)
}
Expand Down
Loading