invite notifications

This commit is contained in:
keyan 2022-01-19 15:02:38 -06:00
parent 7d4324eb33
commit 6b19b10bb2
7 changed files with 181 additions and 110 deletions

View File

@ -1,5 +1,6 @@
import { AuthenticationError } from 'apollo-server-micro' import { AuthenticationError } from 'apollo-server-micro'
import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor' import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
import { getItem } from './item'
export default { export default {
Query: { Query: {
@ -62,48 +63,50 @@ export default {
// HACK to make notifications faster, we only return a limited sub set of the unioned // HACK to make notifications faster, we only return a limited sub set of the unioned
// queries ... we only ever need at most LIMIT+current offset in the child queries to // queries ... we only ever need at most LIMIT+current offset in the child queries to
// have enough items to return in the union // have enough items to return in the union
let notifications = await models.$queryRaw(` const notifications = await models.$queryRaw(`
(SELECT ${ITEM_FIELDS}, "Item".created_at as "sortTime", NULL as "earnedSats", (SELECT "Item".id::TEXT, "Item".created_at AS "sortTime", NULL as "earnedSats",
false as mention 'Reply' AS type
FROM "Item" FROM "Item"
JOIN "Item" p ON "Item"."parentId" = p.id JOIN "Item" p ON "Item"."parentId" = p.id
WHERE p."userId" = $1 WHERE p."userId" = $1
AND "Item"."userId" <> $1 AND "Item".created_at <= $2 AND "Item"."userId" <> $1 AND "Item".created_at <= $2
ORDER BY "Item".created_at desc ORDER BY "Item".created_at DESC
LIMIT ${LIMIT}+$3) LIMIT ${LIMIT}+$3)
UNION ALL UNION ALL
(SELECT ${ITEM_FIELDS}, max("ItemAct".created_at) as "sortTime", (SELECT "Item".id::TEXT, MAX("ItemAct".created_at) AS "sortTime",
sum("ItemAct".sats) as "earnedSats", false as mention sum("ItemAct".sats) as "earnedSats", 'Votification' AS type
FROM "Item" FROM "Item"
JOIN "ItemAct" on "ItemAct"."itemId" = "Item".id JOIN "ItemAct" ON "ItemAct"."itemId" = "Item".id
WHERE "ItemAct"."userId" <> $1 WHERE "ItemAct"."userId" <> $1
AND "ItemAct".created_at <= $2 AND "ItemAct".created_at <= $2
AND "ItemAct".act <> 'BOOST' AND "ItemAct".act <> 'BOOST'
AND "Item"."userId" = $1 AND "Item"."userId" = $1
GROUP BY ${ITEM_GROUP_FIELDS} GROUP BY ${ITEM_GROUP_FIELDS}
ORDER BY max("ItemAct".created_at) desc ORDER BY MAX("ItemAct".created_at) DESC
LIMIT ${LIMIT}+$3) LIMIT ${LIMIT}+$3)
UNION ALL UNION ALL
(SELECT ${ITEM_FIELDS}, "Mention".created_at as "sortTime", NULL as "earnedSats", (SELECT "Item".id::TEXT, "Mention".created_at AS "sortTime", NULL as "earnedSats",
true as mention 'Mention' AS type
FROM "Mention" FROM "Mention"
JOIN "Item" on "Mention"."itemId" = "Item".id JOIN "Item" ON "Mention"."itemId" = "Item".id
LEFT JOIN "Item" p on "Item"."parentId" = p.id LEFT JOIN "Item" p ON "Item"."parentId" = p.id
WHERE "Mention"."userId" = $1 WHERE "Mention"."userId" = $1
AND "Mention".created_at <= $2 AND "Mention".created_at <= $2
AND "Item"."userId" <> $1 AND "Item"."userId" <> $1
AND (p."userId" IS NULL OR p."userId" <> $1) AND (p."userId" IS NULL OR p."userId" <> $1)
ORDER BY "Mention".created_at desc ORDER BY "Mention".created_at DESC
LIMIT ${LIMIT}+$3) LIMIT ${LIMIT}+$3)
UNION ALL
(SELECT "Invite".id, MAX(users.created_at) AS "sortTime", NULL as "earnedSats",
'Invitification' AS type
FROM users JOIN "Invite" on users."inviteId" = "Invite".id
WHERE "Invite"."userId" = $1
AND users.created_at <= $2
GROUP BY "Invite".id)
ORDER BY "sortTime" DESC ORDER BY "sortTime" DESC
OFFSET $3 OFFSET $3
LIMIT ${LIMIT}`, me.id, decodedCursor.time, decodedCursor.offset) LIMIT ${LIMIT}`, me.id, decodedCursor.time, decodedCursor.offset)
notifications = notifications.map(n => {
n.item = { ...n }
return n
})
const { checkedNotesAt } = await models.user.findUnique({ where: { id: me.id } }) const { checkedNotesAt } = await models.user.findUnique({ where: { id: me.id } })
if (decodedCursor.offset === 0) { if (decodedCursor.offset === 0) {
await models.user.update({ where: { id: me.id }, data: { checkedNotesAt: new Date() } }) await models.user.update({ where: { id: me.id }, data: { checkedNotesAt: new Date() } })
@ -117,8 +120,26 @@ export default {
} }
}, },
Notification: { Notification: {
__resolveType: async (notification, args, { models }) => __resolveType: async (n, args, { models }) => n.type
notification.earnedSats ? 'Votification' : (notification.mention ? 'Mention' : 'Reply') },
Votification: {
item: async (n, args, { models }) => getItem(n, { id: n.id }, { models })
},
Reply: {
item: async (n, args, { models }) => getItem(n, { id: n.id }, { models })
},
Mention: {
mention: async (n, args, { models }) => true,
item: async (n, args, { models }) => getItem(n, { id: n.id }, { models })
},
Invitification: {
invite: async (n, args, { models }) => {
return await models.invite.findUnique({
where: {
id: n.id
}
})
}
} }
} }
@ -130,6 +151,6 @@ const ITEM_GROUP_FIELDS =
`"Item".id, "Item".created_at, "Item".updated_at, "Item".title, `"Item".id, "Item".created_at, "Item".updated_at, "Item".title,
"Item".text, "Item".url, "Item"."userId", "Item"."parentId", ltree2text("Item"."path")` "Item".text, "Item".url, "Item"."userId", "Item"."parentId", ltree2text("Item"."path")`
const ITEM_FIELDS = // const ITEM_FIELDS =
`"Item".id, "Item".created_at as "createdAt", "Item".updated_at as "updatedAt", "Item".title, // `"Item".id, "Item".created_at as "createdAt", "Item".updated_at as "updatedAt", "Item".title,
"Item".text, "Item".url, "Item"."userId", "Item"."parentId", ltree2text("Item"."path") AS path` // "Item".text, "Item".url, "Item"."userId", "Item"."parentId", ltree2text("Item"."path") AS path`

View File

@ -160,13 +160,13 @@ export default {
// check if any votes have been cast for them since checkedNotesAt // check if any votes have been cast for them since checkedNotesAt
const votes = await models.$queryRaw(` const votes = await models.$queryRaw(`
SELECT "ItemAct".id, "ItemAct".created_at SELECT "ItemAct".id, "ItemAct".created_at
FROM "ItemAct" FROM "ItemAct"
JOIN "Item" on "ItemAct"."itemId" = "Item".id JOIN "Item" on "ItemAct"."itemId" = "Item".id
WHERE "ItemAct"."userId" <> $1 WHERE "ItemAct"."userId" <> $1
AND ("ItemAct".created_at > $2 OR $2 IS NULL) AND ("ItemAct".created_at > $2 OR $2 IS NULL)
AND "ItemAct".act <> 'BOOST' AND "ItemAct".act <> 'BOOST'
AND "Item"."userId" = $1 AND "Item"."userId" = $1
LIMIT 1`, user.id, user.checkedNotesAt) LIMIT 1`, user.id, user.checkedNotesAt)
if (votes.length > 0) { if (votes.length > 0) {
return true return true
} }
@ -174,11 +174,11 @@ export default {
// check if they have any replies since checkedNotesAt // check if they have any replies since checkedNotesAt
const newReplies = await models.$queryRaw(` const newReplies = await models.$queryRaw(`
SELECT "Item".id, "Item".created_at SELECT "Item".id, "Item".created_at
From "Item" FROM "Item"
JOIN "Item" p ON "Item"."parentId" = p.id JOIN "Item" p ON "Item"."parentId" = p.id
WHERE p."userId" = $1 WHERE p."userId" = $1
AND ("Item".created_at > $2 OR $2 IS NULL) AND "Item"."userId" <> $1 AND ("Item".created_at > $2 OR $2 IS NULL) AND "Item"."userId" <> $1
LIMIT 1`, user.id, user.checkedNotesAt) LIMIT 1`, user.id, user.checkedNotesAt)
if (newReplies.length > 0) { if (newReplies.length > 0) {
return true return true
} }
@ -186,13 +186,24 @@ export default {
// check if they have any mentions since checkedNotesAt // check if they have any mentions since checkedNotesAt
const newMentions = await models.$queryRaw(` const newMentions = await models.$queryRaw(`
SELECT "Item".id, "Item".created_at SELECT "Item".id, "Item".created_at
From "Mention" FROM "Mention"
JOIN "Item" ON "Mention"."itemId" = "Item".id JOIN "Item" ON "Mention"."itemId" = "Item".id
WHERE "Mention"."userId" = $1 WHERE "Mention"."userId" = $1
AND ("Mention".created_at > $2 OR $2 IS NULL) AND ("Mention".created_at > $2 OR $2 IS NULL)
AND "Item"."userId" <> $1 AND "Item"."userId" <> $1
LIMIT 1`, user.id, user.checkedNotesAt) LIMIT 1`, user.id, user.checkedNotesAt)
return newMentions.length > 0 if (newMentions.length > 0) {
return true
}
// check if new invites have been redeemed
const newInvitees = await models.$queryRaw(`
SELECT "Invite".id
FROM users JOIN "Invite" on users."inviteId" = "Invite".id
WHERE "Invite"."userId" = $1
AND (users.created_at > $2 or $2 IS NULL)
LIMIT 1`, user.id, user.checkedNotesAt)
return newInvitees.length > 0
} }
} }
} }

View File

@ -22,7 +22,12 @@ export default gql`
sortTime: String! sortTime: String!
} }
union Notification = Reply | Votification | Mention type Invitification {
invite: Invite!
sortTime: String!
}
union Notification = Reply | Votification | Mention | Invitification
type Notifications { type Notifications {
lastChecked: String lastChecked: String

52
components/invite.js Normal file
View File

@ -0,0 +1,52 @@
import { CopyInput } from './form'
import { gql, useMutation } from '@apollo/client'
import { INVITE_FIELDS } from '../fragments/invites'
import styles from '../styles/invites.module.css'
export default function Invite ({ invite, active }) {
const [revokeInvite] = useMutation(
gql`
${INVITE_FIELDS}
mutation revokeInvite($id: ID!) {
revokeInvite(id: $id) {
...InviteFields
}
}`
)
return (
<div
className={styles.invite}
>
<CopyInput
groupClassName='mb-1'
size='sm' type='text'
placeholder={`https://stacker.news/invites/${invite.id}`} readOnly
/>
<div className={styles.other}>
<span>{invite.gift} sat gift</span>
<span> \ </span>
<span>{invite.invitees.length} joined{invite.limit ? ` of ${invite.limit}` : ''}</span>
{active
? (
<>
<span> \ </span>
<span
className={styles.revoke}
onClick={() => revokeInvite({ variables: { id: invite.id } })}
>revoke
</span>
</>)
: invite.revoked && (
<>
<span> \ </span>
<span
className='text-danger'
>revoked
</span>
</>)}
</div>
</div>
)
}

View File

@ -4,6 +4,7 @@ import Item from './item'
import { NOTIFICATIONS } from '../fragments/notifications' import { NOTIFICATIONS } from '../fragments/notifications'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import MoreFooter from './more-footer' import MoreFooter from './more-footer'
import Invite from './invite'
function Notification ({ n }) { function Notification ({ n }) {
const router = useRouter() const router = useRouter()
@ -11,7 +12,9 @@ function Notification ({ n }) {
<div <div
className='clickToContext' className='clickToContext'
onClick={() => { onClick={() => {
if (n.__typename === 'Reply' || !n.item.title) { if (n.__typename === 'Invitification') {
router.push('/invites')
} else if (!n.item.title) {
router.push({ router.push({
pathname: '/items/[id]', pathname: '/items/[id]',
query: { id: n.item.root.id, commentId: n.item.id } query: { id: n.item.root.id, commentId: n.item.id }
@ -24,23 +27,41 @@ function Notification ({ n }) {
} }
}} }}
> >
{n.__typename === 'Votification' && {n.__typename === 'Invitification'
<small className='font-weight-bold text-success ml-2'>your {n.item.title ? 'post' : 'reply'} stacked {n.earnedSats} sats</small>} ? (
{n.__typename === 'Mention' && <>
<small className='font-weight-bold text-info ml-2'>you were mentioned in</small>} <small className='font-weight-bold text-secondary ml-2'>
<div className={ your invite has been redeemed by {n.invite.invitees.length} users
n.__typename === 'Votification' || n.__typename === 'Mention' </small>
? '' <div className='ml-4 mr-2 mt-1'>
: 'py-2' <Invite
} invite={n.invite} active={
> !n.invite.revoked &&
{n.item.title !(n.invite.limit && n.invite.invitees.length >= n.invite.limit)
? <Item item={n.item} /> }
: ( />
<div className='pb-2'> </div>
<Comment item={n.item} noReply includeParent rootText={n.__typename === 'Reply' ? 'replying to you on:' : undefined} clickToContext /> </>
</div>)} )
</div> : (
<>
{n.__typename === 'Votification' &&
<small className='font-weight-bold text-success ml-2'>
your {n.item.title ? 'post' : 'reply'} stacked {n.earnedSats} sats
</small>}
{n.__typename === 'Mention' &&
<small className='font-weight-bold text-info ml-2'>
you were mentioned in
</small>}
<div className={n.__typename === 'Votification' || n.__typename === 'Mention' ? '' : 'py-2'}>
{n.item.title
? <Item item={n.item} />
: (
<div className='pb-2'>
<Comment item={n.item} noReply includeParent rootText={n.__typename === 'Reply' ? 'replying to you on:' : undefined} clickToContext />
</div>)}
</div>
</>)}
</div> </div>
) )
} }

View File

@ -1,8 +1,10 @@
import { gql } from '@apollo/client' import { gql } from '@apollo/client'
import { ITEM_FIELDS } from './items' import { ITEM_FIELDS } from './items'
import { INVITE_FIELDS } from './invites'
export const NOTIFICATIONS = gql` export const NOTIFICATIONS = gql`
${ITEM_FIELDS} ${ITEM_FIELDS}
${INVITE_FIELDS}
query Notifications($cursor: String) { query Notifications($cursor: String) {
notifications(cursor: $cursor) { notifications(cursor: $cursor) {
@ -33,6 +35,12 @@ export const NOTIFICATIONS = gql`
text text
} }
} }
... on Invitification {
sortTime
invite {
...InviteFields
}
}
} }
} }
} ` } `

View File

@ -1,11 +1,12 @@
import Layout from '../../components/layout' import Layout from '../../components/layout'
import * as Yup from 'yup' import * as Yup from 'yup'
import { CopyInput, Form, Input, SubmitButton } from '../../components/form' import { Form, Input, SubmitButton } from '../../components/form'
import { InputGroup } from 'react-bootstrap' import { InputGroup } from 'react-bootstrap'
import { gql, useMutation, useQuery } from '@apollo/client' import { gql, useMutation, useQuery } from '@apollo/client'
import { INVITE_FIELDS } from '../../fragments/invites' import { INVITE_FIELDS } from '../../fragments/invites'
import AccordianItem from '../../components/accordian-item' import AccordianItem from '../../components/accordian-item'
import styles from '../../styles/invites.module.css' import styles from '../../styles/invites.module.css'
import Invite from '../../components/invite'
export const InviteSchema = Yup.object({ export const InviteSchema = Yup.object({
gift: Yup.number().typeError('must be a number') gift: Yup.number().typeError('must be a number')
@ -73,54 +74,6 @@ function InviteForm () {
) )
} }
function Invite ({ invite, active }) {
const [revokeInvite] = useMutation(
gql`
${INVITE_FIELDS}
mutation revokeInvite($id: ID!) {
revokeInvite(id: $id) {
...InviteFields
}
}`
)
return (
<div
className={styles.invite}
>
<CopyInput
groupClassName='mb-1'
size='sm' type='text'
placeholder={`https://stacker.news/invites/${invite.id}`} readOnly
/>
<div className={styles.other}>
<span>{invite.gift} sat gift</span>
<span> \ </span>
<span>{invite.invitees.length} joined{invite.limit ? ` of ${invite.limit}` : ''}</span>
{active
? (
<>
<span> \ </span>
<span
className={styles.revoke}
onClick={() => revokeInvite({ variables: { id: invite.id } })}
>revoke
</span>
</>)
: invite.revoked && (
<>
<span> \ </span>
<span
className='text-danger'
>revoked
</span>
</>)}
</div>
</div>
)
}
function InviteList ({ name, invites }) { function InviteList ({ name, invites }) {
return ( return (
<div className='mt-4'> <div className='mt-4'>