diff --git a/api/resolvers/item.js b/api/resolvers/item.js index ad420ae4..0e896e35 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -17,7 +17,7 @@ import uu from 'url-unshort' import { actSchema, advSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, ssValidate } from '../../lib/validate' import { sendUserNotification } from '../webPush' import { defaultCommentSort, isJob, deleteItemByAuthor, getDeleteCommand, hasDeleteCommand } from '../../lib/item' -import { notifyItemParents, notifyUserSubscribers, notifyZapped, notifyFounders } from '../../lib/push-notifications' +import { notifyItemParents, notifyUserSubscribers, notifyZapped, notifyTerritorySubscribers } from '../../lib/push-notifications' import { datePivot, whenRange } from '../../lib/time' import { imageFeesInfo, uploadIdsFromText } from './image' import assertGofacYourself from './ofac' @@ -125,7 +125,8 @@ export async function itemQueryWithMeta ({ me, models, query, orderBy = '' }, .. COALESCE("ItemAct"."meMsats", 0) as "meMsats", COALESCE("ItemAct"."meDontLikeMsats", 0) as "meDontLikeMsats", b."itemId" IS NOT NULL AS "meBookmark", "ThreadSubscription"."itemId" IS NOT NULL AS "meSubscription", "ItemForward"."itemId" IS NOT NULL AS "meForward", - to_jsonb("Sub".*) || jsonb_build_object('meMuteSub', "MuteSub"."userId" IS NOT NULL) as sub + to_jsonb("Sub".*) || jsonb_build_object('meMuteSub', "MuteSub"."userId" IS NOT NULL) + || jsonb_build_object('meSubscription', "SubSubscription"."userId" IS NOT NULL) as sub FROM ( ${query} ) "Item" @@ -136,6 +137,7 @@ export async function itemQueryWithMeta ({ me, models, query, orderBy = '' }, .. LEFT JOIN "ItemForward" ON "ItemForward"."itemId" = "Item".id AND "ItemForward"."userId" = ${me.id} LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName" LEFT JOIN "MuteSub" ON "Sub"."name" = "MuteSub"."subName" AND "MuteSub"."userId" = ${me.id} + LEFT JOIN "SubSubscription" ON "Sub"."name" = "SubSubscription"."subName" AND "SubSubscription"."userId" = ${me.id} LEFT JOIN LATERAL ( SELECT "itemId", sum("ItemAct".msats) FILTER (WHERE act = 'FEE' OR act = 'TIP') AS "meMsats", sum("ItemAct".msats) FILTER (WHERE act = 'DONT_LIKE_THIS') AS "meDontLikeMsats" @@ -1340,7 +1342,7 @@ export const createItem = async (parent, { forward, options, ...item }, { me, mo notifyUserSubscribers({ models, item }) - notifyFounders({ models, item }) + notifyTerritorySubscribers({ models, item }) item.comments = [] return item diff --git a/api/resolvers/notifications.js b/api/resolvers/notifications.js index 86e4b6f6..36d83658 100644 --- a/api/resolvers/notifications.js +++ b/api/resolvers/notifications.js @@ -107,16 +107,20 @@ export default { LIMIT ${LIMIT}+$3` ) - if (meFull.noteTerritoryPosts) { - itemDrivenQueries.push( - `SELECT "Item".*, "Item".created_at AS "sortTime", 'TerritoryPost' AS type - FROM "Item" - JOIN "Sub" ON "Item"."subName" = "Sub".name - WHERE "Sub"."userId" = $1 AND "Item"."userId" <> $1 - ORDER BY "sortTime" DESC - LIMIT ${LIMIT}+$3` - ) - } + // Territory subscriptions + itemDrivenQueries.push( + `SELECT "Item".*, "Item".created_at AS "sortTime", 'TerritoryPost' AS type + FROM "Item" + JOIN "SubSubscription" ON "Item"."subName" = "SubSubscription"."subName" + ${whereClause( + '"SubSubscription"."userId" = $1', + '"Item"."userId" <> $1', + '"Item"."parentId" IS NULL', + '"Item".created_at >= "SubSubscription".created_at' + )} + ORDER BY "sortTime" DESC + LIMIT ${LIMIT}+$3` + ) // mentions if (meFull.noteMentions) { diff --git a/api/resolvers/sub.js b/api/resolvers/sub.js index 048b3713..189d4560 100644 --- a/api/resolvers/sub.js +++ b/api/resolvers/sub.js @@ -259,6 +259,22 @@ export default { await models.muteSub.create({ data: { ...lookupData } }) return true } + }, + toggleSubSubscription: async (sub, { name }, { me, models }) => { + if (!me) { + throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) + } + + const lookupData = { userId: me.id, subName: name } + const where = { userId_subName: lookupData } + const existing = await models.subSubscription.findUnique({ where }) + if (existing) { + await models.subSubscription.delete({ where }) + return false + } else { + await models.subSubscription.create({ data: lookupData }) + return true + } } }, Sub: { @@ -281,6 +297,9 @@ export default { if (typeof sub.ncomments !== 'undefined') { return sub.ncomments } + }, + meSubscription: async (sub, args, { me, models }) => { + return sub.meSubscription || sub.SubSubscription?.length > 0 } } } @@ -331,6 +350,13 @@ async function createSub (parent, data, { me, models, lnd, hash, hmac }) { msats: cost, type: 'BILLING' } + }), + // notify 'em (in the future) + models.subSubscription.create({ + data: { + userId: me.id, + subName: data.name + } }) ], { models, lnd, hash, hmac, me, enforceFee: billingCost }) diff --git a/api/typeDefs/sub.js b/api/typeDefs/sub.js index 66615a02..99b6e39b 100644 --- a/api/typeDefs/sub.js +++ b/api/typeDefs/sub.js @@ -21,6 +21,7 @@ export default gql` moderated: Boolean!, hash: String, hmac: String, nsfw: Boolean!): Sub paySub(name: String!, hash: String, hmac: String): Sub toggleMuteSub(name: String!): Boolean! + toggleSubSubscription(name: String!): Boolean! } type Sub { @@ -46,6 +47,7 @@ export default gql` nsfw: Boolean! nposts(when: String, from: String, to: String): Int! ncomments(when: String, from: String, to: String): Int! + meSubscription: Boolean! optional: SubOptional! } diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index 488f2243..8b808a51 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -73,7 +73,6 @@ export default gql` nostrPubkey: String nostrRelays: [String!] noteAllDescendants: Boolean! - noteTerritoryPosts: Boolean! noteCowboyHat: Boolean! noteDeposits: Boolean! noteEarning: Boolean! @@ -136,7 +135,6 @@ export default gql` nostrPubkey: String nostrRelays: [String!] noteAllDescendants: Boolean! - noteTerritoryPosts: Boolean! noteCowboyHat: Boolean! noteDeposits: Boolean! noteEarning: Boolean! diff --git a/api/webPush/index.js b/api/webPush/index.js index c59ba84b..a380a03f 100644 --- a/api/webPush/index.js +++ b/api/webPush/index.js @@ -35,7 +35,6 @@ const createUserFilter = (tag) => { // filter users by notification settings const tagMap = { REPLY: 'noteAllDescendants', - TERRITORY_POST: 'noteTerritoryPosts', MENTION: 'noteMentions', TIP: 'noteItemSats', FORWARDEDTIP: 'noteForwardedSats', diff --git a/components/territory-header.js b/components/territory-header.js index d98f70de..5617802c 100644 --- a/components/territory-header.js +++ b/components/territory-header.js @@ -10,6 +10,7 @@ import { useMe } from './me' import Share from './share' import { gql, useMutation } from '@apollo/client' import { useToast } from './toast' +import ActionDropdown from './action-dropdown' export function TerritoryDetails ({ sub }) { return ( @@ -79,7 +80,7 @@ export default function TerritoryHeader ({ sub }) {
- + {me && (Number(sub.userId) === Number(me?.id) ? ( @@ -101,6 +102,9 @@ export default function TerritoryHeader ({ sub }) { }} >{sub.meMuteSub ? 'join' : 'mute'} territory ))} + + +
@@ -170,3 +174,37 @@ export function PinSubDropdownItem ({ item: { id, position } }) { ) } + +export function ToggleSubSubscriptionDropdownItem ({ sub: { name, meSubscription } }) { + const toaster = useToast() + const [toggleSubSubscription] = useMutation( + gql` + mutation toggleSubSubscription($name: String!) { + toggleSubSubscription(name: $name) + }`, { + update (cache, { data: { toggleSubSubscription } }) { + cache.modify({ + id: `Sub:{"name":"${name}"}`, + fields: { + meSubscription: () => toggleSubSubscription + } + }) + } + } + ) + return ( + { + try { + await toggleSubSubscription({ variables: { name } }) + toaster.success(meSubscription ? 'unsubscribed' : 'subscribed') + } catch (err) { + console.error(err) + toaster.danger(meSubscription ? 'failed to unsubscribe' : 'failed to subscribe') + } + }} + > + {meSubscription ? `unsubscribe from ~${name}` : `subscribe to ~${name}`} + + ) +} diff --git a/fragments/items.js b/fragments/items.js index 0e090fa0..98ef4380 100644 --- a/fragments/items.js +++ b/fragments/items.js @@ -22,6 +22,7 @@ export const ITEM_FIELDS = gql` userId moderated meMuteSub + meSubscription nsfw } otsHash @@ -82,6 +83,7 @@ export const ITEM_FULL_FIELDS = gql` userId moderated meMuteSub + meSubscription } } forwards { diff --git a/fragments/subs.js b/fragments/subs.js index 405fc404..354c1ac0 100644 --- a/fragments/subs.js +++ b/fragments/subs.js @@ -20,6 +20,7 @@ export const SUB_FIELDS = gql` moderated moderatedCount meMuteSub + meSubscription nsfw }` diff --git a/fragments/users.js b/fragments/users.js index fed40d8b..a3fc401e 100644 --- a/fragments/users.js +++ b/fragments/users.js @@ -27,7 +27,6 @@ export const ME = gql` lastCheckedJobs nostrCrossposting noteAllDescendants - noteTerritoryPosts noteCowboyHat noteDeposits noteEarning @@ -70,7 +69,6 @@ export const SETTINGS_FIELDS = gql` noteItemSats noteEarning noteAllDescendants - noteTerritoryPosts noteMentions noteDeposits noteInvites diff --git a/lib/push-notifications.js b/lib/push-notifications.js index c8c6955f..4ffaccba 100644 --- a/lib/push-notifications.js +++ b/lib/push-notifications.js @@ -28,6 +28,40 @@ export const notifyUserSubscribers = async ({ models, item }) => { } } +export const notifyTerritorySubscribers = async ({ models, item }) => { + try { + const isPost = !!item.title + const { subName } = item + + // only notify on posts in subs + if (!isPost || !subName) return + + const territorySubs = await models.subSubscription.findMany({ + where: { + subName + } + }) + + const author = await models.user.findUnique({ where: { id: item.userId } }) + + const tag = `TERRITORY_POST-${subName}` + await Promise.allSettled( + territorySubs + // don't send push notification to author itself + .filter(({ userId }) => userId !== author.id) + .map(({ userId }) => + sendUserNotification(userId, { + title: `@${author.name} created a post in ~${subName}`, + body: item.title, + item, + data: { subName }, + tag + }))) + } catch (err) { + console.error(err) + } +} + export const notifyItemParents = async ({ models, item, me }) => { try { const user = await models.user.findUnique({ where: { id: me?.id || ANON_USER_ID } }) @@ -96,29 +130,3 @@ export const notifyZapped = async ({ models, id }) => { console.error(err) } } - -export const notifyFounders = async ({ models, item }) => { - try { - const isPost = !!item.title - - // only notify on posts in subs - if (!isPost || !item.subName) return - - const author = await models.user.findUnique({ where: { id: item.userId } }) - const sub = await models.sub.findUnique({ where: { name: item.subName } }) - - // don't send notifications on own posts to founders - if (sub.userId === author.id) return - - const tag = `TERRITORY_POST-${sub.name}` - await sendUserNotification(sub.userId, { - title: `@${author.name} created a post in ~${sub.name}`, - body: item.title, - item, - data: { subName: sub.name }, - tag - }) - } catch (err) { - console.error(err) - } -} diff --git a/pages/settings/index.js b/pages/settings/index.js index 056fe38e..88a2bace 100644 --- a/pages/settings/index.js +++ b/pages/settings/index.js @@ -69,7 +69,6 @@ export default function Settings ({ ssrData }) { noteItemSats: settings?.noteItemSats, noteEarning: settings?.noteEarning, noteAllDescendants: settings?.noteAllDescendants, - noteTerritoryPosts: settings?.noteTerritoryPosts, noteMentions: settings?.noteMentions, noteDeposits: settings?.noteDeposits, noteInvites: settings?.noteInvites, @@ -220,11 +219,6 @@ export default function Settings ({ ssrData }) { name='noteAllDescendants' groupClassName='mb-0' /> -