Proposal: User Subscriptions: separate posts and comments (#470)

* Subscribe to user posts and comments independently

* Track when comments and posts subscriptions are set to filter out old items

* Only send push notification to subscribed user if posts/comments enabled

* Remove `posts` and `comments` boolean fields on UserSub, rely solely on timestamps
This commit is contained in:
SatsAllDay 2023-09-18 14:20:02 -04:00 committed by GitHub
parent 654ecaf00a
commit 8ab58fff87
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 85 additions and 29 deletions

View File

@ -1203,15 +1203,16 @@ export const createItem = async (parent, { forward, options, ...item }, { me, mo
const notifyUserSubscribers = async () => {
try {
const isPost = !!item.title
const userSubs = await models.userSubscription.findMany({
where: {
followeeId: Number(item.userId)
followeeId: Number(item.userId),
[isPost ? 'postsSubscribedAt' : 'commentsSubscribedAt']: { not: null }
},
include: {
followee: true
}
})
const isPost = !!item.title
await Promise.allSettled(userSubs.map(({ followerId, followee }) => sendUserNotification(followerId, {
title: `@${followee.name} ${isPost ? 'created a post' : 'replied to a post'}`,
body: isPost ? item.title : item.text,

View File

@ -112,8 +112,11 @@ export default {
JOIN "UserSubscription" ON "Item"."userId" = "UserSubscription"."followeeId"
WHERE "UserSubscription"."followerId" = $1
AND "Item".created_at <= $2
-- Only show items that have been created since subscribing to the user
AND "Item".created_at >= "UserSubscription".created_at
AND (
-- Only include posts or comments created after the corresponding subscription was enabled, not _all_ from history
("Item"."parentId" IS NULL AND "UserSubscription"."postsSubscribedAt" IS NOT NULL AND "Item".created_at >= "UserSubscription"."postsSubscribedAt")
OR ("Item"."parentId" IS NOT NULL AND "UserSubscription"."commentsSubscribedAt" IS NOT NULL AND "Item".created_at >= "UserSubscription"."commentsSubscribedAt")
)
${await filterClause(me, models)}
ORDER BY "sortTime" DESC
LIMIT ${LIMIT}+$3`

View File

@ -323,6 +323,10 @@ export default {
WHERE
"UserSubscription"."followerId" = $1
AND "Item".created_at > $2::timestamp(3) without time zone
AND (
("Item"."parentId" IS NULL AND "UserSubscription"."postsSubscribedAt" IS NOT NULL AND "Item".created_at >= "UserSubscription"."postsSubscribedAt")
OR ("Item"."parentId" IS NOT NULL AND "UserSubscription"."commentsSubscribedAt" IS NOT NULL AND "Item".created_at >= "UserSubscription"."commentsSubscribedAt")
)
${await filterClause(me, models)}
LIMIT 1`, me.id, lastChecked)
if (newUserSubs.length > 0) {
@ -588,13 +592,23 @@ export default {
return true
},
subscribeUser: async (parent, { id }, { me, models }) => {
const data = { followerId: Number(me.id), followeeId: Number(id) }
const old = await models.userSubscription.findUnique({ where: { followerId_followeeId: data } })
if (old) {
await models.userSubscription.delete({ where: { followerId_followeeId: data } })
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 } })
if (existing) {
await models.userSubscription.update({ where: { followerId_followeeId: lookupData }, data: { postsSubscribedAt: existing.postsSubscribedAt ? null : new Date() } })
} else {
await models.userSubscription.create({ data })
await models.userSubscription.create({ data: { ...lookupData, postsSubscribedAt: new Date() } })
}
return { id }
},
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 } })
if (existing) {
await models.userSubscription.update({ where: { followerId_followeeId: lookupData }, data: { commentsSubscribedAt: existing.commentsSubscribedAt ? null : new Date() } })
} else {
await models.userSubscription.create({ data: { ...lookupData, commentsSubscribedAt: new Date() } })
}
return { id }
},
@ -774,9 +788,9 @@ export default {
return relays?.map(r => r.nostrRelayAddr)
},
meSubscription: async (user, args, { me, models }) => {
meSubscriptionPosts: async (user, args, { me, models }) => {
if (!me) return false
if (typeof user.meSubscription !== 'undefined') return user.meSubscription
if (typeof user.meSubscriptionPosts !== 'undefined') return user.meSubscriptionPosts
const subscription = await models.userSubscription.findUnique({
where: {
@ -787,7 +801,22 @@ export default {
}
})
return !!subscription
return !!subscription?.postsSubscribedAt
},
meSubscriptionComments: async (user, args, { me, models }) => {
if (!me) return false
if (typeof user.meSubscriptionComments !== 'undefined') return user.meSubscriptionComments
const subscription = await models.userSubscription.findUnique({
where: {
followerId_followeeId: {
followerId: Number(me.id),
followeeId: Number(user.id)
}
}
})
return !!subscription?.commentsSubscribedAt
}
}
}

View File

@ -31,8 +31,9 @@ export default gql`
setWalkthrough(tipPopover: Boolean, upvotePopover: Boolean): Boolean
unlinkAuth(authType: String!): AuthMethods!
linkUnverifiedEmail(email: String!): Boolean
subscribeUser(id: ID): User
hideWelcomeBanner: Boolean
subscribeUserPosts(id: ID): User
subscribeUserComments(id: ID): User
}
type AuthMethods {
@ -91,6 +92,7 @@ export default gql`
greeterMode: Boolean!
lastCheckedJobs: String
authMethods: AuthMethods!
meSubscription: Boolean!
meSubscriptionPosts: Boolean!
meSubscriptionComments: Boolean!
}
`

View File

@ -3,20 +3,24 @@ import { gql } from 'graphql-tag'
import Dropdown from 'react-bootstrap/Dropdown'
import { useToast } from './toast'
export default function SubscribeUserDropdownItem ({ user: { id, meSubscription } }) {
export default function SubscribeUserDropdownItem ({ user, target = 'posts' }) {
const isPosts = target === 'posts'
const mutation = isPosts ? 'subscribeUserPosts' : 'subscribeUserComments'
const userField = isPosts ? 'meSubscriptionPosts' : 'meSubscriptionComments'
const toaster = useToast()
const { id, [userField]: meSubscription } = user
const [subscribeUser] = useMutation(
gql`
mutation subscribeUser($id: ID!) {
subscribeUser(id: $id) {
meSubscription
mutation ${mutation}($id: ID!) {
${mutation}(id: $id) {
${userField}
}
}`, {
update (cache, { data: { subscribeUser } }) {
update (cache, { data: { [mutation]: subscribeUser } }) {
cache.modify({
id: `User:${id}`,
fields: {
meSubscription: () => subscribeUser.meSubscription
[userField]: () => subscribeUser[userField]
}
})
}
@ -34,7 +38,9 @@ export default function SubscribeUserDropdownItem ({ user: { id, meSubscription
}
}}
>
{meSubscription ? 'remove subscription' : 'subscribe'}
{meSubscription
? `unsubscribe from ${isPosts ? 'posts' : 'comments'}`
: `subscribe to ${isPosts ? 'posts' : 'comments'}`}
</Dropdown.Item>
)
}

View File

@ -158,7 +158,8 @@ function NymView ({ user, isMe, setEditting }) {
{!isMe &&
<div className='ms-2'>
<ActionDropdown>
{me && <SubscribeUserDropdownItem user={user} />}
{me && <SubscribeUserDropdownItem user={user} target='posts' />}
{me && <SubscribeUserDropdownItem user={user} target='comments' />}
</ActionDropdown>
</div>}
</div>

View File

@ -150,7 +150,8 @@ export const USER_FIELDS = gql`
stacked
since
photoId
meSubscription
meSubscriptionPosts
meSubscriptionComments
}`
export const TOP_USERS = gql`

View File

@ -0,0 +1,10 @@
-- AlterTable
ALTER TABLE "UserSubscription"
ADD COLUMN "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "commentsSubscribedAt" TIMESTAMP(3),
ADD COLUMN "postsSubscribedAt" TIMESTAMP(3);
-- Set the individual post and comment times based on the original creation time for pre-existing subscriptions
UPDATE "UserSubscription"
SET "commentsSubscribedAt" = "UserSubscription".created_at,
"postsSubscribedAt" = "UserSubscription".created_at;

View File

@ -533,11 +533,14 @@ model ThreadSubscription {
}
model UserSubscription {
followerId Int
followeeId Int
createdAt DateTime @default(now()) @map("created_at")
follower User @relation("follower", fields: [followerId], references: [id], onDelete: Cascade)
followee User @relation("followee", fields: [followeeId], references: [id], onDelete: Cascade)
followerId Int
followeeId Int
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @map("updated_at") @updatedAt
postsSubscribedAt DateTime?
commentsSubscribedAt DateTime?
follower User @relation("follower", fields: [followerId], references: [id], onDelete: Cascade)
followee User @relation("followee", fields: [followeeId], references: [id], onDelete: Cascade)
@@id([followerId, followeeId])
@@index([createdAt], map: "UserSubscription.created_at_index")