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:
SatsAllDay 2023-08-28 21:27:56 -04:00 committed by GitHub
parent 781d034422
commit 0d4a225442
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 227 additions and 19 deletions

View File

@ -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
}

View File

@ -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 })
},

View File

@ -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
}
}
}

View File

@ -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

View File

@ -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!
}
`

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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)

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -60,6 +60,14 @@ export const NOTIFICATIONS = gql`
text
}
}
... on FollowActivity {
id
sortTime
item {
...ItemFullFields
text
}
}
... on Invitification {
id
sortTime

View File

@ -136,6 +136,7 @@ export const USER_FIELDS = gql`
stacked
since
photoId
meSubscription
}`
export const TOP_USERS = gql`

View File

@ -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");

View File

@ -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