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 () => { const notifyUserSubscribers = async () => {
try { try {
const isPost = !!item.title
const userSubs = await models.userSubscription.findMany({ const userSubs = await models.userSubscription.findMany({
where: { where: {
followeeId: Number(item.userId) followeeId: Number(item.userId),
[isPost ? 'postsSubscribedAt' : 'commentsSubscribedAt']: { not: null }
}, },
include: { include: {
followee: true followee: true
} }
}) })
const isPost = !!item.title
await Promise.allSettled(userSubs.map(({ followerId, followee }) => sendUserNotification(followerId, { await Promise.allSettled(userSubs.map(({ followerId, followee }) => sendUserNotification(followerId, {
title: `@${followee.name} ${isPost ? 'created a post' : 'replied to a post'}`, title: `@${followee.name} ${isPost ? 'created a post' : 'replied to a post'}`,
body: isPost ? item.title : item.text, body: isPost ? item.title : item.text,

View File

@ -112,8 +112,11 @@ export default {
JOIN "UserSubscription" ON "Item"."userId" = "UserSubscription"."followeeId" JOIN "UserSubscription" ON "Item"."userId" = "UserSubscription"."followeeId"
WHERE "UserSubscription"."followerId" = $1 WHERE "UserSubscription"."followerId" = $1
AND "Item".created_at <= $2 AND "Item".created_at <= $2
-- Only show items that have been created since subscribing to the user AND (
AND "Item".created_at >= "UserSubscription".created_at -- 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)} ${await filterClause(me, models)}
ORDER BY "sortTime" DESC ORDER BY "sortTime" DESC
LIMIT ${LIMIT}+$3` LIMIT ${LIMIT}+$3`

View File

@ -323,6 +323,10 @@ export default {
WHERE WHERE
"UserSubscription"."followerId" = $1 "UserSubscription"."followerId" = $1
AND "Item".created_at > $2::timestamp(3) without time zone 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)} ${await filterClause(me, models)}
LIMIT 1`, me.id, lastChecked) LIMIT 1`, me.id, lastChecked)
if (newUserSubs.length > 0) { if (newUserSubs.length > 0) {
@ -588,13 +592,23 @@ export default {
return true return true
}, },
subscribeUser: async (parent, { id }, { me, models }) => { subscribeUserPosts: async (parent, { id }, { me, models }) => {
const data = { followerId: Number(me.id), followeeId: Number(id) } const lookupData = { followerId: Number(me.id), followeeId: Number(id) }
const old = await models.userSubscription.findUnique({ where: { followerId_followeeId: data } }) const existing = await models.userSubscription.findUnique({ where: { followerId_followeeId: lookupData } })
if (old) { if (existing) {
await models.userSubscription.delete({ where: { followerId_followeeId: data } }) await models.userSubscription.update({ where: { followerId_followeeId: lookupData }, data: { postsSubscribedAt: existing.postsSubscribedAt ? null : new Date() } })
} else { } 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 } return { id }
}, },
@ -774,9 +788,9 @@ export default {
return relays?.map(r => r.nostrRelayAddr) return relays?.map(r => r.nostrRelayAddr)
}, },
meSubscription: async (user, args, { me, models }) => { meSubscriptionPosts: async (user, args, { me, models }) => {
if (!me) return false 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({ const subscription = await models.userSubscription.findUnique({
where: { 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 setWalkthrough(tipPopover: Boolean, upvotePopover: Boolean): Boolean
unlinkAuth(authType: String!): AuthMethods! unlinkAuth(authType: String!): AuthMethods!
linkUnverifiedEmail(email: String!): Boolean linkUnverifiedEmail(email: String!): Boolean
subscribeUser(id: ID): User
hideWelcomeBanner: Boolean hideWelcomeBanner: Boolean
subscribeUserPosts(id: ID): User
subscribeUserComments(id: ID): User
} }
type AuthMethods { type AuthMethods {
@ -91,6 +92,7 @@ export default gql`
greeterMode: Boolean! greeterMode: Boolean!
lastCheckedJobs: String lastCheckedJobs: String
authMethods: AuthMethods! 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 Dropdown from 'react-bootstrap/Dropdown'
import { useToast } from './toast' 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 toaster = useToast()
const { id, [userField]: meSubscription } = user
const [subscribeUser] = useMutation( const [subscribeUser] = useMutation(
gql` gql`
mutation subscribeUser($id: ID!) { mutation ${mutation}($id: ID!) {
subscribeUser(id: $id) { ${mutation}(id: $id) {
meSubscription ${userField}
} }
}`, { }`, {
update (cache, { data: { subscribeUser } }) { update (cache, { data: { [mutation]: subscribeUser } }) {
cache.modify({ cache.modify({
id: `User:${id}`, id: `User:${id}`,
fields: { 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> </Dropdown.Item>
) )
} }

View File

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

View File

@ -150,7 +150,8 @@ export const USER_FIELDS = gql`
stacked stacked
since since
photoId photoId
meSubscription meSubscriptionPosts
meSubscriptionComments
}` }`
export const TOP_USERS = gql` 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 { model UserSubscription {
followerId Int followerId Int
followeeId Int followeeId Int
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
follower User @relation("follower", fields: [followerId], references: [id], onDelete: Cascade) updatedAt DateTime @default(now()) @map("updated_at") @updatedAt
followee User @relation("followee", fields: [followeeId], references: [id], onDelete: Cascade) 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]) @@id([followerId, followeeId])
@@index([createdAt], map: "UserSubscription.created_at_index") @@index([createdAt], map: "UserSubscription.created_at_index")