From 1976bc755e1dbe479c892fd49f8ed295ca352a6e Mon Sep 17 00:00:00 2001
From: Ian Philips <iansphilips@gmail.com>
Date: Mon, 6 Jun 2022 11:36:59 -0600
Subject: [PATCH] Revert "Revert "Notification detail, grouping, and settings
 control [wip] (#403)""

This reverts commit 07f2d390e58c0a0fb90dd0f53b76ab5f1baacda9.
---
 common/notification.ts                  |  21 +-
 common/user.ts                          |   3 +
 functions/src/create-notification.ts    | 270 +++++----
 functions/src/index.ts                  |   1 +
 functions/src/on-create-answer.ts       |   4 +-
 functions/src/on-create-comment.ts      |  30 +-
 functions/src/on-follow-user.ts         |  28 +
 functions/src/on-update-contract.ts     |   8 +-
 web/components/choices-toggle-group.tsx |   4 +-
 web/components/notifications-icon.tsx   |  22 +-
 web/hooks/use-notifications.ts          |  66 +++
 web/pages/notifications.tsx             | 724 +++++++++++++++++++++---
 12 files changed, 967 insertions(+), 214 deletions(-)
 create mode 100644 functions/src/on-follow-user.ts
 create mode 100644 web/hooks/use-notifications.ts

diff --git a/common/notification.ts b/common/notification.ts
index f13a75e9..9ccf6f98 100644
--- a/common/notification.ts
+++ b/common/notification.ts
@@ -9,6 +9,7 @@ export type Notification = {
 
   sourceId?: string
   sourceType?: notification_source_types
+  sourceUpdateType?: notification_source_update_types
   sourceContractId?: string
   sourceUserName?: string
   sourceUserUsername?: string
@@ -20,10 +21,24 @@ export type notification_source_types =
   | 'bet'
   | 'answer'
   | 'liquidity'
+  | 'follow'
+  | 'tip'
+  | 'admin_message'
 
-export type notification_reason_types =
+export type notification_source_update_types =
   | 'created'
   | 'updated'
   | 'resolved'
-  | 'tagged'
-  | 'replied'
+  | 'deleted'
+  | 'closed'
+
+export type notification_reason_types =
+  | 'tagged_user'
+  | 'on_users_contract'
+  | 'on_contract_with_users_shares_in'
+  | 'on_contract_with_users_shares_out'
+  | 'on_contract_with_users_answer'
+  | 'on_contract_with_users_comment'
+  | 'reply_to_users_answer'
+  | 'reply_to_users_comment'
+  | 'on_new_follow'
diff --git a/common/user.ts b/common/user.ts
index b93ed4d6..3b74ac1a 100644
--- a/common/user.ts
+++ b/common/user.ts
@@ -36,4 +36,7 @@ export type PrivateUser = {
   initialDeviceToken?: string
   initialIpAddress?: string
   apiKey?: string
+  notificationPreferences?: notification_subscribe_types
 }
+
+export type notification_subscribe_types = 'all' | 'less' | 'none'
diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts
index fc5e606d..308ed490 100644
--- a/functions/src/create-notification.ts
+++ b/functions/src/create-notification.ts
@@ -2,28 +2,34 @@ import * as admin from 'firebase-admin'
 import {
   Notification,
   notification_reason_types,
+  notification_source_update_types,
   notification_source_types,
 } from '../../common/notification'
 import { User } from '../../common/user'
 import { Contract } from '../../common/contract'
-import { getValues } from './utils'
+import { getUserByUsername, getValues } from './utils'
 import { Comment } from '../../common/comment'
 import { uniq } from 'lodash'
 import { Bet } from '../../common/bet'
 import { Answer } from '../../common/answer'
+import { getContractBetMetrics } from '../../common/calculate'
+import { removeUndefinedProps } from '../../common/util/object'
 const firestore = admin.firestore()
 
 type user_to_reason_texts = {
-  [userId: string]: { text: string; reason: notification_reason_types }
+  [userId: string]: { reason: notification_reason_types }
 }
 
 export const createNotification = async (
   sourceId: string,
   sourceType: notification_source_types,
-  reason: notification_reason_types,
-  sourceContract: Contract,
+  sourceUpdateType: notification_source_update_types,
   sourceUser: User,
-  idempotencyKey: string
+  idempotencyKey: string,
+  sourceContract?: Contract,
+  relatedSourceType?: notification_source_types,
+  relatedUserId?: string,
+  sourceText?: string
 ) => {
   const shouldGetNotification = (
     userId: string,
@@ -46,128 +52,170 @@ export const createNotification = async (
         const notification: Notification = {
           id: idempotencyKey,
           userId,
-          reasonText: userToReasonTexts[userId].text,
           reason: userToReasonTexts[userId].reason,
           createdTime: Date.now(),
           isSeen: false,
           sourceId,
           sourceType,
-          sourceContractId: sourceContract.id,
+          sourceUpdateType,
+          sourceContractId: sourceContract?.id,
           sourceUserName: sourceUser.name,
           sourceUserUsername: sourceUser.username,
           sourceUserAvatarUrl: sourceUser.avatarUrl,
         }
-        await notificationRef.set(notification)
+        await notificationRef.set(removeUndefinedProps(notification))
       })
     )
   }
 
-  // TODO: Update for liquidity.
-  // TODO: Find tagged users.
-  // TODO: Find replies to comments.
-  // TODO: Filter bets for only open bets.
-  // TODO: Notify users of their own closed but not resolved contracts.
-  if (
-    sourceType === 'comment' ||
-    sourceType === 'answer' ||
-    sourceType === 'contract'
-  ) {
-    const reasonTextPretext = getReasonTextFromReason(sourceType, reason)
+  const notifyRepliedUsers = async (
+    userToReasonTexts: user_to_reason_texts
+  ) => {
+    if (
+      !relatedSourceType ||
+      !relatedUserId ||
+      !shouldGetNotification(relatedUserId, userToReasonTexts)
+    )
+      return
+    if (relatedSourceType === 'comment') {
+      userToReasonTexts[relatedUserId] = {
+        reason: 'reply_to_users_comment',
+      }
+    } else if (relatedSourceType === 'answer') {
+      userToReasonTexts[relatedUserId] = {
+        reason: 'reply_to_users_answer',
+      }
+    }
+  }
 
-    const notifyContractCreator = async (
-      userToReasonTexts: user_to_reason_texts
-    ) => {
-      if (shouldGetNotification(sourceContract.creatorId, userToReasonTexts))
-        userToReasonTexts[sourceContract.creatorId] = {
-          text: `${reasonTextPretext} your question`,
-          reason,
+  const notifyFollowedUser = async (
+    userToReasonTexts: user_to_reason_texts,
+    followedUserId: string
+  ) => {
+    if (shouldGetNotification(followedUserId, userToReasonTexts))
+      userToReasonTexts[followedUserId] = {
+        reason: 'on_new_follow',
+      }
+  }
+
+  const notifyTaggedUsers = async (userToReasonTexts: user_to_reason_texts) => {
+    if (!sourceText) return
+    const taggedUsers = sourceText.match(/@\w+/g)
+    if (!taggedUsers) return
+    // await all get tagged users:
+    const users = await Promise.all(
+      taggedUsers.map(async (username) => {
+        return await getUserByUsername(username.slice(1))
+      })
+    )
+    users.forEach((taggedUser) => {
+      if (taggedUser && shouldGetNotification(taggedUser.id, userToReasonTexts))
+        userToReasonTexts[taggedUser.id] = {
+          reason: 'tagged_user',
         }
-    }
-
-    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')
+  const notifyContractCreator = async (
+    userToReasonTexts: user_to_reason_texts,
+    sourceContract: Contract
+  ) => {
+    if (shouldGetNotification(sourceContract.creatorId, userToReasonTexts))
+      userToReasonTexts[sourceContract.creatorId] = {
+        reason: 'on_users_contract',
+      }
   }
+
+  const notifyOtherAnswerersOnContract = async (
+    userToReasonTexts: user_to_reason_texts,
+    sourceContract: Contract
+  ) => {
+    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] = {
+          reason: 'on_contract_with_users_answer',
+        }
+    })
+  }
+
+  const notifyOtherCommentersOnContract = async (
+    userToReasonTexts: user_to_reason_texts,
+    sourceContract: Contract
+  ) => {
+    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] = {
+          reason: 'on_contract_with_users_comment',
+        }
+    })
+  }
+
+  const notifyOtherBettorsOnContract = async (
+    userToReasonTexts: user_to_reason_texts,
+    sourceContract: Contract
+  ) => {
+    const betsSnap = await firestore
+      .collection(`contracts/${sourceContract.id}/bets`)
+      .get()
+    const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
+    // filter bets for only users that have an amount invested still
+    const recipientUserIds = uniq(bets.map((bet) => bet.userId)).filter(
+      (userId) => {
+        return (
+          getContractBetMetrics(
+            sourceContract,
+            bets.filter((bet) => bet.userId === userId)
+          ).invested > 0
+        )
+      }
+    )
+    recipientUserIds.forEach((userId) => {
+      if (shouldGetNotification(userId, userToReasonTexts))
+        userToReasonTexts[userId] = {
+          reason: 'on_contract_with_users_shares_in',
+        }
+    })
+  }
+
+  // TODO: Update for liquidity.
+  // TODO: Notify users of their own closed but not resolved contracts.
+  const getUsersToNotify = async () => {
+    const userToReasonTexts: user_to_reason_texts = {}
+    // The following functions modify the userToReasonTexts object in place.
+    if (
+      sourceContract &&
+      (sourceType === 'comment' ||
+        sourceType === 'answer' ||
+        sourceType === 'contract')
+    ) {
+      if (sourceType === 'comment') {
+        await notifyRepliedUsers(userToReasonTexts)
+        await notifyTaggedUsers(userToReasonTexts)
+      }
+      await notifyContractCreator(userToReasonTexts, sourceContract)
+      await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract)
+      await notifyOtherBettorsOnContract(userToReasonTexts, sourceContract)
+      await notifyOtherCommentersOnContract(userToReasonTexts, sourceContract)
+    } else if (sourceType === 'follow' && relatedUserId) {
+      await notifyFollowedUser(userToReasonTexts, relatedUserId)
+    }
+    return userToReasonTexts
+  }
+
+  const userToReasonTexts = await getUsersToNotify()
+  await createUsersNotifications(userToReasonTexts)
 }
diff --git a/functions/src/index.ts b/functions/src/index.ts
index 81ea59e5..910bd937 100644
--- a/functions/src/index.ts
+++ b/functions/src/index.ts
@@ -30,3 +30,4 @@ export * from './market-close-emails'
 export * from './add-liquidity'
 export * from './on-create-answer'
 export * from './on-update-contract'
+export * from './on-follow-user'
diff --git a/functions/src/on-create-answer.ts b/functions/src/on-create-answer.ts
index 3fd7fefa..17863205 100644
--- a/functions/src/on-create-answer.ts
+++ b/functions/src/on-create-answer.ts
@@ -25,8 +25,8 @@ export const onCreateAnswer = functions.firestore
       answer.id,
       'answer',
       'created',
-      contract,
       answerCreator,
-      eventId
+      eventId,
+      contract
     )
   })
diff --git a/functions/src/on-create-comment.ts b/functions/src/on-create-comment.ts
index bf1dacd5..75bfc6b1 100644
--- a/functions/src/on-create-comment.ts
+++ b/functions/src/on-create-comment.ts
@@ -30,15 +30,6 @@ export const onCreateComment = functions
     const commentCreator = await getUser(comment.userId)
     if (!commentCreator) throw new Error('Could not find comment creator')
 
-    await createNotification(
-      comment.id,
-      'comment',
-      'created',
-      contract,
-      commentCreator,
-      eventId
-    )
-
     await firestore
       .collection('contracts')
       .doc(contract.id)
@@ -71,6 +62,27 @@ export const onCreateComment = functions
     const comments = await getValues<Comment>(
       firestore.collection('contracts').doc(contractId).collection('comments')
     )
+    const relatedSourceType = comment.replyToCommentId
+      ? 'comment'
+      : comment.answerOutcome
+      ? 'answer'
+      : undefined
+
+    const relatedUser = comment.replyToCommentId
+      ? comments.find((c) => c.id === comment.replyToCommentId)?.userId
+      : answer?.userId
+
+    await createNotification(
+      comment.id,
+      'comment',
+      'created',
+      commentCreator,
+      eventId,
+      contract,
+      relatedSourceType,
+      relatedUser,
+      comment.text
+    )
 
     const recipientUserIds = uniq([
       contract.creatorId,
diff --git a/functions/src/on-follow-user.ts b/functions/src/on-follow-user.ts
new file mode 100644
index 00000000..61c671db
--- /dev/null
+++ b/functions/src/on-follow-user.ts
@@ -0,0 +1,28 @@
+import * as functions from 'firebase-functions'
+import { getUser } from './utils'
+import { createNotification } from './create-notification'
+
+export const onFollowUser = functions.firestore
+  .document('users/{userId}/follows/{followedUserId}')
+  .onCreate(async (change, context) => {
+    const { userId } = context.params as {
+      userId: string
+    }
+    const { eventId } = context
+
+    const follow = change.data() as { userId: string; timestamp: number }
+
+    const followingUser = await getUser(userId)
+    if (!followingUser) throw new Error('Could not find following user')
+
+    await createNotification(
+      followingUser.id,
+      'follow',
+      'created',
+      followingUser,
+      eventId,
+      undefined,
+      undefined,
+      follow.userId
+    )
+  })
diff --git a/functions/src/on-update-contract.ts b/functions/src/on-update-contract.ts
index 2ccd5540..3bf202a2 100644
--- a/functions/src/on-update-contract.ts
+++ b/functions/src/on-update-contract.ts
@@ -18,9 +18,9 @@ export const onUpdateContract = functions.firestore
         contract.id,
         'contract',
         'resolved',
-        contract,
         contractUpdater,
-        eventId
+        eventId,
+        contract
       )
     } else if (
       previousValue.closeTime !== contract.closeTime ||
@@ -30,9 +30,9 @@ export const onUpdateContract = functions.firestore
         contract.id,
         'contract',
         'updated',
-        contract,
         contractUpdater,
-        eventId
+        eventId,
+        contract
       )
     }
   })
diff --git a/web/components/choices-toggle-group.tsx b/web/components/choices-toggle-group.tsx
index 13800ba6..f974d72f 100644
--- a/web/components/choices-toggle-group.tsx
+++ b/web/components/choices-toggle-group.tsx
@@ -8,6 +8,7 @@ export function ChoicesToggleGroup(props: {
   isSubmitting?: boolean
   setChoice: (p: number | string) => void
   className?: string
+  toggleClassName?: string
   children?: React.ReactNode
 }) {
   const {
@@ -17,6 +18,7 @@ export function ChoicesToggleGroup(props: {
     choicesMap,
     className,
     children,
+    toggleClassName,
   } = props
   return (
     <RadioGroup
@@ -37,7 +39,7 @@ export function ChoicesToggleGroup(props: {
                 : 'border-gray-200 bg-white text-gray-900 hover:bg-gray-50',
               'flex cursor-pointer items-center justify-center rounded-md border py-3 px-3 text-sm font-medium normal-case',
               "hover:ring-offset-2' hover:ring-2 hover:ring-indigo-500",
-              className
+              toggleClassName
             )
           }
           disabled={isSubmitting}
diff --git a/web/components/notifications-icon.tsx b/web/components/notifications-icon.tsx
index 95f7b721..02fbea5b 100644
--- a/web/components/notifications-icon.tsx
+++ b/web/components/notifications-icon.tsx
@@ -2,32 +2,26 @@ 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'
+import { useNotifications } from 'web/hooks/use-notifications'
 
 export default function NotificationsIcon(props: { className?: string }) {
   const user = useUser()
-  const [notifications, setNotifications] = useState<
-    Notification[] | undefined
-  >()
+  const notifications = useNotifications(user?.id, { unseenOnly: true })
+  const [seen, setSeen] = useState(false)
+
   const router = useRouter()
   useEffect(() => {
-    if (router.pathname.endsWith('notifications')) return setNotifications([])
+    if (router.pathname.endsWith('notifications')) return setSeen(true)
+    else setSeen(false)
   }, [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>
+        {!seen && notifications && notifications.length > 0 && (
+          <div className="absolute mt-0.5 ml-3.5 min-h-[10px] min-w-[10px] rounded-full bg-indigo-500 p-[2px] text-center text-[10px] leading-3 text-white lg:-mt-0 lg:ml-2.5"></div>
         )}
         <BellIcon className={clsx(props.className)} />
       </div>
diff --git a/web/hooks/use-notifications.ts b/web/hooks/use-notifications.ts
new file mode 100644
index 00000000..0e303036
--- /dev/null
+++ b/web/hooks/use-notifications.ts
@@ -0,0 +1,66 @@
+import { useEffect, useState } from 'react'
+import { listenForPrivateUser } from 'web/lib/firebase/users'
+import { notification_subscribe_types, PrivateUser } from 'common/user'
+import { Notification } from 'common/notification'
+import { listenForNotifications } from 'web/lib/firebase/notifications'
+
+export function useNotifications(
+  userId: string | undefined,
+  options: { unseenOnly: boolean }
+) {
+  const { unseenOnly } = options
+  const [privateUser, setPrivateUser] = useState<PrivateUser | null>(null)
+  const [notifications, setNotifications] = useState<Notification[]>([])
+  const [userAppropriateNotifications, setUserAppropriateNotifications] =
+    useState<Notification[]>([])
+
+  useEffect(() => {
+    if (userId) listenForPrivateUser(userId, setPrivateUser)
+  }, [userId])
+
+  useEffect(() => {
+    if (privateUser)
+      return listenForNotifications(
+        privateUser.id,
+        setNotifications,
+        unseenOnly
+      )
+  }, [privateUser])
+
+  useEffect(() => {
+    if (!privateUser) return
+
+    const notificationsToShow = getAppropriateNotifications(
+      notifications,
+      privateUser.notificationPreferences
+    )
+    setUserAppropriateNotifications(notificationsToShow)
+  }, [privateUser, notifications])
+
+  return userAppropriateNotifications
+}
+
+const lessPriorityReasons = [
+  'on_contract_with_users_comment',
+  'on_contract_with_users_answer',
+  'on_contract_with_users_shares_out',
+  // Not sure if users will want to see these w/ less:
+  // 'on_contract_with_users_shares_in',
+]
+
+function getAppropriateNotifications(
+  notifications: Notification[],
+  notificationPreferences?: notification_subscribe_types
+) {
+  if (notificationPreferences === 'all') return notifications
+  if (notificationPreferences === 'less')
+    return notifications.filter(
+      (n) =>
+        n.reason &&
+        // Show all contract notifications
+        (n.sourceType === 'contract' || !lessPriorityReasons.includes(n.reason))
+    )
+  if (notificationPreferences === 'none') return []
+
+  return notifications
+}
diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx
index 63c9fc2f..80383425 100644
--- a/web/pages/notifications.tsx
+++ b/web/pages/notifications.tsx
@@ -6,7 +6,6 @@ import {
   notification_reason_types,
   notification_source_types,
 } 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'
@@ -19,22 +18,90 @@ 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 { User } from 'common/user'
+import { notification_subscribe_types, PrivateUser } from 'common/user'
 import { useContract } from 'web/hooks/use-contract'
 import { Contract } from 'common/contract'
+import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
+import { listenForPrivateUser, updatePrivateUser } from 'web/lib/firebase/users'
+import { LoadingIndicator } from 'web/components/loading-indicator'
+import clsx from 'clsx'
+import { groupBy, map } from 'lodash'
+import { UsersIcon } from '@heroicons/react/solid'
+import { RelativeTimestamp } from 'web/components/relative-timestamp'
+import { Linkify } from 'web/components/linkify'
+import {
+  FreeResponseOutcomeLabel,
+  OutcomeLabel,
+} from 'web/components/outcome-label'
+import { useNotifications } from 'web/hooks/use-notifications'
+import { getContractFromId } from 'web/lib/firebase/contracts'
+import { CheckIcon, XIcon } from '@heroicons/react/outline'
+import toast from 'react-hot-toast'
+
+type NotificationGroup = {
+  notifications: Notification[]
+  sourceContractId: string
+  isSeen: boolean
+  timePeriod: string
+}
 
 export default function Notifications() {
   const user = useUser()
-  const [notifications, setNotifications] = useState<
-    Notification[] | undefined
-  >()
+  const [allNotificationGroups, setAllNotificationsGroups] = useState<
+    NotificationGroup[]
+  >([])
+  const [unseenNotificationGroups, setUnseenNotificationGroups] = useState<
+    NotificationGroup[]
+  >([])
+  const notifications = useNotifications(user?.id, { unseenOnly: false })
 
   useEffect(() => {
-    if (user) return listenForNotifications(user.id, setNotifications)
-  }, [user])
+    const notificationIdsToShow = notifications.map(
+      (notification) => notification.id
+    )
+    // Hide notifications the user doesn't want to see.
+    const notificationIdsToHide = notifications
+      .filter(
+        (notification) => !notificationIdsToShow.includes(notification.id)
+      )
+      .map((notification) => notification.id)
 
-  if (!user) {
-    // TODO: return sign in page
+    // Because hidden notifications won't be rendered, set them to seen here
+    setNotificationsAsSeen(
+      notifications.filter((n) => notificationIdsToHide.includes(n.id))
+    )
+
+    // Group notifications by contract and 24-hour time period.
+    const allGroupedNotifications = groupNotifications(
+      notifications,
+      notificationIdsToHide
+    )
+
+    // Don't add notifications that are already visible or have been seen.
+    const currentlyVisibleUnseenNotificationIds = Object.values(
+      unseenNotificationGroups
+    )
+      .map((n) => n.notifications.map((n) => n.id))
+      .flat()
+    const unseenGroupedNotifications = groupNotifications(
+      notifications.filter(
+        (notification) =>
+          !notification.isSeen ||
+          currentlyVisibleUnseenNotificationIds.includes(notification.id)
+      ),
+      notificationIdsToHide
+    )
+    setAllNotificationsGroups(allGroupedNotifications)
+    setUnseenNotificationGroups(unseenGroupedNotifications)
+
+    // We don't want unseenNotificationsGroup to be in the dependencies as we update it here.
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [notifications])
+
+  if (user === undefined) {
+    return <LoadingIndicator />
+  }
+  if (user === null) {
     return <Custom404 />
   }
 
@@ -47,18 +114,55 @@ export default function Notifications() {
           className={'pb-2 pt-1 '}
           defaultIndex={0}
           tabs={[
+            {
+              title: 'New Notifications',
+              content: (
+                <div className={''}>
+                  {unseenNotificationGroups.length === 0 &&
+                    "You don't have any new notifications."}
+                  {unseenNotificationGroups.map((notification) =>
+                    notification.notifications.length === 1 ? (
+                      <NotificationItem
+                        notification={notification.notifications[0]}
+                        key={notification.notifications[0].id}
+                      />
+                    ) : (
+                      <NotificationGroupItem
+                        notificationGroup={notification}
+                        key={notification.sourceContractId}
+                      />
+                    )
+                  )}
+                </div>
+              ),
+            },
             {
               title: 'All Notifications',
               content: (
                 <div className={''}>
-                  {notifications &&
-                    notifications.map((notification) => (
-                      <Notification
-                        currentUser={user}
-                        notification={notification}
-                        key={notification.id}
+                  {allNotificationGroups.length === 0 &&
+                    "You don't have any notifications."}
+                  {allNotificationGroups.map((notification) =>
+                    notification.notifications.length === 1 ? (
+                      <NotificationItem
+                        notification={notification.notifications[0]}
+                        key={notification.notifications[0].id}
                       />
-                    ))}
+                    ) : (
+                      <NotificationGroupItem
+                        notificationGroup={notification}
+                        key={notification.sourceContractId}
+                      />
+                    )
+                  )}
+                </div>
+              ),
+            },
+            {
+              title: 'Settings',
+              content: (
+                <div className={''}>
+                  <NotificationSettings />
                 </div>
               ),
             },
@@ -69,17 +173,378 @@ export default function Notifications() {
   )
 }
 
-function Notification(props: {
-  currentUser: User
-  notification: Notification
+const setNotificationsAsSeen = (notifications: Notification[]) => {
+  notifications.forEach((notification) => {
+    if (!notification.isSeen)
+      updateDoc(
+        doc(db, `users/${notification.userId}/notifications/`, notification.id),
+        {
+          ...notification,
+          isSeen: true,
+          viewTime: new Date(),
+        }
+      )
+  })
+  return notifications
+}
+
+function groupNotifications(
+  notifications: Notification[],
+  hideNotificationIds: string[]
+) {
+  // Then remove them from the list of notifications to show
+  notifications = notifications.filter(
+    (notification) => !hideNotificationIds.includes(notification.id)
+  )
+
+  let notificationGroups: NotificationGroup[] = []
+  const notificationGroupsByDay = groupBy(notifications, (notification) =>
+    new Date(notification.createdTime).toDateString()
+  )
+  Object.keys(notificationGroupsByDay).forEach((day) => {
+    // Group notifications by contract:
+    const groupedNotificationsByContractId = groupBy(
+      notificationGroupsByDay[day],
+      (notification) => {
+        return notification.sourceContractId
+      }
+    )
+    notificationGroups = notificationGroups.concat(
+      map(groupedNotificationsByContractId, (notifications, contractId) => {
+        // Create a notification group for each contract within each day
+        const notificationGroup: NotificationGroup = {
+          notifications: groupedNotificationsByContractId[contractId].sort(
+            (a, b) => {
+              return b.createdTime - a.createdTime
+            }
+          ),
+          sourceContractId: contractId,
+          isSeen: groupedNotificationsByContractId[contractId][0].isSeen,
+          timePeriod: day,
+        }
+        return notificationGroup
+      })
+    )
+  })
+  return notificationGroups
+}
+
+function NotificationGroupItem(props: {
+  notificationGroup: NotificationGroup
+  className?: string
 }) {
-  const { notification, currentUser } = props
+  const { notificationGroup, className } = props
+  const { sourceContractId, notifications } = notificationGroup
+  const contract = useContract(sourceContractId ?? '')
+  const numSummaryLines = 3
+  const [expanded, setExpanded] = useState(false)
+
+  useEffect(() => {
+    if (!contract) return
+    setNotificationsAsSeen(notifications)
+  }, [contract, notifications])
+
+  return (
+    <div
+      className={clsx(
+        'relative cursor-pointer bg-white px-2 pt-6 text-sm',
+        className,
+        !expanded ? 'hover:bg-gray-100' : ''
+      )}
+      onClick={() => setExpanded(!expanded)}
+    >
+      {expanded && (
+        <span
+          className="absolute top-14 left-6 -ml-px h-[calc(100%-5rem)] w-0.5 bg-gray-200"
+          aria-hidden="true"
+        />
+      )}
+      <Row className={'items-center text-gray-500 sm:justify-start'}>
+        <div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-200">
+          <UsersIcon className="h-5 w-5 text-gray-500" aria-hidden="true" />
+        </div>
+        <div className={'flex-1 overflow-hidden pl-2 sm:flex'}>
+          <div
+            onClick={() => setExpanded(!expanded)}
+            className={'line-clamp-1 cursor-pointer pl-1  sm:pl-0'}
+          >
+            {'Activity on '}
+            <span className={'mx-1 font-bold'}>{contract?.question}</span>
+          </div>
+          <RelativeTimestamp time={notifications[0].createdTime} />
+        </div>
+      </Row>
+      <div>
+        <div className={clsx('mt-1 md:text-base', expanded ? 'pl-4' : '')}>
+          {' '}
+          <div className={'line-clamp-4 mt-1 gap-1 whitespace-pre-line'}>
+            {!expanded ? (
+              <>
+                {notifications
+                  .slice(0, numSummaryLines)
+                  .map((notification, i) => {
+                    return (
+                      <NotificationItem
+                        notification={notification}
+                        justSummary={true}
+                      />
+                    )
+                  })}
+                <div className={'text-sm text-gray-500 hover:underline '}>
+                  {notifications.length - numSummaryLines > 0
+                    ? 'And ' +
+                      (notifications.length - numSummaryLines) +
+                      ' more...'
+                    : ''}
+                </div>
+              </>
+            ) : (
+              <>
+                {notifications.map((notification, i) => (
+                  <NotificationItem
+                    notification={notification}
+                    key={notification.id}
+                    justSummary={false}
+                  />
+                ))}
+              </>
+            )}
+          </div>
+        </div>
+
+        <div className={'mt-6 border-b border-gray-300'} />
+      </div>
+    </div>
+  )
+}
+
+function NotificationSettings() {
+  const user = useUser()
+  const [notificationSettings, setNotificationSettings] =
+    useState<notification_subscribe_types>('all')
+  const [emailNotificationSettings, setEmailNotificationSettings] =
+    useState<notification_subscribe_types>('all')
+  const [privateUser, setPrivateUser] = useState<PrivateUser | null>(null)
+  const [showSettings, setShowSettings] = useState<'in-app' | 'email' | 'none'>(
+    'none'
+  )
+
+  useEffect(() => {
+    if (user) listenForPrivateUser(user.id, setPrivateUser)
+  }, [user])
+
+  useEffect(() => {
+    if (!privateUser) return
+    if (privateUser.notificationPreferences) {
+      setNotificationSettings(privateUser.notificationPreferences)
+    }
+    if (
+      privateUser.unsubscribedFromResolutionEmails &&
+      privateUser.unsubscribedFromCommentEmails &&
+      privateUser.unsubscribedFromAnswerEmails
+    ) {
+      setEmailNotificationSettings('none')
+    } else if (
+      !privateUser.unsubscribedFromResolutionEmails &&
+      !privateUser.unsubscribedFromCommentEmails &&
+      !privateUser.unsubscribedFromAnswerEmails
+    ) {
+      setEmailNotificationSettings('all')
+    } else {
+      setEmailNotificationSettings('less')
+    }
+  }, [privateUser])
+
+  const loading = 'Changing Notifications Settings'
+  const success = 'Notification Settings Changed!'
+  function changeEmailNotifications(newValue: notification_subscribe_types) {
+    if (!privateUser) return
+    setShowSettings('email')
+    if (newValue === 'all') {
+      toast.promise(
+        updatePrivateUser(privateUser.id, {
+          unsubscribedFromResolutionEmails: false,
+          unsubscribedFromCommentEmails: false,
+          unsubscribedFromAnswerEmails: false,
+        }),
+        {
+          loading,
+          success,
+          error: (err) => `${err.message}`,
+        }
+      )
+    } else if (newValue === 'less') {
+      toast.promise(
+        updatePrivateUser(privateUser.id, {
+          unsubscribedFromResolutionEmails: false,
+          unsubscribedFromCommentEmails: true,
+          unsubscribedFromAnswerEmails: true,
+        }),
+        {
+          loading,
+          success,
+          error: (err) => `${err.message}`,
+        }
+      )
+    } else if (newValue === 'none') {
+      toast.promise(
+        updatePrivateUser(privateUser.id, {
+          unsubscribedFromResolutionEmails: true,
+          unsubscribedFromCommentEmails: true,
+          unsubscribedFromAnswerEmails: true,
+        }),
+        {
+          loading,
+          success,
+          error: (err) => `${err.message}`,
+        }
+      )
+    }
+  }
+
+  function changeInAppNotificationSettings(
+    newValue: notification_subscribe_types
+  ) {
+    if (!privateUser) return
+    toast.promise(
+      updatePrivateUser(privateUser.id, {
+        notificationPreferences: newValue,
+      }),
+      {
+        loading,
+        success,
+        error: (err) => `${err.message}`,
+      }
+    )
+    setShowSettings('in-app')
+  }
+
+  useEffect(() => {
+    if (privateUser && privateUser.notificationPreferences)
+      setNotificationSettings(privateUser.notificationPreferences)
+    else setNotificationSettings('all')
+  }, [privateUser])
+
+  if (!privateUser) {
+    return <LoadingIndicator spinnerClassName={'border-gray-500 h-4 w-4'} />
+  }
+
+  function NotificationSettingLine(props: {
+    label: string
+    highlight: boolean
+  }) {
+    const { label, highlight } = props
+    return (
+      <Row className={clsx('my-1 text-gray-300', highlight && '!text-black')}>
+        {highlight ? <CheckIcon height={20} /> : <XIcon height={20} />}
+        {label}
+      </Row>
+    )
+  }
+
+  return (
+    <div className={'p-2'}>
+      <div>In App Notifications</div>
+      <ChoicesToggleGroup
+        currentChoice={notificationSettings}
+        choicesMap={{ All: 'all', Less: 'less', None: 'none' }}
+        setChoice={(choice) =>
+          changeInAppNotificationSettings(
+            choice as notification_subscribe_types
+          )
+        }
+        className={'col-span-4 p-2'}
+        toggleClassName={'w-24'}
+      />
+      <div className={'mt-4'}>Email Notifications</div>
+      <ChoicesToggleGroup
+        currentChoice={emailNotificationSettings}
+        choicesMap={{ All: 'all', Less: 'less', None: 'none' }}
+        setChoice={(choice) =>
+          changeEmailNotifications(choice as notification_subscribe_types)
+        }
+        className={'col-span-4 p-2'}
+        toggleClassName={'w-24'}
+      />
+      <div className={'mt-4 text-base'}>
+        {showSettings === 'in-app' ? (
+          <div>
+            <div className={''}>
+              You will receive notifications for:
+              <NotificationSettingLine
+                label={"Resolutions on questions you've interacted with"}
+                highlight={notificationSettings !== 'none'}
+              />
+              <NotificationSettingLine
+                highlight={notificationSettings !== 'none'}
+                label={'Activity on your own questions, comments, & answers'}
+              />
+              <NotificationSettingLine
+                highlight={notificationSettings !== 'none'}
+                label={"Activity on questions you're betting on"}
+              />
+              <NotificationSettingLine
+                label={"Activity on questions you've ever bet or commented on"}
+                highlight={notificationSettings === 'all'}
+              />
+            </div>
+          </div>
+        ) : showSettings === 'email' ? (
+          <div>
+            You will receive emails for:
+            <NotificationSettingLine
+              label={"Resolutions on questions you're betting on"}
+              highlight={emailNotificationSettings !== 'none'}
+            />
+            <NotificationSettingLine
+              label={'Closure of your questions'}
+              highlight={emailNotificationSettings !== 'none'}
+            />
+            <NotificationSettingLine
+              label={'Activity on your questions'}
+              highlight={emailNotificationSettings === 'all'}
+            />
+            <NotificationSettingLine
+              label={"Activity on questions you've answered or commented on"}
+              highlight={emailNotificationSettings === 'all'}
+            />
+          </div>
+        ) : (
+          <div />
+        )}
+      </div>
+    </div>
+  )
+}
+
+async function getNotificationSummaryText(
+  sourceId: string,
+  sourceContractId: string,
+  sourceType: 'answer' | 'comment',
+  setText: (text: string) => void
+) {
+  if (sourceType === 'answer') {
+    const answer = await getValue<Answer>(
+      doc(db, `contracts/${sourceContractId}/answers/`, sourceId)
+    )
+    setText(answer?.text ?? '')
+  } else {
+    const comment = await getValue<Comment>(
+      doc(db, `contracts/${sourceContractId}/comments/`, sourceId)
+    )
+    setText(comment?.text ?? '')
+  }
+}
+
+function NotificationItem(props: {
+  notification: Notification
+  justSummary?: boolean
+}) {
+  const { notification, justSummary } = props
   const {
     sourceType,
     sourceContractId,
     sourceId,
-    userId,
-    id,
     sourceUserName,
     sourceUserAvatarUrl,
     reasonText,
@@ -87,42 +552,41 @@ function Notification(props: {
     sourceUserUsername,
     createdTime,
   } = notification
-  const [subText, setSubText] = useState<string>('')
-  const contract = useContract(sourceContractId ?? '')
-
+  const [notificationText, setNotificationText] = useState<string>('')
+  const [contract, setContract] = useState<Contract | null>(null)
   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(),
+    if (!sourceContractId) return
+    getContractFromId(sourceContractId).then((contract) => {
+      if (contract) setContract(contract)
     })
-  }, [notification, contract, currentUser, id, userId])
+  }, [sourceContractId])
+
+  useEffect(() => {
+    if (!contract || !sourceContractId) return
+    if (sourceType === 'contract') {
+      // We don't handle anything other than contract updates & resolution yet.
+      if (contract.resolution) setNotificationText(contract.resolution)
+      else setNotificationText(contract.question)
+      return
+    }
+    if (!sourceId) return
+
+    if (sourceType === 'answer' || sourceType === 'comment') {
+      getNotificationSummaryText(
+        sourceId,
+        sourceContractId,
+        sourceType,
+        setNotificationText
+      )
+    } else if (reasonText) {
+      // Handle arbitrary notifications with reason text here.
+      setNotificationText(reasonText)
+    }
+  }, [contract, reasonText, sourceContractId, sourceId, sourceType])
+
+  useEffect(() => {
+    setNotificationsAsSeen([notification])
+  }, [notification])
 
   function getSourceUrl(sourceId?: string) {
     if (!contract) return ''
@@ -144,8 +608,53 @@ function Notification(props: {
     }
   }
 
+  function isNotificationContractResolution() {
+    return sourceType === 'contract' && contract?.resolution
+  }
+
+  if (justSummary) {
+    return (
+      <Row className={'items-center text-sm text-gray-500 sm:justify-start'}>
+        <div className={'line-clamp-1 flex-1 overflow-hidden sm:flex'}>
+          <div className={'flex pl-1 sm:pl-0'}>
+            <UserLink
+              name={sourceUserName || ''}
+              username={sourceUserUsername || ''}
+              className={'mr-0 flex-shrink-0'}
+            />
+            <div className={'inline-flex overflow-hidden text-ellipsis pl-1'}>
+              {sourceType &&
+                reason &&
+                getReasonTextFromReason(
+                  sourceType,
+                  reason,
+                  contract,
+                  true
+                ).replace(' on', '')}
+              <div className={'ml-1 text-black'}>
+                {contract ? (
+                  <NotificationTextLabel
+                    contract={contract}
+                    notificationText={notificationText}
+                    className={'line-clamp-1'}
+                  />
+                ) : sourceType != 'follow' ? (
+                  <LoadingIndicator
+                    spinnerClassName={'border-gray-500 h-4 w-4'}
+                  />
+                ) : (
+                  <div />
+                )}
+              </div>
+            </div>
+          </div>
+        </div>
+      </Row>
+    )
+  }
+
   return (
-    <div className={'bg-white px-1 pt-6 text-sm sm:px-4'}>
+    <div className={'bg-white px-2 pt-6 text-sm sm:px-4'}>
       <Row className={'items-center text-gray-500 sm:justify-start'}>
         <Avatar
           avatarUrl={sourceUserAvatarUrl}
@@ -156,7 +665,7 @@ function Notification(props: {
         <div className={'flex-1 overflow-hidden sm:flex'}>
           <div
             className={
-              'flex max-w-sm shrink overflow-hidden text-ellipsis sm:max-w-md'
+              'flex max-w-xl shrink overflow-hidden text-ellipsis pl-1 sm:pl-0'
             }
           >
             <UserLink
@@ -168,12 +677,11 @@ function Notification(props: {
               href={getSourceUrl(sourceId)}
               className={'inline-flex overflow-hidden text-ellipsis pl-1'}
             >
-              {sourceType && reason ? (
+              {sourceType && reason && (
                 <div className={'inline truncate'}>
                   {getReasonTextFromReason(sourceType, reason, contract)}
+                  <span className={'mx-1 font-bold'}>{contract?.question}</span>
                 </div>
-              ) : (
-                reasonText
               )}
             </a>
           </div>
@@ -189,11 +697,16 @@ function Notification(props: {
       </Row>
       <a href={getSourceUrl(sourceId)}>
         <div className={'mt-1 md:text-base'}>
-          {' '}
-          {contract && subText === contract.question ? (
-            <div className={'text-indigo-700 hover:underline'}>{subText}</div>
+          {isNotificationContractResolution() && ' Resolved:'}{' '}
+          {contract ? (
+            <NotificationTextLabel
+              contract={contract}
+              notificationText={notificationText}
+            />
+          ) : sourceType != 'follow' ? (
+            <LoadingIndicator spinnerClassName={'border-gray-500 h-4 w-4'} />
           ) : (
-            <div className={'line-clamp-4 whitespace-pre-line'}>{subText}</div>
+            <div />
           )}
         </div>
 
@@ -203,19 +716,90 @@ function Notification(props: {
   )
 }
 
+function NotificationTextLabel(props: {
+  contract: Contract
+  notificationText: string
+  className?: string
+}) {
+  const { contract, notificationText, className } = props
+  if (notificationText === contract.question) {
+    return (
+      <div className={clsx('text-indigo-700 hover:underline', className)}>
+        {notificationText}
+      </div>
+    )
+  } else if (notificationText === contract.resolution) {
+    if (contract.outcomeType === 'FREE_RESPONSE') {
+      return (
+        <FreeResponseOutcomeLabel
+          contract={contract}
+          resolution={contract.resolution}
+          truncate={'long'}
+          answerClassName={className}
+        />
+      )
+    }
+    return (
+      <OutcomeLabel
+        contract={contract}
+        outcome={contract.resolution}
+        truncate={'long'}
+      />
+    )
+  } else {
+    return (
+      <div
+        className={className ? className : 'line-clamp-4 whitespace-pre-line'}
+      >
+        <Linkify text={notificationText} />
+      </div>
+    )
+  }
+}
+
 function getReasonTextFromReason(
   source: notification_source_types,
   reason: notification_reason_types,
-  contract: Contract | undefined
+  contract: Contract | undefined | null,
+  simple?: boolean
 ) {
+  let reasonText = ''
   switch (source) {
     case 'comment':
-      return `commented on ${contract?.question}`
+      if (reason === 'reply_to_users_answer')
+        reasonText = !simple ? 'replied to your answer on' : 'replied'
+      else if (reason === 'tagged_user')
+        reasonText = !simple ? 'tagged you in a comment on' : 'tagged you'
+      else if (reason === 'reply_to_users_comment')
+        reasonText = !simple ? 'replied to your comment on' : 'replied'
+      else if (reason === 'on_users_contract')
+        reasonText = !simple ? `commented on your question` : 'commented'
+      else if (reason === 'on_contract_with_users_comment')
+        reasonText = `commented on`
+      else if (reason === 'on_contract_with_users_answer')
+        reasonText = `commented on`
+      else if (reason === 'on_contract_with_users_shares_in')
+        reasonText = `commented`
+      else reasonText = `commented on`
+      break
     case 'contract':
-      return `${reason} ${contract?.question}`
+      if (contract?.resolution) reasonText = `resolved`
+      else reasonText = `updated`
+      break
     case 'answer':
-      return `answered ${contract?.question}`
+      if (reason === 'on_users_contract') reasonText = `answered your question `
+      if (reason === 'on_contract_with_users_comment') reasonText = `answered`
+      else if (reason === 'on_contract_with_users_answer')
+        reasonText = `answered`
+      else if (reason === 'on_contract_with_users_shares_in')
+        reasonText = `answered`
+      else reasonText = `answered`
+      break
+    case 'follow':
+      reasonText = 'followed you'
+      break
     default:
-      return ''
+      reasonText = ''
   }
+  return reasonText
 }