From 9fb1167bf724326c518df63986adf7943ad9e727 Mon Sep 17 00:00:00 2001 From: Satoshi Nakamoto Date: Fri, 3 May 2024 21:03:27 -0400 Subject: [PATCH 1/5] 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 --- api/resolvers/item.js | 2 +- api/resolvers/user.js | 40 ++++++++++++++++++++++++++ components/mute.js | 2 +- components/subscribeUser.js | 2 +- lib/webPush.js | 56 +++++++++++++++++++++---------------- 5 files changed, 75 insertions(+), 27 deletions(-) diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 3aa4c4b25..734060f75 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -1227,7 +1227,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 98d8350b4..16c8fa23d 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -701,9 +701,23 @@ 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 mute = await models.mute.findUnique({ + where: { + muterId_mutedId: { + muterId: Number(me.id), + mutedId: Number(id) + } + } + }) if (existing) { + if (mute && !existing.postsSubscribedAt) { + throw new GraphQLError("you cannot subscribe to a user 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 (mute) { + throw new GraphQLError("you cannot subscribe to a user that you've muted", { extensions: { code: 'BAD_INPUT' } }) + } await models.userSubscription.create({ data: { ...lookupData, postsSubscribedAt: new Date() } }) } return { id } @@ -711,9 +725,23 @@ 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 mute = await models.mute.findUnique({ + where: { + muterId_mutedId: { + muterId: Number(me.id), + mutedId: Number(id) + } + } + }) if (existing) { + if (mute && !existing.commentsSubscribedAt) { + throw new GraphQLError("you cannot subscribe to a user 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 (mute) { + throw new GraphQLError("you cannot subscribe to a user that you've muted", { extensions: { code: 'BAD_INPUT' } }) + } await models.userSubscription.create({ data: { ...lookupData, commentsSubscribedAt: new Date() } }) } return { id } @@ -725,6 +753,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 cannot mute a user to which you've subscribed", { extensions: { code: 'BAD_INPUT' } }) + } await models.mute.create({ data: { ...lookupData } }) } return { id } diff --git a/components/mute.js b/components/mute.js index 54455ecae..b1c63490f 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 f27ee576e..273e09eba 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/webPush.js b/lib/webPush.js index 860b099ca..91bab12eb 100644 --- a/lib/webPush.js +++ b/lib/webPush.js @@ -121,22 +121,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 +150,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 +245,24 @@ 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 mute = await models.mute.findUnique({ + where: { + muterId_mutedId: { + muterId: Number(userId), + mutedId: Number(item.userId) + } + } }) + if (!mute) { + await sendUserNotification(userId, { + title: 'you were mentioned', + body: item.text, + item, + tag: 'MENTION' + }) + } } catch (err) { console.error(err) } From c1577b40fe2c6b0ecc33d68465522c2b2f88db6a Mon Sep 17 00:00:00 2001 From: Satoshi Nakamoto Date: Sat, 4 May 2024 21:54:15 -0400 Subject: [PATCH 2/5] refactor mute detection for more code reuse update mute/subscribe error messages for consistency --- api/resolvers/user.js | 52 +++++++++++++++++-------------------------- 1 file changed, 21 insertions(+), 31 deletions(-) diff --git a/api/resolvers/user.js b/api/resolvers/user.js index 16c8fa23d..d3fc806b7 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -113,6 +113,19 @@ export function viewValueGroup () { ) vv` } +const isMuted = async ({ models, me, mutedId }) => { + const mute = await models.mute.findUnique({ + where: { + muterId_mutedId: { + muterId: Number(me.id), + mutedId: Number(mutedId) + } + } + }) + + return !!mute +} + export default { Query: { me: async (parent, args, { models, me }) => { @@ -701,22 +714,15 @@ 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 mute = await models.mute.findUnique({ - where: { - muterId_mutedId: { - muterId: Number(me.id), - mutedId: Number(id) - } - } - }) + const mute = await isMuted({ models, me, mutedId: id }) if (existing) { if (mute && !existing.postsSubscribedAt) { - throw new GraphQLError("you cannot subscribe to a user that you've muted", { extensions: { code: 'BAD_INPUT' } }) + 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 (mute) { - throw new GraphQLError("you cannot subscribe to a user that you've muted", { extensions: { code: 'BAD_INPUT' } }) + 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() } }) } @@ -725,22 +731,15 @@ 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 mute = await models.mute.findUnique({ - where: { - muterId_mutedId: { - muterId: Number(me.id), - mutedId: Number(id) - } - } - }) + const mute = await isMuted({ models, me, mutedId: id }) if (existing) { if (mute && !existing.commentsSubscribedAt) { - throw new GraphQLError("you cannot subscribe to a user that you've muted", { extensions: { code: 'BAD_INPUT' } }) + 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 (mute) { - throw new GraphQLError("you cannot subscribe to a user that you've muted", { extensions: { code: 'BAD_INPUT' } }) + 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() } }) } @@ -763,7 +762,7 @@ export default { } }) if (subscription.postsSubscribedAt || subscription.commentsSubscribedAt) { - throw new GraphQLError("you cannot mute a user to which you've subscribed", { extensions: { code: 'BAD_INPUT' } }) + throw new GraphQLError("you can't mute a stacker to whom you've subscribed", { extensions: { code: 'BAD_INPUT' } }) } await models.mute.create({ data: { ...lookupData } }) } @@ -822,16 +821,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, me, mutedId: user.id }) }, since: async (user, args, { models }) => { // get the user's first item From 5caa828b8be42ef826ada0170e8e46ed65a175ff Mon Sep 17 00:00:00 2001 From: Satoshi Nakamoto Date: Sat, 4 May 2024 21:57:32 -0400 Subject: [PATCH 3/5] variable rename --- api/resolvers/user.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/api/resolvers/user.js b/api/resolvers/user.js index d3fc806b7..b7503223c 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -714,14 +714,14 @@ 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 mute = await isMuted({ models, me, mutedId: id }) + const muted = await isMuted({ models, me, mutedId: id }) if (existing) { - if (mute && !existing.postsSubscribedAt) { + 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 (mute) { + 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() } }) @@ -731,14 +731,14 @@ 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 mute = await isMuted({ models, me, mutedId: id }) + const muted = await isMuted({ models, me, mutedId: id }) if (existing) { - if (mute && !existing.commentsSubscribedAt) { + 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 (mute) { + 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() } }) From 46318fe11767134444f97a8d0198549b15aa6360 Mon Sep 17 00:00:00 2001 From: Satoshi Nakamoto Date: Sun, 5 May 2024 11:09:00 -0400 Subject: [PATCH 4/5] move `isMuted` to shared user lib, reuse in user resolver and webpush --- api/resolvers/user.js | 20 ++++---------------- lib/user.js | 12 ++++++++++++ lib/webPush.js | 12 +++--------- 3 files changed, 19 insertions(+), 25 deletions(-) create mode 100644 lib/user.js diff --git a/api/resolvers/user.js b/api/resolvers/user.js index b7503223c..65dc3ff07 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() @@ -113,19 +114,6 @@ export function viewValueGroup () { ) vv` } -const isMuted = async ({ models, me, mutedId }) => { - const mute = await models.mute.findUnique({ - where: { - muterId_mutedId: { - muterId: Number(me.id), - mutedId: Number(mutedId) - } - } - }) - - return !!mute -} - export default { Query: { me: async (parent, args, { models, me }) => { @@ -714,7 +702,7 @@ 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, me, mutedId: id }) + 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' } }) @@ -731,7 +719,7 @@ 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, me, mutedId: id }) + 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' } }) @@ -821,7 +809,7 @@ export default { if (!me) return false if (typeof user.meMute !== 'undefined') return user.meMute - return await isMuted({ models, me, mutedId: user.id }) + return await isMuted({ models, muterId: me.id, mutedId: user.id }) }, since: async (user, args, { models }) => { // get the user's first item diff --git a/lib/user.js b/lib/user.js new file mode 100644 index 000000000..b6460b1d6 --- /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 91bab12eb..f8928242e 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) @@ -247,15 +248,8 @@ export const notifyZapped = async ({ models, id }) => { export const notifyMention = async ({ models, userId, item }) => { try { - const mute = await models.mute.findUnique({ - where: { - muterId_mutedId: { - muterId: Number(userId), - mutedId: Number(item.userId) - } - } - }) - if (!mute) { + const muted = await isMuted({ models, muterId: userId, mutedId: item.userId }) + if (!muted) { await sendUserNotification(userId, { title: 'you were mentioned', body: item.text, From 0b4a5e45df95487f57dd67d9fafd5ce8eaa75954 Mon Sep 17 00:00:00 2001 From: Satoshi Nakamoto Date: Sun, 5 May 2024 11:57:35 -0400 Subject: [PATCH 5/5] update awards.csv --- awards.csv | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/awards.csv b/awards.csv index 84dc6c9ac..7773ce46a 100644 --- a/awards.csv +++ b/awards.csv @@ -79,4 +79,5 @@ felipebueno,pr,#1094,,,,2,,80k,felipe@stacker.news,??? 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,??? SatsAllDay,issue,#1135,#1016,good-first-issue,,,,1k,weareallsatoshi@getalby.com,2024-05-04 -s373nZ,issue,#1136,#1107,medium,high,,,50k,???,??? \ No newline at end of file +s373nZ,issue,#1136,#1107,medium,high,,,50k,???,??? +SatsAllDay,pr,#1145,#717,medium,,,,250k,weareallsatoshi@zeuspay.com,???