From 1c980ba678b76739d1d5bd8f008283c7f85991cb Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Wed, 1 Jun 2022 07:11:25 -0600 Subject: [PATCH] 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 --- common/notification.ts | 29 ++++ functions/src/create-notification.ts | 172 +++++++++++++++++++++++ functions/src/index.ts | 2 + functions/src/on-create-answer.ts | 32 +++++ functions/src/on-create-comment.ts | 13 +- functions/src/on-update-contract.ts | 38 ++++++ web/components/nav/nav-bar.tsx | 8 +- web/components/nav/sidebar.tsx | 8 +- web/components/notifications-icon.tsx | 36 +++++ web/lib/firebase/notifications.ts | 24 ++++ web/pages/notifications.tsx | 187 ++++++++++++++++++++++++++ 11 files changed, 545 insertions(+), 4 deletions(-) create mode 100644 common/notification.ts create mode 100644 functions/src/create-notification.ts create mode 100644 functions/src/on-create-answer.ts create mode 100644 functions/src/on-update-contract.ts create mode 100644 web/components/notifications-icon.tsx create mode 100644 web/lib/firebase/notifications.ts create mode 100644 web/pages/notifications.tsx diff --git a/common/notification.ts b/common/notification.ts new file mode 100644 index 00000000..f13a75e9 --- /dev/null +++ b/common/notification.ts @@ -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' diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts new file mode 100644 index 00000000..57f5d6ef --- /dev/null +++ b/functions/src/create-notification.ts @@ -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( + 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( + 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') + } +} diff --git a/functions/src/index.ts b/functions/src/index.ts index 3c0dc8f8..f18b6109 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -27,3 +27,5 @@ export * from './backup-db' export * from './change-user-info' export * from './market-close-emails' export * from './add-liquidity' +export * from './on-create-answer' +export * from './on-update-contract' diff --git a/functions/src/on-create-answer.ts b/functions/src/on-create-answer.ts new file mode 100644 index 00000000..3fd7fefa --- /dev/null +++ b/functions/src/on-create-answer.ts @@ -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 + ) + }) diff --git a/functions/src/on-create-comment.ts b/functions/src/on-create-comment.ts index ecbd9ea1..43676615 100644 --- a/functions/src/on-create-comment.ts +++ b/functions/src/on-create-comment.ts @@ -7,6 +7,7 @@ import { Comment } from '../../common/comment' import { sendNewCommentEmail } from './emails' import { Bet } from '../../common/bet' import { Answer } from '../../common/answer' +import { createNotification } from './create-notification' const firestore = admin.firestore() @@ -16,6 +17,7 @@ export const onCreateComment = functions.firestore const { contractId } = context.params as { contractId: string } + const { eventId } = context const contract = await getContract(contractId) if (!contract) @@ -25,7 +27,16 @@ export const onCreateComment = functions.firestore const lastCommentTime = comment.createdTime 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 .collection('contracts') diff --git a/functions/src/on-update-contract.ts b/functions/src/on-update-contract.ts new file mode 100644 index 00000000..2ccd5540 --- /dev/null +++ b/functions/src/on-update-contract.ts @@ -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 + ) + } + }) diff --git a/web/components/nav/nav-bar.tsx b/web/components/nav/nav-bar.tsx index b279e470..7a291606 100644 --- a/web/components/nav/nav-bar.tsx +++ b/web/components/nav/nav-bar.tsx @@ -5,7 +5,6 @@ import { MenuAlt3Icon, PresentationChartLineIcon, SearchIcon, - ChatAltIcon, XIcon, } from '@heroicons/react/outline' import { Transition, Dialog } from '@headlessui/react' @@ -16,17 +15,22 @@ import { formatMoney } from 'common/util/format' import { Avatar } from '../avatar' import clsx from 'clsx' import { useRouter } from 'next/router' +import NotificationsIcon from 'web/components/notifications-icon' import { useIsIframe } from 'web/hooks/use-is-iframe' function getNavigation(username: string) { return [ { name: 'Home', href: '/home', icon: HomeIcon }, - { name: 'Activity', href: '/activity', icon: ChatAltIcon }, { name: 'Portfolio', href: `/${username}/bets`, icon: PresentationChartLineIcon, }, + { + name: 'Notifications', + href: `/notifications`, + icon: NotificationsIcon, + }, ] } diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index db81b97d..1fd5743f 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -26,6 +26,7 @@ import { useHasCreatedContractToday, } from 'web/hooks/use-has-created-contract-today' import { Row } from '../layout/row' +import NotificationsIcon from 'web/components/notifications-icon' import React, { useEffect, useState } from 'react' import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants' @@ -39,12 +40,17 @@ function IconFromUrl(url: string): React.ComponentType<{ className?: string }> { function getNavigation(username: string) { return [ { name: 'Home', href: '/home', icon: HomeIcon }, - { name: 'Activity', href: '/activity', icon: ChatAltIcon }, { name: 'Portfolio', href: `/${username}/bets`, icon: PresentationChartLineIcon, }, + { + name: 'Notifications', + href: `/notifications`, + icon: NotificationsIcon, + }, + { name: 'Charity', href: '/charity', icon: HeartIcon }, ] } diff --git a/web/components/notifications-icon.tsx b/web/components/notifications-icon.tsx new file mode 100644 index 00000000..95f7b721 --- /dev/null +++ b/web/components/notifications-icon.tsx @@ -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 ( + +
+ {notifications && notifications.length > 0 && ( +
+ {notifications.length} +
+ )} + +
+
+ ) +} diff --git a/web/lib/firebase/notifications.ts b/web/lib/firebase/notifications.ts new file mode 100644 index 00000000..c4a30300 --- /dev/null +++ b/web/lib/firebase/notifications.ts @@ -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( + getNotificationsQuery(userId, unseenOnly), + (notifs) => { + notifs.sort((n1, n2) => n2.createdTime - n1.createdTime) + setNotifications(notifs) + } + ) +} diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx new file mode 100644 index 00000000..b3c10a20 --- /dev/null +++ b/web/pages/notifications.tsx @@ -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 + } + + // TODO: use infinite scroll + return ( + +
+ + <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> + ) +}