Subscribe to a user (#443)
* First pass of user subscriptions * add new db model to track subscriptions * update user typedef and api resolver for subscription state * add subscribe action to user profile page * add mutation to subscribe to a user * Update notifications queries, hasNewNotes queries for FollowActivity note type * Only show items that have been created since subscribing to the user * Send push notifications to user subscribers for posts and comments * Rename item dropdown to action dropdown and re-use for item info and user actions * Don't allow self-follows * Add index on followee for faster lookups * Don't show subscribe action if not logged in * small style enhance --------- Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com> Co-authored-by: keyan <keyan.kousha+huumn@gmail.com>
This commit is contained in:
parent
781d034422
commit
0d4a225442
|
@ -1137,6 +1137,29 @@ export const createItem = async (parent, { forward, options, ...item }, { me, mo
|
|||
|
||||
await createMentions(item, models)
|
||||
|
||||
const notifyUserSubscribers = async () => {
|
||||
try {
|
||||
const userSubs = await models.userSubscription.findMany({
|
||||
where: {
|
||||
followeeId: Number(item.userId)
|
||||
},
|
||||
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: item.text,
|
||||
item,
|
||||
tag: 'FOLLOW'
|
||||
})))
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
notifyUserSubscribers()
|
||||
|
||||
item.comments = []
|
||||
return item
|
||||
}
|
||||
|
|
|
@ -91,6 +91,18 @@ export default {
|
|||
AND "Item"."userId" <> $1 AND "Item".created_at <= $2
|
||||
${await filterClause(me, models)}
|
||||
ORDER BY "sortTime" DESC
|
||||
LIMIT ${LIMIT}+$3)
|
||||
UNION DISTINCT
|
||||
(SELECT DISTINCT "Item".id::TEXT, "Item".created_at AS "sortTime", NULL::BIGINT as "earnedSats",
|
||||
'FollowActivity' AS type
|
||||
FROM "Item"
|
||||
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
|
||||
${await filterClause(me, models)}
|
||||
ORDER BY "sortTime" DESC
|
||||
LIMIT ${LIMIT}+$3)`
|
||||
)
|
||||
|
||||
|
@ -274,6 +286,9 @@ export default {
|
|||
Reply: {
|
||||
item: async (n, args, { models, me }) => getItem(n, { id: n.id }, { models, me })
|
||||
},
|
||||
FollowActivity: {
|
||||
item: async (n, args, { models, me }) => getItem(n, { id: n.id }, { models, me })
|
||||
},
|
||||
JobChanged: {
|
||||
item: async (n, args, { models, me }) => getItem(n, { id: n.id }, { models, me })
|
||||
},
|
||||
|
|
|
@ -316,6 +316,19 @@ export default {
|
|||
return true
|
||||
}
|
||||
|
||||
const newUserSubs = await models.$queryRawUnsafe(`
|
||||
SELECT 1
|
||||
FROM "UserSubscription"
|
||||
JOIN "Item" ON "UserSubscription"."followeeId" = "Item"."userId"
|
||||
WHERE
|
||||
"UserSubscription"."followerId" = $1
|
||||
AND "Item".created_at > $2::timestamp(3) without time zone
|
||||
${await filterClause(me, models)}
|
||||
LIMIT 1`, me.id, lastChecked)
|
||||
if (newUserSubs.length > 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
// check if they have any mentions since checkedNotesAt
|
||||
if (user.noteMentions) {
|
||||
const newMentions = await models.$queryRawUnsafe(`
|
||||
|
@ -552,6 +565,16 @@ 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 } })
|
||||
} else {
|
||||
await models.userSubscription.create({ data })
|
||||
}
|
||||
return { id }
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -720,6 +743,21 @@ export default {
|
|||
})
|
||||
|
||||
return relays?.map(r => r.nostrRelayAddr)
|
||||
},
|
||||
meSubscription: async (user, args, { me, models }) => {
|
||||
if (!me) return false
|
||||
if (typeof user.meSubscription !== 'undefined') return user.meSubscription
|
||||
|
||||
const subscription = await models.userSubscription.findUnique({
|
||||
where: {
|
||||
followerId_followeeId: {
|
||||
followerId: Number(me.id),
|
||||
followeeId: Number(user.id)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return !!subscription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,6 +17,12 @@ export default gql`
|
|||
sortTime: Date!
|
||||
}
|
||||
|
||||
type FollowActivity {
|
||||
id: ID!
|
||||
item: Item!
|
||||
sortTime: Date!
|
||||
}
|
||||
|
||||
type Reply {
|
||||
id: ID!
|
||||
item: Item!
|
||||
|
@ -77,7 +83,7 @@ export default gql`
|
|||
|
||||
union Notification = Reply | Votification | Mention
|
||||
| Invitification | Earn | JobChanged | InvoicePaid | Referral
|
||||
| Streak
|
||||
| Streak | FollowActivity
|
||||
|
||||
type Notifications {
|
||||
lastChecked: Date
|
||||
|
|
|
@ -30,6 +30,7 @@ export default gql`
|
|||
setWalkthrough(tipPopover: Boolean, upvotePopover: Boolean): Boolean
|
||||
unlinkAuth(authType: String!): AuthMethods!
|
||||
linkUnverifiedEmail(email: String!): Boolean
|
||||
subscribeUser(id: ID): User
|
||||
}
|
||||
|
||||
type AuthMethods {
|
||||
|
@ -85,5 +86,6 @@ export default gql`
|
|||
greeterMode: Boolean!
|
||||
lastCheckedJobs: String
|
||||
authMethods: AuthMethods!
|
||||
meSubscription: Boolean!
|
||||
}
|
||||
`
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
import Dropdown from 'react-bootstrap/Dropdown'
|
||||
import styles from './item.module.css'
|
||||
import MoreIcon from '../svgs/more-fill.svg'
|
||||
|
||||
export default function ActionDropdown ({ children }) {
|
||||
if (!children) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<Dropdown className={`pointer ${styles.dropdown}`} as='span'>
|
||||
<Dropdown.Toggle variant='success' as='a'>
|
||||
<MoreIcon className='fill-grey ms-1' height={16} width={16} />
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu>
|
||||
{children}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
|
@ -2,7 +2,6 @@ import Link from 'next/link'
|
|||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import Badge from 'react-bootstrap/Badge'
|
||||
import Dropdown from 'react-bootstrap/Dropdown'
|
||||
import Countdown from './countdown'
|
||||
import { abbrNum, numWithUnits } from '../lib/format'
|
||||
import { newComments, commentsViewedAt } from '../lib/new-comments'
|
||||
|
@ -10,13 +9,13 @@ import { timeSince } from '../lib/time'
|
|||
import { DeleteDropdownItem } from './delete'
|
||||
import styles from './item.module.css'
|
||||
import { useMe } from './me'
|
||||
import MoreIcon from '../svgs/more-fill.svg'
|
||||
import DontLikeThisDropdownItem from './dont-link-this'
|
||||
import BookmarkDropdownItem from './bookmark'
|
||||
import SubscribeDropdownItem from './subscribe'
|
||||
import { CopyLinkDropdownItem } from './share'
|
||||
import Hat from './hat'
|
||||
import { AD_USER_ID } from '../lib/constants'
|
||||
import ActionDropdown from './action-dropdown'
|
||||
|
||||
export default function ItemInfo ({
|
||||
item, pendingSats, full, commentsText = 'comments',
|
||||
|
@ -129,7 +128,7 @@ export default function ItemInfo ({
|
|||
/>
|
||||
</span>
|
||||
</>}
|
||||
<ItemDropdown>
|
||||
<ActionDropdown>
|
||||
<CopyLinkDropdownItem item={item} />
|
||||
{me && <BookmarkDropdownItem item={item} />}
|
||||
{me && item.user.id !== me.id && <SubscribeDropdownItem item={item} />}
|
||||
|
@ -141,21 +140,8 @@ export default function ItemInfo ({
|
|||
!item.mine && !item.deletedAt && <DontLikeThisDropdownItem id={item.id} />}
|
||||
{item.mine && !item.position && !item.deletedAt &&
|
||||
<DeleteDropdownItem itemId={item.id} type={item.title ? 'post' : 'comment'} />}
|
||||
</ItemDropdown>
|
||||
</ActionDropdown>
|
||||
{extraInfo}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ItemDropdown ({ children }) {
|
||||
return (
|
||||
<Dropdown className={`pointer ${styles.dropdown}`} as='span'>
|
||||
<Dropdown.Toggle variant='success' as='a'>
|
||||
<MoreIcon className='fill-grey ms-1' height={16} width={16} />
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu>
|
||||
{children}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -40,7 +40,8 @@ function Notification ({ n, fresh }) {
|
|||
(type === 'Votification' && <Votification n={n} />) ||
|
||||
(type === 'Mention' && <Mention n={n} />) ||
|
||||
(type === 'JobChanged' && <JobChanged n={n} />) ||
|
||||
(type === 'Reply' && <Reply n={n} />)
|
||||
(type === 'Reply' && <Reply n={n} />) ||
|
||||
(type === 'FollowActivity' && <FollowActivity n={n} />)
|
||||
}
|
||||
</NotificationLayout>
|
||||
)
|
||||
|
@ -331,6 +332,25 @@ function Reply ({ n }) {
|
|||
)
|
||||
}
|
||||
|
||||
function FollowActivity ({ n }) {
|
||||
return (
|
||||
<>
|
||||
<small className='fw-bold text-info ms-2'>
|
||||
a stacker you subscribe to {n.item.parentId ? 'commented' : 'posted'}
|
||||
</small>
|
||||
{n.item.title
|
||||
? <div className='ms-2'><Item item={n.item} /></div>
|
||||
: (
|
||||
<div className='pb-2'>
|
||||
<RootProvider root={n.item.root}>
|
||||
<Comment item={n.item} noReply includeParent clickToContext rootText='replying on:' />
|
||||
</RootProvider>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function NotificationAlert () {
|
||||
const [showAlert, setShowAlert] = useState(false)
|
||||
const [hasSubscription, setHasSubscription] = useState(false)
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
import { useMutation } from '@apollo/client'
|
||||
import { gql } from 'graphql-tag'
|
||||
import Dropdown from 'react-bootstrap/Dropdown'
|
||||
import { useToast } from './toast'
|
||||
|
||||
export default function SubscribeUserDropdownItem ({ user: { id, meSubscription } }) {
|
||||
const toaster = useToast()
|
||||
const [subscribeUser] = useMutation(
|
||||
gql`
|
||||
mutation subscribeUser($id: ID!) {
|
||||
subscribeUser(id: $id) {
|
||||
meSubscription
|
||||
}
|
||||
}`, {
|
||||
update (cache, { data: { subscribeUser } }) {
|
||||
cache.modify({
|
||||
id: `User:${id}`,
|
||||
fields: {
|
||||
meSubscription: () => subscribeUser.meSubscription
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
return (
|
||||
<Dropdown.Item
|
||||
onClick={async () => {
|
||||
try {
|
||||
await subscribeUser({ variables: { id } })
|
||||
toaster.success(meSubscription ? 'unsubscribed' : 'subscribed')
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
toaster.danger(meSubscription ? 'failed to unsubscribe' : 'failed to subscribe')
|
||||
}
|
||||
}}
|
||||
>
|
||||
{meSubscription ? 'remove subscription' : 'subscribe'}
|
||||
</Dropdown.Item>
|
||||
)
|
||||
}
|
|
@ -18,6 +18,8 @@ import { userSchema } from '../lib/validate'
|
|||
import { useShowModal } from './modal'
|
||||
import { numWithUnits } from '../lib/format'
|
||||
import Hat from './hat'
|
||||
import SubscribeUserDropdownItem from './subscribeUser'
|
||||
import ActionDropdown from './action-dropdown'
|
||||
|
||||
export default function UserHeader ({ user }) {
|
||||
const router = useRouter()
|
||||
|
@ -147,11 +149,18 @@ function NymEdit ({ user, setEditting }) {
|
|||
}
|
||||
|
||||
function NymView ({ user, isMe, setEditting }) {
|
||||
const me = useMe()
|
||||
return (
|
||||
<div className='d-flex align-items-center mb-2'>
|
||||
<div className={styles.username}>@{user.name}<Hat className='' user={user} badge /></div>
|
||||
{isMe &&
|
||||
<Button className='py-0' style={{ lineHeight: '1.25' }} variant='link' onClick={() => setEditting(true)}>edit nym</Button>}
|
||||
{!isMe &&
|
||||
<div className='ms-2'>
|
||||
<ActionDropdown>
|
||||
{me && <SubscribeUserDropdownItem user={user} />}
|
||||
</ActionDropdown>
|
||||
</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -60,6 +60,14 @@ export const NOTIFICATIONS = gql`
|
|||
text
|
||||
}
|
||||
}
|
||||
... on FollowActivity {
|
||||
id
|
||||
sortTime
|
||||
item {
|
||||
...ItemFullFields
|
||||
text
|
||||
}
|
||||
}
|
||||
... on Invitification {
|
||||
id
|
||||
sortTime
|
||||
|
|
|
@ -136,6 +136,7 @@ export const USER_FIELDS = gql`
|
|||
stacked
|
||||
since
|
||||
photoId
|
||||
meSubscription
|
||||
}`
|
||||
|
||||
export const TOP_USERS = gql`
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "UserSubscription" (
|
||||
"followerId" INTEGER NOT NULL,
|
||||
"followeeId" INTEGER NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "UserSubscription_pkey" PRIMARY KEY ("followerId","followeeId")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "UserSubscription.created_at_index" ON "UserSubscription"("created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "UserSubscription.follower_index" ON "UserSubscription"("followerId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "UserSubscription.followee_index" ON "UserSubscription"("followeeId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "UserSubscription" ADD CONSTRAINT "UserSubscription_followerId_fkey" FOREIGN KEY ("followerId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "UserSubscription" ADD CONSTRAINT "UserSubscription_followeeId_fkey" FOREIGN KEY ("followeeId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- Can't follow yourself
|
||||
ALTER TABLE "UserSubscription" ADD CONSTRAINT "UserSubscription_no_follow_self" CHECK ("followerId" <> "followeeId");
|
|
@ -85,6 +85,8 @@ model User {
|
|||
Session Session[]
|
||||
itemForwards ItemForward[]
|
||||
hideBookmarks Boolean @default(false)
|
||||
followers UserSubscription[] @relation("follower")
|
||||
followees UserSubscription[] @relation("followee")
|
||||
|
||||
@@index([createdAt], map: "users.created_at_index")
|
||||
@@index([inviteId], map: "users.inviteId_index")
|
||||
|
@ -525,6 +527,19 @@ model ThreadSubscription {
|
|||
@@index([createdAt], map: "ThreadSubscription.created_at_index")
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@id([followerId, followeeId])
|
||||
@@index([createdAt], map: "UserSubscription.created_at_index")
|
||||
@@index([followerId], map: "UserSubscription.follower_index")
|
||||
@@index([followeeId], map: "UserSubscription.followee_index")
|
||||
}
|
||||
|
||||
model PushSubscription {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
|
|
Loading…
Reference in New Issue