Notifications (#354)

* Notifications generating on comment,answer,contract update

* Notifications MVP

* Submitted an answer => answered

* Listen for unseen notifications

* Fix userlink formatting, move page

* Fix links

* Remove redundant code

* Cleanup

* Cleanup

* Refactor name

* Comments

* Cleanup & update notif only after data retrieval

* Find initial new notifs on user change

* Enforce auth rules in db

* eslint update

* Code review changes

* Refactor reason
This commit is contained in:
Ian Philips 2022-06-01 07:11:25 -06:00 committed by GitHub
parent 35c373f6ce
commit 1c980ba678
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 545 additions and 4 deletions

29
common/notification.ts Normal file
View File

@ -0,0 +1,29 @@
export type Notification = {
id: string
userId: string
reasonText?: string
reason?: notification_reason_types
createdTime: number
viewTime?: number
isSeen: boolean
sourceId?: string
sourceType?: notification_source_types
sourceContractId?: string
sourceUserName?: string
sourceUserUsername?: string
sourceUserAvatarUrl?: string
}
export type notification_source_types =
| 'contract'
| 'comment'
| 'bet'
| 'answer'
| 'liquidity'
export type notification_reason_types =
| 'created'
| 'updated'
| 'resolved'
| 'tagged'
| 'replied'

View File

@ -0,0 +1,172 @@
import * as admin from 'firebase-admin'
import {
Notification,
notification_reason_types,
notification_source_types,
} from '../../common/notification'
import { User } from '../../common/user'
import { Contract } from '../../common/contract'
import { getValues } from './utils'
import { Comment } from '../../common/comment'
import { uniq } from 'lodash'
import { Bet } from '../../common/bet'
import { Answer } from '../../common/answer'
const firestore = admin.firestore()
type user_to_reason_texts = {
[userId: string]: { text: string; reason: notification_reason_types }
}
export const createNotification = async (
sourceId: string,
sourceType: notification_source_types,
reason: notification_reason_types,
sourceContract: Contract,
sourceUser: User,
idempotencyKey: string
) => {
const shouldGetNotification = (
userId: string,
userToReasonTexts: user_to_reason_texts
) => {
return (
sourceUser.id != userId &&
!Object.keys(userToReasonTexts).includes(userId)
)
}
const createUsersNotifications = async (
userToReasonTexts: user_to_reason_texts
) => {
await Promise.all(
Object.keys(userToReasonTexts).map(async (userId) => {
const notificationRef = firestore
.collection(`/users/${userId}/notifications`)
.doc(idempotencyKey)
const notification: Notification = {
id: idempotencyKey,
userId,
reasonText: userToReasonTexts[userId].text,
reason: userToReasonTexts[userId].reason,
createdTime: Date.now(),
isSeen: false,
sourceId,
sourceType,
sourceContractId: sourceContract.id,
sourceUserName: sourceUser.name,
sourceUserUsername: sourceUser.username,
sourceUserAvatarUrl: sourceUser.avatarUrl,
}
await notificationRef.set(notification)
})
)
}
// TODO: Update for liquidity.
// TODO: Find tagged users.
// TODO: Find replies to comments.
// TODO: Filter bets for only open bets
if (
sourceType === 'comment' ||
sourceType === 'answer' ||
sourceType === 'contract'
) {
let reasonTextPretext = getReasonTextFromReason(sourceType, reason)
const notifyContractCreator = async (
userToReasonTexts: user_to_reason_texts
) => {
if (shouldGetNotification(sourceContract.creatorId, userToReasonTexts))
userToReasonTexts[sourceContract.creatorId] = {
text: `${reasonTextPretext} your question`,
reason,
}
}
const notifyOtherAnswerersOnContract = async (
userToReasonTexts: user_to_reason_texts
) => {
const answers = await getValues<Answer>(
firestore
.collection('contracts')
.doc(sourceContract.id)
.collection('answers')
)
const recipientUserIds = uniq(answers.map((answer) => answer.userId))
recipientUserIds.forEach((userId) => {
if (shouldGetNotification(userId, userToReasonTexts))
userToReasonTexts[userId] = {
text: `${reasonTextPretext} a question you submitted an answer to`,
reason,
}
})
}
const notifyOtherCommentersOnContract = async (
userToReasonTexts: user_to_reason_texts
) => {
const comments = await getValues<Comment>(
firestore
.collection('contracts')
.doc(sourceContract.id)
.collection('comments')
)
const recipientUserIds = uniq(comments.map((comment) => comment.userId))
recipientUserIds.forEach((userId) => {
if (shouldGetNotification(userId, userToReasonTexts))
userToReasonTexts[userId] = {
text: `${reasonTextPretext} a question you commented on`,
reason,
}
})
}
const notifyOtherBettorsOnContract = async (
userToReasonTexts: user_to_reason_texts
) => {
const betsSnap = await firestore
.collection(`contracts/${sourceContract.id}/bets`)
.get()
const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
const recipientUserIds = uniq(bets.map((bet) => bet.userId))
recipientUserIds.forEach((userId) => {
if (shouldGetNotification(userId, userToReasonTexts))
userToReasonTexts[userId] = {
text: `${reasonTextPretext} a question you bet on`,
reason,
}
})
}
const getUsersToNotify = async () => {
const userToReasonTexts: user_to_reason_texts = {}
// The following functions modify the userToReasonTexts object in place.
await notifyContractCreator(userToReasonTexts)
await notifyOtherAnswerersOnContract(userToReasonTexts)
await notifyOtherCommentersOnContract(userToReasonTexts)
await notifyOtherBettorsOnContract(userToReasonTexts)
return userToReasonTexts
}
const userToReasonTexts = await getUsersToNotify()
await createUsersNotifications(userToReasonTexts)
}
}
function getReasonTextFromReason(
source: notification_source_types,
reason: notification_reason_types
) {
// TODO: Find tagged users.
// TODO: Find replies to comments.
switch (source) {
case 'comment':
return 'commented on'
case 'contract':
return reason
case 'answer':
return 'answered'
default:
throw new Error('Invalid notification reason')
}
}

View File

@ -27,3 +27,5 @@ export * from './backup-db'
export * from './change-user-info' export * from './change-user-info'
export * from './market-close-emails' export * from './market-close-emails'
export * from './add-liquidity' export * from './add-liquidity'
export * from './on-create-answer'
export * from './on-update-contract'

View File

@ -0,0 +1,32 @@
import * as functions from 'firebase-functions'
import { getContract, getUser } from './utils'
import { createNotification } from './create-notification'
import { Answer } from '../../common/answer'
export const onCreateAnswer = functions.firestore
.document('contracts/{contractId}/answers/{answerNumber}')
.onCreate(async (change, context) => {
const { contractId } = context.params as {
contractId: string
}
const { eventId } = context
const contract = await getContract(contractId)
if (!contract)
throw new Error('Could not find contract corresponding with answer')
const answer = change.data() as Answer
// Ignore ante answer.
if (answer.number === 0) return
const answerCreator = await getUser(answer.userId)
if (!answerCreator) throw new Error('Could not find answer creator')
await createNotification(
answer.id,
'answer',
'created',
contract,
answerCreator,
eventId
)
})

View File

@ -7,6 +7,7 @@ import { Comment } from '../../common/comment'
import { sendNewCommentEmail } from './emails' import { sendNewCommentEmail } from './emails'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
import { Answer } from '../../common/answer' import { Answer } from '../../common/answer'
import { createNotification } from './create-notification'
const firestore = admin.firestore() const firestore = admin.firestore()
@ -16,6 +17,7 @@ export const onCreateComment = functions.firestore
const { contractId } = context.params as { const { contractId } = context.params as {
contractId: string contractId: string
} }
const { eventId } = context
const contract = await getContract(contractId) const contract = await getContract(contractId)
if (!contract) if (!contract)
@ -25,7 +27,16 @@ export const onCreateComment = functions.firestore
const lastCommentTime = comment.createdTime const lastCommentTime = comment.createdTime
const commentCreator = await getUser(comment.userId) const commentCreator = await getUser(comment.userId)
if (!commentCreator) throw new Error('Could not find contract creator') if (!commentCreator) throw new Error('Could not find comment creator')
await createNotification(
comment.id,
'comment',
'created',
contract,
commentCreator,
eventId
)
await firestore await firestore
.collection('contracts') .collection('contracts')

View File

@ -0,0 +1,38 @@
import * as functions from 'firebase-functions'
import { getUser } from './utils'
import { createNotification } from './create-notification'
import { Contract } from '../../common/contract'
export const onUpdateContract = functions.firestore
.document('contracts/{contractId}')
.onUpdate(async (change, context) => {
const contract = change.after.data() as Contract
const { eventId } = context
const contractUpdater = await getUser(contract.creatorId)
if (!contractUpdater) throw new Error('Could not find contract updater')
const previousValue = change.before.data() as Contract
if (previousValue.isResolved !== contract.isResolved) {
await createNotification(
contract.id,
'contract',
'resolved',
contract,
contractUpdater,
eventId
)
} else if (
previousValue.closeTime !== contract.closeTime ||
previousValue.description !== contract.description
) {
await createNotification(
contract.id,
'contract',
'updated',
contract,
contractUpdater,
eventId
)
}
})

View File

@ -5,7 +5,6 @@ import {
MenuAlt3Icon, MenuAlt3Icon,
PresentationChartLineIcon, PresentationChartLineIcon,
SearchIcon, SearchIcon,
ChatAltIcon,
XIcon, XIcon,
} from '@heroicons/react/outline' } from '@heroicons/react/outline'
import { Transition, Dialog } from '@headlessui/react' import { Transition, Dialog } from '@headlessui/react'
@ -16,17 +15,22 @@ import { formatMoney } from 'common/util/format'
import { Avatar } from '../avatar' import { Avatar } from '../avatar'
import clsx from 'clsx' import clsx from 'clsx'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import NotificationsIcon from 'web/components/notifications-icon'
import { useIsIframe } from 'web/hooks/use-is-iframe' import { useIsIframe } from 'web/hooks/use-is-iframe'
function getNavigation(username: string) { function getNavigation(username: string) {
return [ return [
{ name: 'Home', href: '/home', icon: HomeIcon }, { name: 'Home', href: '/home', icon: HomeIcon },
{ name: 'Activity', href: '/activity', icon: ChatAltIcon },
{ {
name: 'Portfolio', name: 'Portfolio',
href: `/${username}/bets`, href: `/${username}/bets`,
icon: PresentationChartLineIcon, icon: PresentationChartLineIcon,
}, },
{
name: 'Notifications',
href: `/notifications`,
icon: NotificationsIcon,
},
] ]
} }

View File

@ -26,6 +26,7 @@ import {
useHasCreatedContractToday, useHasCreatedContractToday,
} from 'web/hooks/use-has-created-contract-today' } from 'web/hooks/use-has-created-contract-today'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import NotificationsIcon from 'web/components/notifications-icon'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants' import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
@ -39,12 +40,17 @@ function IconFromUrl(url: string): React.ComponentType<{ className?: string }> {
function getNavigation(username: string) { function getNavigation(username: string) {
return [ return [
{ name: 'Home', href: '/home', icon: HomeIcon }, { name: 'Home', href: '/home', icon: HomeIcon },
{ name: 'Activity', href: '/activity', icon: ChatAltIcon },
{ {
name: 'Portfolio', name: 'Portfolio',
href: `/${username}/bets`, href: `/${username}/bets`,
icon: PresentationChartLineIcon, icon: PresentationChartLineIcon,
}, },
{
name: 'Notifications',
href: `/notifications`,
icon: NotificationsIcon,
},
{ name: 'Charity', href: '/charity', icon: HeartIcon }, { name: 'Charity', href: '/charity', icon: HeartIcon },
] ]
} }

View File

@ -0,0 +1,36 @@
import { BellIcon } from '@heroicons/react/outline'
import clsx from 'clsx'
import { Row } from 'web/components/layout/row'
import { useEffect, useState } from 'react'
import { Notification } from 'common/notification'
import { listenForNotifications } from 'web/lib/firebase/notifications'
import { useUser } from 'web/hooks/use-user'
import { useRouter } from 'next/router'
export default function NotificationsIcon(props: { className?: string }) {
const user = useUser()
const [notifications, setNotifications] = useState<
Notification[] | undefined
>()
const router = useRouter()
useEffect(() => {
if (router.pathname.endsWith('notifications')) return setNotifications([])
}, [router.pathname])
useEffect(() => {
if (user) return listenForNotifications(user.id, setNotifications, true)
}, [user])
return (
<Row className={clsx('justify-center')}>
<div className={'relative'}>
{notifications && notifications.length > 0 && (
<div className="-mt-0.75 absolute ml-3.5 min-w-[15px] rounded-full bg-indigo-500 p-[2px] text-center text-[10px] leading-3 text-white lg:-mt-1 lg:ml-2">
{notifications.length}
</div>
)}
<BellIcon className={clsx(props.className)} />
</div>
</Row>
)
}

View File

@ -0,0 +1,24 @@
import { collection, query, where } from 'firebase/firestore'
import { Notification } from 'common/notification'
import { db } from 'web/lib/firebase/init'
import { getValues, listenForValues } from 'web/lib/firebase/utils'
function getNotificationsQuery(userId: string, unseenOnly?: boolean) {
const notifsCollection = collection(db, `/users/${userId}/notifications`)
if (unseenOnly) return query(notifsCollection, where('isSeen', '==', false))
return query(notifsCollection)
}
export function listenForNotifications(
userId: string,
setNotifications: (notifs: Notification[]) => void,
unseenOnly?: boolean
) {
return listenForValues<Notification>(
getNotificationsQuery(userId, unseenOnly),
(notifs) => {
notifs.sort((n1, n2) => n2.createdTime - n1.createdTime)
setNotifications(notifs)
}
)
}

187
web/pages/notifications.tsx Normal file
View File

@ -0,0 +1,187 @@
import { Tabs } from 'web/components/layout/tabs'
import { useUser } from 'web/hooks/use-user'
import React, { useEffect, useState } from 'react'
import { Notification } from 'common/notification'
import { listenForNotifications } from 'web/lib/firebase/notifications'
import { Avatar } from 'web/components/avatar'
import { Row } from 'web/components/layout/row'
import { Page } from 'web/components/page'
import { Title } from 'web/components/title'
import { doc, updateDoc } from 'firebase/firestore'
import { db } from 'web/lib/firebase/init'
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
import { Answer } from 'common/answer'
import { Comment } from 'web/lib/firebase/comments'
import { getValue } from 'web/lib/firebase/utils'
import Custom404 from 'web/pages/404'
import { UserLink } from 'web/components/user-page'
import { Linkify } from 'web/components/linkify'
import { User } from 'common/user'
import { useContract } from 'web/hooks/use-contract'
export default function Notifications() {
const user = useUser()
const [notifications, setNotifications] = useState<
Notification[] | undefined
>()
useEffect(() => {
if (user) return listenForNotifications(user.id, setNotifications)
}, [user])
if (!user) {
// TODO: return sign in page
return <Custom404 />
}
// TODO: use infinite scroll
return (
<Page>
<div className={'p-4'}>
<Title text={'Notifications'} />
<Tabs
className={'pb-2 pt-1 '}
defaultIndex={0}
tabs={[
{
title: 'All Notifications',
content: (
<div className={''}>
{notifications &&
notifications.map((notification) => (
<Notification
currentUser={user}
notification={notification}
key={notification.id}
/>
))}
</div>
),
},
]}
/>
</div>
</Page>
)
}
function Notification(props: {
currentUser: User
notification: Notification
}) {
const { notification, currentUser } = props
const {
sourceType,
sourceContractId,
sourceId,
userId,
id,
sourceUserName,
sourceUserAvatarUrl,
reasonText,
sourceUserUsername,
createdTime,
} = notification
const [subText, setSubText] = useState<string>('')
const contract = useContract(sourceContractId ?? '')
useEffect(() => {
if (!contract) return
if (sourceType === 'contract') {
setSubText(contract.question)
}
}, [contract, sourceType])
useEffect(() => {
if (!sourceContractId || !sourceId) return
if (sourceType === 'answer') {
getValue<Answer>(
doc(db, `contracts/${sourceContractId}/answers/`, sourceId)
).then((answer) => {
setSubText(answer?.text || '')
})
} else if (sourceType === 'comment') {
getValue<Comment>(
doc(db, `contracts/${sourceContractId}/comments/`, sourceId)
).then((comment) => {
setSubText(comment?.text || '')
})
}
}, [sourceContractId, sourceId, sourceType])
useEffect(() => {
if (!contract || !notification || notification.isSeen) return
updateDoc(doc(db, `users/${currentUser.id}/notifications/`, id), {
...notification,
isSeen: true,
viewTime: new Date(),
})
}, [notification, contract, currentUser, id, userId])
function getSourceUrl(sourceId?: string) {
if (!contract) return ''
return `/${contract.creatorUsername}/${
contract.slug
}#${getSourceIdForLinkComponent(sourceId ?? '')}`
}
function getSourceIdForLinkComponent(sourceId: string) {
switch (sourceType) {
case 'answer':
return `answer-${sourceId}`
case 'comment':
return sourceId
case 'contract':
return ''
default:
return sourceId
}
}
return (
<div className={' bg-white px-4 pt-6'}>
<Row className={'items-center text-gray-500 sm:justify-start'}>
<Avatar
avatarUrl={sourceUserAvatarUrl}
size={'sm'}
className={'mr-2'}
username={sourceUserName}
/>
<div className={'flex-1'}>
<UserLink
name={sourceUserName || ''}
username={sourceUserUsername || ''}
className={'mr-0 flex-shrink-0'}
/>
<a href={getSourceUrl(sourceId)} className={'flex-1 pl-1'}>
{reasonText}
{contract && sourceId && (
<div className={'inline'}>
<CopyLinkDateTimeComponent
contract={contract}
createdTime={createdTime}
elementId={getSourceIdForLinkComponent(sourceId)}
/>
</div>
)}
</a>
</div>
</Row>
<a href={getSourceUrl(sourceId)}>
<div className={'ml-4 mt-1'}>
{' '}
{contract && subText === contract.question ? (
<div className={'text-md text-indigo-700 hover:underline'}>
{subText}
</div>
) : (
<Linkify text={subText} />
)}
</div>
<div className={'mt-6 border-b border-gray-300'} />
</a>
</div>
)
}