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:
parent
96ff26f26e
commit
fa4f09ddca
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 })
|
||||
|
||||
|
|
|
@ -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!
|
||||
}
|
||||
|
|
|
@ -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!
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -20,6 +20,7 @@ export const SUB_FIELDS = gql`
|
|||
moderated
|
||||
moderatedCount
|
||||
meMuteSub
|
||||
meSubscription
|
||||
nsfw
|
||||
}`
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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;
|
|
@ -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
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue