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:
parent
35c373f6ce
commit
1c980ba678
29
common/notification.ts
Normal file
29
common/notification.ts
Normal 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'
|
172
functions/src/create-notification.ts
Normal file
172
functions/src/create-notification.ts
Normal 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')
|
||||||
|
}
|
||||||
|
}
|
|
@ -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'
|
||||||
|
|
32
functions/src/on-create-answer.ts
Normal file
32
functions/src/on-create-answer.ts
Normal 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
|
||||||
|
)
|
||||||
|
})
|
|
@ -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')
|
||||||
|
|
38
functions/src/on-update-contract.ts
Normal file
38
functions/src/on-update-contract.ts
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
|
@ -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,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
36
web/components/notifications-icon.tsx
Normal file
36
web/components/notifications-icon.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
24
web/lib/firebase/notifications.ts
Normal file
24
web/lib/firebase/notifications.ts
Normal 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
187
web/pages/notifications.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user