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:
parent
654ecaf00a
commit
8ab58fff87
|
@ -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,
|
||||
|
|
|
@ -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`
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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!
|
||||
}
|
||||
`
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -150,7 +150,8 @@ export const USER_FIELDS = gql`
|
|||
stacked
|
||||
since
|
||||
photoId
|
||||
meSubscription
|
||||
meSubscriptionPosts
|
||||
meSubscriptionComments
|
||||
}`
|
||||
|
||||
export const TOP_USERS = gql`
|
||||
|
|
|
@ -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;
|
|
@ -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")
|
||||
|
|
Loading…
Reference in New Issue