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 { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
import { getItem } from './item'
export default {
Query: {
@ -62,48 +63,50 @@ export default {
// 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
// have enough items to return in the union
let notifications = await models.$queryRaw(`
(SELECT ${ITEM_FIELDS}, "Item".created_at as "sortTime", NULL as "earnedSats",
false as mention
const notifications = await models.$queryRaw(`
(SELECT "Item".id::TEXT, "Item".created_at AS "sortTime", NULL as "earnedSats",
'Reply' AS type
FROM "Item"
JOIN "Item" p ON "Item"."parentId" = p.id
WHERE p."userId" = $1
AND "Item"."userId" <> $1 AND "Item".created_at <= $2
ORDER BY "Item".created_at desc
ORDER BY "Item".created_at DESC
LIMIT ${LIMIT}+$3)
UNION ALL
(SELECT ${ITEM_FIELDS}, max("ItemAct".created_at) as "sortTime",
sum("ItemAct".sats) as "earnedSats", false as mention
(SELECT "Item".id::TEXT, MAX("ItemAct".created_at) AS "sortTime",
sum("ItemAct".sats) as "earnedSats", 'Votification' AS type
FROM "Item"
JOIN "ItemAct" on "ItemAct"."itemId" = "Item".id
JOIN "ItemAct" ON "ItemAct"."itemId" = "Item".id
WHERE "ItemAct"."userId" <> $1
AND "ItemAct".created_at <= $2
AND "ItemAct".act <> 'BOOST'
AND "Item"."userId" = $1
GROUP BY ${ITEM_GROUP_FIELDS}
ORDER BY max("ItemAct".created_at) desc
ORDER BY MAX("ItemAct".created_at) DESC
LIMIT ${LIMIT}+$3)
UNION ALL
(SELECT ${ITEM_FIELDS}, "Mention".created_at as "sortTime", NULL as "earnedSats",
true as mention
(SELECT "Item".id::TEXT, "Mention".created_at AS "sortTime", NULL as "earnedSats",
'Mention' AS type
FROM "Mention"
JOIN "Item" on "Mention"."itemId" = "Item".id
LEFT JOIN "Item" p on "Item"."parentId" = p.id
JOIN "Item" ON "Mention"."itemId" = "Item".id
LEFT JOIN "Item" p ON "Item"."parentId" = p.id
WHERE "Mention"."userId" = $1
AND "Mention".created_at <= $2
AND "Item"."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)
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
OFFSET $3
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 } })
if (decodedCursor.offset === 0) {
await models.user.update({ where: { id: me.id }, data: { checkedNotesAt: new Date() } })
@ -117,8 +120,26 @@ export default {
}
},
Notification: {
__resolveType: async (notification, args, { models }) =>
notification.earnedSats ? 'Votification' : (notification.mention ? 'Mention' : 'Reply')
__resolveType: async (n, args, { models }) => n.type
},
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".text, "Item".url, "Item"."userId", "Item"."parentId", ltree2text("Item"."path")`
const ITEM_FIELDS =
`"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`
// const ITEM_FIELDS =
// `"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`

View File

@ -174,7 +174,7 @@ export default {
// check if they have any replies since checkedNotesAt
const newReplies = await models.$queryRaw(`
SELECT "Item".id, "Item".created_at
From "Item"
FROM "Item"
JOIN "Item" p ON "Item"."parentId" = p.id
WHERE p."userId" = $1
AND ("Item".created_at > $2 OR $2 IS NULL) AND "Item"."userId" <> $1
@ -186,13 +186,24 @@ export default {
// check if they have any mentions since checkedNotesAt
const newMentions = await models.$queryRaw(`
SELECT "Item".id, "Item".created_at
From "Mention"
FROM "Mention"
JOIN "Item" ON "Mention"."itemId" = "Item".id
WHERE "Mention"."userId" = $1
AND ("Mention".created_at > $2 OR $2 IS NULL)
AND "Item"."userId" <> $1
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!
}
union Notification = Reply | Votification | Mention
type Invitification {
invite: Invite!
sortTime: String!
}
union Notification = Reply | Votification | Mention | Invitification
type Notifications {
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 { useRouter } from 'next/router'
import MoreFooter from './more-footer'
import Invite from './invite'
function Notification ({ n }) {
const router = useRouter()
@ -11,7 +12,9 @@ function Notification ({ n }) {
<div
className='clickToContext'
onClick={() => {
if (n.__typename === 'Reply' || !n.item.title) {
if (n.__typename === 'Invitification') {
router.push('/invites')
} else if (!n.item.title) {
router.push({
pathname: '/items/[id]',
query: { id: n.item.root.id, commentId: n.item.id }
@ -24,16 +27,33 @@ function Notification ({ n }) {
}
}}
>
{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.__typename === 'Invitification'
? (
<>
<small className='font-weight-bold text-secondary ml-2'>
your invite has been redeemed by {n.invite.invitees.length} users
</small>
<div className='ml-4 mr-2 mt-1'>
<Invite
invite={n.invite} active={
!n.invite.revoked &&
!(n.invite.limit && n.invite.invitees.length >= n.invite.limit)
}
>
/>
</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} />
: (
@ -41,6 +61,7 @@ function Notification ({ n }) {
<Comment item={n.item} noReply includeParent rootText={n.__typename === 'Reply' ? 'replying to you on:' : undefined} clickToContext />
</div>)}
</div>
</>)}
</div>
)
}

View File

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

View File

@ -1,11 +1,12 @@
import Layout from '../../components/layout'
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 { gql, useMutation, useQuery } from '@apollo/client'
import { INVITE_FIELDS } from '../../fragments/invites'
import AccordianItem from '../../components/accordian-item'
import styles from '../../styles/invites.module.css'
import Invite from '../../components/invite'
export const InviteSchema = Yup.object({
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 }) {
return (
<div className='mt-4'>