Territory notifications for everyone (#870)

* Territory notifications

* Migrate old setting to new table

* Auto subscribe founders to their territories on creation

* Fix (un)subscribe not shown to founder

* Rename to toggleSubSubscription

* Fix inconsistency between toggleSubSubscription and toggleMuteSub

* Add dedicated button in header for following territories

* Don't drop noteTerritoryPosts column

* Fix db dip in Sub.meSubscription resolver

* Move territory subscribe to new territory context menu

* Decrease space between share icon and mute button

* Fix eslint
This commit is contained in:
ekzyis 2024-02-23 16:12:49 +01:00 committed by GitHub
parent 96ff26f26e
commit fa4f09ddca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 166 additions and 58 deletions

View File

@ -17,7 +17,7 @@ import uu from 'url-unshort'
import { actSchema, advSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, ssValidate } from '../../lib/validate' import { actSchema, advSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, ssValidate } from '../../lib/validate'
import { sendUserNotification } from '../webPush' import { sendUserNotification } from '../webPush'
import { defaultCommentSort, isJob, deleteItemByAuthor, getDeleteCommand, hasDeleteCommand } from '../../lib/item' 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 { datePivot, whenRange } from '../../lib/time'
import { imageFeesInfo, uploadIdsFromText } from './image' import { imageFeesInfo, uploadIdsFromText } from './image'
import assertGofacYourself from './ofac' import assertGofacYourself from './ofac'
@ -125,7 +125,8 @@ export async function itemQueryWithMeta ({ me, models, query, orderBy = '' }, ..
COALESCE("ItemAct"."meMsats", 0) as "meMsats", COALESCE("ItemAct"."meMsats", 0) as "meMsats",
COALESCE("ItemAct"."meDontLikeMsats", 0) as "meDontLikeMsats", b."itemId" IS NOT NULL AS "meBookmark", 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", "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 ( FROM (
${query} ${query}
) "Item" ) "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 "ItemForward" ON "ItemForward"."itemId" = "Item".id AND "ItemForward"."userId" = ${me.id}
LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName" LEFT JOIN "Sub" ON "Sub"."name" = "Item"."subName"
LEFT JOIN "MuteSub" ON "Sub"."name" = "MuteSub"."subName" AND "MuteSub"."userId" = ${me.id} 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 ( LEFT JOIN LATERAL (
SELECT "itemId", sum("ItemAct".msats) FILTER (WHERE act = 'FEE' OR act = 'TIP') AS "meMsats", 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" 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 }) notifyUserSubscribers({ models, item })
notifyFounders({ models, item }) notifyTerritorySubscribers({ models, item })
item.comments = [] item.comments = []
return item return item

View File

@ -107,16 +107,20 @@ export default {
LIMIT ${LIMIT}+$3` LIMIT ${LIMIT}+$3`
) )
if (meFull.noteTerritoryPosts) { // Territory subscriptions
itemDrivenQueries.push( itemDrivenQueries.push(
`SELECT "Item".*, "Item".created_at AS "sortTime", 'TerritoryPost' AS type `SELECT "Item".*, "Item".created_at AS "sortTime", 'TerritoryPost' AS type
FROM "Item" FROM "Item"
JOIN "Sub" ON "Item"."subName" = "Sub".name JOIN "SubSubscription" ON "Item"."subName" = "SubSubscription"."subName"
WHERE "Sub"."userId" = $1 AND "Item"."userId" <> $1 ${whereClause(
'"SubSubscription"."userId" = $1',
'"Item"."userId" <> $1',
'"Item"."parentId" IS NULL',
'"Item".created_at >= "SubSubscription".created_at'
)}
ORDER BY "sortTime" DESC ORDER BY "sortTime" DESC
LIMIT ${LIMIT}+$3` LIMIT ${LIMIT}+$3`
) )
}
// mentions // mentions
if (meFull.noteMentions) { if (meFull.noteMentions) {

View File

@ -259,6 +259,22 @@ export default {
await models.muteSub.create({ data: { ...lookupData } }) await models.muteSub.create({ data: { ...lookupData } })
return true 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: { Sub: {
@ -281,6 +297,9 @@ export default {
if (typeof sub.ncomments !== 'undefined') { if (typeof sub.ncomments !== 'undefined') {
return sub.ncomments 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, msats: cost,
type: 'BILLING' type: 'BILLING'
} }
}),
// notify 'em (in the future)
models.subSubscription.create({
data: {
userId: me.id,
subName: data.name
}
}) })
], { models, lnd, hash, hmac, me, enforceFee: billingCost }) ], { models, lnd, hash, hmac, me, enforceFee: billingCost })

View File

@ -21,6 +21,7 @@ export default gql`
moderated: Boolean!, hash: String, hmac: String, nsfw: Boolean!): Sub moderated: Boolean!, hash: String, hmac: String, nsfw: Boolean!): Sub
paySub(name: String!, hash: String, hmac: String): Sub paySub(name: String!, hash: String, hmac: String): Sub
toggleMuteSub(name: String!): Boolean! toggleMuteSub(name: String!): Boolean!
toggleSubSubscription(name: String!): Boolean!
} }
type Sub { type Sub {
@ -46,6 +47,7 @@ export default gql`
nsfw: Boolean! nsfw: Boolean!
nposts(when: String, from: String, to: String): Int! nposts(when: String, from: String, to: String): Int!
ncomments(when: String, from: String, to: String): Int! ncomments(when: String, from: String, to: String): Int!
meSubscription: Boolean!
optional: SubOptional! optional: SubOptional!
} }

View File

@ -73,7 +73,6 @@ export default gql`
nostrPubkey: String nostrPubkey: String
nostrRelays: [String!] nostrRelays: [String!]
noteAllDescendants: Boolean! noteAllDescendants: Boolean!
noteTerritoryPosts: Boolean!
noteCowboyHat: Boolean! noteCowboyHat: Boolean!
noteDeposits: Boolean! noteDeposits: Boolean!
noteEarning: Boolean! noteEarning: Boolean!
@ -136,7 +135,6 @@ export default gql`
nostrPubkey: String nostrPubkey: String
nostrRelays: [String!] nostrRelays: [String!]
noteAllDescendants: Boolean! noteAllDescendants: Boolean!
noteTerritoryPosts: Boolean!
noteCowboyHat: Boolean! noteCowboyHat: Boolean!
noteDeposits: Boolean! noteDeposits: Boolean!
noteEarning: Boolean! noteEarning: Boolean!

View File

@ -35,7 +35,6 @@ const createUserFilter = (tag) => {
// filter users by notification settings // filter users by notification settings
const tagMap = { const tagMap = {
REPLY: 'noteAllDescendants', REPLY: 'noteAllDescendants',
TERRITORY_POST: 'noteTerritoryPosts',
MENTION: 'noteMentions', MENTION: 'noteMentions',
TIP: 'noteItemSats', TIP: 'noteItemSats',
FORWARDEDTIP: 'noteForwardedSats', FORWARDEDTIP: 'noteForwardedSats',

View File

@ -10,6 +10,7 @@ import { useMe } from './me'
import Share from './share' import Share from './share'
import { gql, useMutation } from '@apollo/client' import { gql, useMutation } from '@apollo/client'
import { useToast } from './toast' import { useToast } from './toast'
import ActionDropdown from './action-dropdown'
export function TerritoryDetails ({ sub }) { export function TerritoryDetails ({ sub }) {
return ( return (
@ -79,7 +80,7 @@ export default function TerritoryHeader ({ sub }) {
<TerritoryDetails sub={sub} /> <TerritoryDetails sub={sub} />
</div> </div>
<div className='d-flex my-2 justify-content-end'> <div className='d-flex my-2 justify-content-end'>
<Share path={`/~${sub.name}`} title={`~${sub.name} stacker news territory`} className='mx-3' /> <Share path={`/~${sub.name}`} title={`~${sub.name} stacker news territory`} className='mx-1' />
{me && {me &&
(Number(sub.userId) === Number(me?.id) (Number(sub.userId) === Number(me?.id)
? ( ? (
@ -101,6 +102,9 @@ export default function TerritoryHeader ({ sub }) {
}} }}
>{sub.meMuteSub ? 'join' : 'mute'} territory >{sub.meMuteSub ? 'join' : 'mute'} territory
</Button>))} </Button>))}
<ActionDropdown>
<ToggleSubSubscriptionDropdownItem sub={sub} />
</ActionDropdown>
</div> </div>
</div> </div>
</> </>
@ -170,3 +174,37 @@ export function PinSubDropdownItem ({ item: { id, position } }) {
</Dropdown.Item> </Dropdown.Item>
) )
} }
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 (
<Dropdown.Item
onClick={async () => {
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}`}
</Dropdown.Item>
)
}

View File

@ -22,6 +22,7 @@ export const ITEM_FIELDS = gql`
userId userId
moderated moderated
meMuteSub meMuteSub
meSubscription
nsfw nsfw
} }
otsHash otsHash
@ -82,6 +83,7 @@ export const ITEM_FULL_FIELDS = gql`
userId userId
moderated moderated
meMuteSub meMuteSub
meSubscription
} }
} }
forwards { forwards {

View File

@ -20,6 +20,7 @@ export const SUB_FIELDS = gql`
moderated moderated
moderatedCount moderatedCount
meMuteSub meMuteSub
meSubscription
nsfw nsfw
}` }`

View File

@ -27,7 +27,6 @@ export const ME = gql`
lastCheckedJobs lastCheckedJobs
nostrCrossposting nostrCrossposting
noteAllDescendants noteAllDescendants
noteTerritoryPosts
noteCowboyHat noteCowboyHat
noteDeposits noteDeposits
noteEarning noteEarning
@ -70,7 +69,6 @@ export const SETTINGS_FIELDS = gql`
noteItemSats noteItemSats
noteEarning noteEarning
noteAllDescendants noteAllDescendants
noteTerritoryPosts
noteMentions noteMentions
noteDeposits noteDeposits
noteInvites noteInvites

View File

@ -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 }) => { export const notifyItemParents = async ({ models, item, me }) => {
try { try {
const user = await models.user.findUnique({ where: { id: me?.id || ANON_USER_ID } }) 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) 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)
}
}

View File

@ -69,7 +69,6 @@ export default function Settings ({ ssrData }) {
noteItemSats: settings?.noteItemSats, noteItemSats: settings?.noteItemSats,
noteEarning: settings?.noteEarning, noteEarning: settings?.noteEarning,
noteAllDescendants: settings?.noteAllDescendants, noteAllDescendants: settings?.noteAllDescendants,
noteTerritoryPosts: settings?.noteTerritoryPosts,
noteMentions: settings?.noteMentions, noteMentions: settings?.noteMentions,
noteDeposits: settings?.noteDeposits, noteDeposits: settings?.noteDeposits,
noteInvites: settings?.noteInvites, noteInvites: settings?.noteInvites,
@ -220,11 +219,6 @@ export default function Settings ({ ssrData }) {
name='noteAllDescendants' name='noteAllDescendants'
groupClassName='mb-0' groupClassName='mb-0'
/> />
<Checkbox
label='someone writes a post in a territory I founded'
name='noteTerritoryPosts'
groupClassName='mb-0'
/>
<Checkbox <Checkbox
label='someone joins using my invite or referral links' label='someone joins using my invite or referral links'
name='noteInvites' name='noteInvites'

View File

@ -0,0 +1,17 @@
-- CreateTable
CREATE TABLE "SubSubscription" (
"userId" INTEGER NOT NULL,
"subName" CITEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SubSubscription_pkey" PRIMARY KEY ("userId","subName")
);
-- CreateIndex
CREATE INDEX "SubSubscription.created_at_index" ON "SubSubscription"("created_at");
-- AddForeignKey
ALTER TABLE "SubSubscription" ADD CONSTRAINT "SubSubscription_subName_fkey" FOREIGN KEY ("subName") REFERENCES "Sub"("name") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SubSubscription" ADD CONSTRAINT "SubSubscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,7 @@
-- migrate values from founders which had notifications for territory posts enabled to new table
INSERT INTO "SubSubscription"("userId", "subName")
SELECT u.id, s.name
FROM users u JOIN "Sub" s ON u.id = s."userId"
WHERE "noteTerritoryPosts";
-- we don't drop the users.noteTerritoryPosts column in this migration since it's a backwards incompatible change

View File

@ -35,7 +35,6 @@ model User {
lastSeenAt DateTime? lastSeenAt DateTime?
stackedMsats BigInt @default(0) stackedMsats BigInt @default(0)
noteAllDescendants Boolean @default(true) noteAllDescendants Boolean @default(true)
noteTerritoryPosts Boolean @default(true)
noteDeposits Boolean @default(true) noteDeposits Boolean @default(true)
noteEarning Boolean @default(true) noteEarning Boolean @default(true)
noteInvites Boolean @default(true) noteInvites Boolean @default(true)
@ -81,6 +80,7 @@ model User {
ReferralAct ReferralAct[] ReferralAct ReferralAct[]
Streak Streak[] Streak Streak[]
ThreadSubscriptions ThreadSubscription[] ThreadSubscriptions ThreadSubscription[]
SubSubscriptions SubSubscription[]
Upload Upload[] @relation("Uploads") Upload Upload[] @relation("Uploads")
nostrRelays UserNostrRelay[] nostrRelays UserNostrRelay[]
withdrawls Withdrawl[] withdrawls Withdrawl[]
@ -489,6 +489,7 @@ model Sub {
Item Item[] Item Item[]
SubAct SubAct[] SubAct SubAct[]
MuteSub MuteSub[] MuteSub MuteSub[]
SubSubscription SubSubscription[]
@@index([parentName]) @@index([parentName])
@@index([createdAt]) @@index([createdAt])
@ -736,6 +737,17 @@ model UserSubscription {
@@index([followeeId], map: "UserSubscription.followee_index") @@index([followeeId], map: "UserSubscription.followee_index")
} }
model SubSubscription {
userId Int
subName String @db.Citext
createdAt DateTime @default(now()) @map("created_at")
sub Sub @relation(fields: [subName], references: [name], onDelete: Cascade, onUpdate: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
@@id([userId, subName])
@@index([createdAt], map: "SubSubscription.created_at_index")
}
model PushSubscription { model PushSubscription {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
userId Int userId Int