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 { 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

View File

@ -107,16 +107,20 @@ export default {
LIMIT ${LIMIT}+$3`
)
if (meFull.noteTerritoryPosts) {
// Territory subscriptions
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
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) {

View File

@ -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 })

View File

@ -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!
}

View File

@ -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!

View File

@ -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',

View File

@ -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 }) {
<TerritoryDetails sub={sub} />
</div>
<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 &&
(Number(sub.userId) === Number(me?.id)
? (
@ -101,6 +102,9 @@ export default function TerritoryHeader ({ sub }) {
}}
>{sub.meMuteSub ? 'join' : 'mute'} territory
</Button>))}
<ActionDropdown>
<ToggleSubSubscriptionDropdownItem sub={sub} />
</ActionDropdown>
</div>
</div>
</>
@ -170,3 +174,37 @@ export function PinSubDropdownItem ({ item: { id, position } }) {
</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
moderated
meMuteSub
meSubscription
nsfw
}
otsHash
@ -82,6 +83,7 @@ export const ITEM_FULL_FIELDS = gql`
userId
moderated
meMuteSub
meSubscription
}
}
forwards {

View File

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

View File

@ -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

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 }) => {
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)
}
}

View File

@ -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'
/>
<Checkbox
label='someone writes a post in a territory I founded'
name='noteTerritoryPosts'
groupClassName='mb-0'
/>
<Checkbox
label='someone joins using my invite or referral links'
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?
stackedMsats BigInt @default(0)
noteAllDescendants Boolean @default(true)
noteTerritoryPosts Boolean @default(true)
noteDeposits Boolean @default(true)
noteEarning Boolean @default(true)
noteInvites Boolean @default(true)
@ -81,6 +80,7 @@ model User {
ReferralAct ReferralAct[]
Streak Streak[]
ThreadSubscriptions ThreadSubscription[]
SubSubscriptions SubSubscription[]
Upload Upload[] @relation("Uploads")
nostrRelays UserNostrRelay[]
withdrawls Withdrawl[]
@ -489,6 +489,7 @@ model Sub {
Item Item[]
SubAct SubAct[]
MuteSub MuteSub[]
SubSubscription SubSubscription[]
@@index([parentName])
@@index([createdAt])
@ -736,6 +737,17 @@ model UserSubscription {
@@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 {
id Int @id @default(autoincrement())
userId Int