From 77c87dae80776da1d08279e63f1814d4a25c0c12 Mon Sep 17 00:00:00 2001 From: SatsAllDay <128755788+SatsAllDay@users.noreply.github.com> Date: Sun, 12 May 2024 14:55:56 -0400 Subject: [PATCH] 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> --- api/resolvers/item.js | 2 +- api/resolvers/user.js | 38 ++++++++++++++++++++------- awards.csv | 3 ++- components/mute.js | 2 +- components/subscribeUser.js | 2 +- lib/user.js | 12 +++++++++ lib/webPush.js | 52 +++++++++++++++++++------------------ 7 files changed, 72 insertions(+), 39 deletions(-) create mode 100644 lib/user.js diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 9d1c5537..ff8422cd 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -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 }) } }) } diff --git a/api/resolvers/user.js b/api/resolvers/user.js index 98d8350b..65dc3ff0 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -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 diff --git a/awards.csv b/awards.csv index 0c398b4a..8bdcb2ed 100644 --- a/awards.csv +++ b/awards.csv @@ -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 \ No newline at end of file +s373nZ,issue,#1136,#1107,medium,high,,,50k,???,??? +SatsAllDay,pr,#1145,#717,medium,,,,250k,weareallsatoshi@zeuspay.com,??? diff --git a/components/mute.js b/components/mute.js index 54455eca..b1c63490 100644 --- a/components/mute.js +++ b/components/mute.js @@ -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}`) } }} > diff --git a/components/subscribeUser.js b/components/subscribeUser.js index f27ee576..273e09eb 100644 --- a/components/subscribeUser.js +++ b/components/subscribeUser.js @@ -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')) } }} > diff --git a/lib/user.js b/lib/user.js new file mode 100644 index 00000000..b6460b1d --- /dev/null +++ b/lib/user.js @@ -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 +} diff --git a/lib/webPush.js b/lib/webPush.js index 860b099c..f8928242 100644 --- a/lib/webPush.js +++ b/lib/webPush.js @@ -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) }