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 () => {
 | 
					  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,
 | 
				
			||||||
 | 
				
			|||||||
@ -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`
 | 
				
			||||||
 | 
				
			|||||||
@ -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
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -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!
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
`
 | 
					`
 | 
				
			||||||
 | 
				
			|||||||
@ -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>
 | 
				
			||||||
  )
 | 
					  )
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -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>
 | 
				
			||||||
 | 
				
			|||||||
@ -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`
 | 
				
			||||||
 | 
				
			|||||||
@ -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 {
 | 
					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")
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user