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 }