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( - firestore - .collection('contracts') - .doc(sourceContract.id) - .collection('answers') - ) - const recipientUserIds = uniq(answers.map((answer) => answer.userId)) - recipientUserIds.forEach((userId) => { - if (shouldGetNotification(userId, userToReasonTexts)) - userToReasonTexts[userId] = { - text: `${reasonTextPretext} a question you submitted an answer to`, - reason, - } - }) - } - - const notifyOtherCommentersOnContract = async ( - userToReasonTexts: user_to_reason_texts - ) => { - const comments = await getValues( - firestore - .collection('contracts') - .doc(sourceContract.id) - .collection('comments') - ) - const recipientUserIds = uniq(comments.map((comment) => comment.userId)) - recipientUserIds.forEach((userId) => { - if (shouldGetNotification(userId, userToReasonTexts)) - userToReasonTexts[userId] = { - text: `${reasonTextPretext} a question you commented on`, - reason, - } - }) - } - - const notifyOtherBettorsOnContract = async ( - userToReasonTexts: user_to_reason_texts - ) => { - const betsSnap = await firestore - .collection(`contracts/${sourceContract.id}/bets`) - .get() - const bets = betsSnap.docs.map((doc) => doc.data() as Bet) - const recipientUserIds = uniq(bets.map((bet) => bet.userId)) - recipientUserIds.forEach((userId) => { - if (shouldGetNotification(userId, userToReasonTexts)) - userToReasonTexts[userId] = { - text: `${reasonTextPretext} a question you bet on`, - reason, - } - }) - } - - const getUsersToNotify = async () => { - const userToReasonTexts: user_to_reason_texts = {} - // The following functions modify the userToReasonTexts object in place. - await notifyContractCreator(userToReasonTexts) - await notifyOtherAnswerersOnContract(userToReasonTexts) - await notifyOtherCommentersOnContract(userToReasonTexts) - await notifyOtherBettorsOnContract(userToReasonTexts) - return userToReasonTexts - } - - const userToReasonTexts = await getUsersToNotify() - await createUsersNotifications(userToReasonTexts) + }) } -} -function getReasonTextFromReason( - source: notification_source_types, - reason: notification_reason_types -) { - // TODO: Find tagged users. - // TODO: Find replies to comments. - switch (source) { - case 'comment': - return 'commented on' - case 'contract': - return reason - case 'answer': - return 'answered' - default: - throw new Error('Invalid notification reason') + 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( + 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( + 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( 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 ( () + 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 (
- {notifications && notifications.length > 0 && ( -
- {notifications.length} -
+ {!seen && notifications && notifications.length > 0 && ( +
)}
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(null) + const [notifications, setNotifications] = useState([]) + const [userAppropriateNotifications, setUserAppropriateNotifications] = + useState([]) + + 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 + } + if (user === null) { return } @@ -47,18 +114,55 @@ export default function Notifications() { className={'pb-2 pt-1 '} defaultIndex={0} tabs={[ + { + title: 'New Notifications', + content: ( +
+ {unseenNotificationGroups.length === 0 && + "You don't have any new notifications."} + {unseenNotificationGroups.map((notification) => + notification.notifications.length === 1 ? ( + + ) : ( + + ) + )} +
+ ), + }, { title: 'All Notifications', content: (
- {notifications && - notifications.map((notification) => ( - + notification.notifications.length === 1 ? ( + - ))} + ) : ( + + ) + )} +
+ ), + }, + { + title: 'Settings', + content: ( +
+
), }, @@ -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 ( +
setExpanded(!expanded)} + > + {expanded && ( +