import { Tabs } from 'web/components/layout/tabs' import { usePrivateUser, useUser } from 'web/hooks/use-user' import React, { useEffect, useState } from 'react' import { Notification, notification_source_types } from 'common/notification' import { Avatar, EmptyAvatar } from 'web/components/avatar' import { Row } from 'web/components/layout/row' import { Page } from 'web/components/page' import { Title } from 'web/components/title' import { doc, updateDoc } from 'firebase/firestore' import { db } from 'web/lib/firebase/init' import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' import { UserLink } from 'web/components/user-page' import { notification_subscribe_types, PrivateUser } from 'common/user' 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 { RelativeTimestamp } from 'web/components/relative-timestamp' import { Linkify } from 'web/components/linkify' import { BinaryOutcomeLabel, CancelLabel, MultiLabel, ProbPercentLabel, } from 'web/components/outcome-label' import { NotificationGroup, usePreferredGroupedNotifications, } from 'web/hooks/use-notifications' import { CheckIcon, TrendingUpIcon, XIcon } from '@heroicons/react/outline' import toast from 'react-hot-toast' import { formatMoney } from 'common/util/format' import { groupPath } from 'web/lib/firebase/groups' import { UNIQUE_BETTOR_BONUS_AMOUNT } from 'common/numeric-constants' import { groupBy, sum, uniq } from 'lodash' import Custom404 from 'web/pages/404' export const NOTIFICATIONS_PER_PAGE = 30 const MULTIPLE_USERS_KEY = 'multipleUsers' export default function Notifications() { const user = useUser() const privateUser = usePrivateUser(user?.id) if (!user) return return ( ) : ( ), }, { title: 'Settings', content: ( ), }, ]} /> ) } function NotificationsList(props: { privateUser: PrivateUser }) { const { privateUser } = props const [page, setPage] = useState(1) const allGroupedNotifications = usePreferredGroupedNotifications(privateUser) const [paginatedGroupedNotifications, setPaginatedGroupedNotifications] = useState(undefined) useEffect(() => { if (!allGroupedNotifications) return const start = (page - 1) * NOTIFICATIONS_PER_PAGE const end = start + NOTIFICATIONS_PER_PAGE const maxNotificationsToShow = allGroupedNotifications.slice(start, end) const remainingNotification = allGroupedNotifications.slice(end) for (const notification of remainingNotification) { if (notification.isSeen) break else setNotificationsAsSeen(notification.notifications) } setPaginatedGroupedNotifications(maxNotificationsToShow) }, [allGroupedNotifications, page]) if (!paginatedGroupedNotifications || !allGroupedNotifications) return return ( {paginatedGroupedNotifications.length === 0 && ( You don't have any notifications. Try changing your settings to see more. )} {paginatedGroupedNotifications.map((notification) => notification.type === 'income' ? ( ) : notification.notifications.length === 1 ? ( ) : ( ) )} {paginatedGroupedNotifications.length > 0 && allGroupedNotifications.length > NOTIFICATIONS_PER_PAGE && ( Showing{' '} {page === 1 ? page : (page - 1) * NOTIFICATIONS_PER_PAGE} {' '} to{' '} {page * NOTIFICATIONS_PER_PAGE} {' '} of{' '} {allGroupedNotifications.length} {' '} results page > 1 && setPage(page - 1)} > Previous page < allGroupedNotifications?.length / NOTIFICATIONS_PER_PAGE && setPage(page + 1) } > Next )} ) } function IncomeNotificationGroupItem(props: { notificationGroup: NotificationGroup className?: string }) { const { notificationGroup, className } = props const { notifications } = notificationGroup const numSummaryLines = 3 const [expanded, setExpanded] = useState(false) const [highlighted, setHighlighted] = useState( notifications.some((n) => !n.isSeen) ) useEffect(() => { setNotificationsAsSeen(notifications) }, [notifications]) useEffect(() => { if (expanded) setHighlighted(false) }, [expanded]) const totalIncome = sum( notifications.map((notification) => notification.sourceText ? parseInt(notification.sourceText) : 0 ) ) // Loop through the contracts and combine the notification items into one function combineNotificationsByAddingNumericSourceTexts( notifications: Notification[] ) { const newNotifications = [] const groupedNotificationsBySourceType = groupBy( notifications, (n) => n.sourceType ) for (const sourceType in groupedNotificationsBySourceType) { const groupedNotificationsByContractId = groupBy( groupedNotificationsBySourceType[sourceType], (notification) => { return notification.sourceContractId } ) for (const contractId in groupedNotificationsByContractId) { const notificationsForContractId = groupedNotificationsByContractId[contractId] if (notificationsForContractId.length === 1) { newNotifications.push(notificationsForContractId[0]) continue } let sum = 0 notificationsForContractId.forEach( (notification) => notification.sourceText && (sum = parseInt(notification.sourceText) + sum) ) const uniqueUsers = uniq( notificationsForContractId.map((notification) => { return notification.sourceUserUsername }) ) const newNotification = { ...notificationsForContractId[0], sourceText: sum.toString(), sourceUserUsername: uniqueUsers.length > 1 ? MULTIPLE_USERS_KEY : notificationsForContractId[0].sourceType, } newNotifications.push(newNotification) } } return newNotifications } const combinedNotifs = combineNotificationsByAddingNumericSourceTexts(notifications) return ( setExpanded(!expanded)} > {expanded && ( )} setExpanded(!expanded)} className={'line-clamp-1 cursor-pointer pl-1 sm:pl-0'} > {'Daily Income Summary: '} {'+' + formatMoney(totalIncome)} {' '} {!expanded ? ( <> {combinedNotifs .slice(0, numSummaryLines) .map((notification) => ( ))} {combinedNotifs.length - numSummaryLines > 0 ? 'And ' + (combinedNotifs.length - numSummaryLines) + ' more...' : ''} > ) : ( <> {combinedNotifs.map((notification) => ( ))} > )} ) } function IncomeNotificationItem(props: { notification: Notification justSummary?: boolean }) { const { notification, justSummary } = props const { sourceType, sourceUserName, reason, sourceUserUsername, createdTime, } = notification const [highlighted] = useState(!notification.isSeen) useEffect(() => { setNotificationsAsSeen([notification]) }, [notification]) function getReasonForShowingIncomeNotification(simple: boolean) { const { sourceText } = notification let reasonText = '' if (sourceType === 'bonus' && sourceText) { reasonText = !simple ? `Bonus for ${ parseInt(sourceText) / UNIQUE_BETTOR_BONUS_AMOUNT } unique bettors` : 'bonus on' } else if (sourceType === 'tip') { reasonText = !simple ? `tipped you` : `in tips on` } return {reasonText} } if (justSummary) { return ( {getReasonForShowingIncomeNotification(true)} ) } return ( {sourceType && reason && ( {sourceType != 'bonus' && (sourceUserUsername === MULTIPLE_USERS_KEY ? ( Multiple users ) : ( ))} )} {getReasonForShowingIncomeNotification(false)} on on ) } function NotificationGroupItem(props: { notificationGroup: NotificationGroup className?: string }) { const { notificationGroup, className } = props const { notifications } = notificationGroup const { sourceContractTitle } = notifications[0] const numSummaryLines = 3 const [expanded, setExpanded] = useState(false) const [highlighted, setHighlighted] = useState( notifications.some((n) => !n.isSeen) ) useEffect(() => { setNotificationsAsSeen(notifications) }, [notifications]) useEffect(() => { if (expanded) setHighlighted(false) }, [expanded]) return ( setExpanded(!expanded)} > {expanded && ( )} setExpanded(!expanded)} className={' flex cursor-pointer truncate pl-1 sm:pl-0'} > {sourceContractTitle ? ( <> {'Activity on '} > ) : ( 'Other activity' )} {' '} {' '} {!expanded ? ( <> {notifications.slice(0, numSummaryLines).map((notification) => { return ( ) })} {notifications.length - numSummaryLines > 0 ? 'And ' + (notifications.length - numSummaryLines) + ' more...' : ''} > ) : ( <> {notifications.map((notification) => ( ))} > )} ) } function NotificationItem(props: { notification: Notification justSummary?: boolean hideTitle?: boolean }) { const { notification, justSummary, hideTitle } = props const { sourceType, sourceId, sourceUserName, sourceUserAvatarUrl, sourceUpdateType, reasonText, reason, sourceUserUsername, createdTime, sourceText, sourceContractCreatorUsername, sourceContractSlug, } = notification const [defaultNotificationText, setDefaultNotificationText] = useState('') useEffect(() => { if (sourceText) { setDefaultNotificationText(sourceText) } else if (reasonText) { // Handle arbitrary notifications with reason text here. setDefaultNotificationText(reasonText) } }, [reasonText, sourceText]) const [highlighted] = useState(!notification.isSeen) useEffect(() => { setNotificationsAsSeen([notification]) }, [notification]) if (justSummary) { return ( {sourceType && reason && getReasonForShowingNotification(notification, true, true)} ) } return ( {sourceUpdateType != 'closed' && ( )} {sourceType && reason && ( {getReasonForShowingNotification(notification, false, true)} {!hideTitle && ( )} )} {sourceId && sourceContractSlug && sourceContractCreatorUsername ? ( ) : ( )} ) } export const setNotificationsAsSeen = (notifications: Notification[]) => { notifications.forEach((notification) => { if (!notification.isSeen) updateDoc( doc(db, `users/${notification.userId}/notifications/`, notification.id), { isSeen: true, viewTime: new Date(), } ) }) return notifications } function NotificationLink(props: { notification: Notification }) { const { notification } = props const { sourceType, sourceContractTitle, sourceContractCreatorUsername, sourceContractSlug, sourceSlug, sourceTitle, } = notification return ( {sourceContractTitle || sourceTitle} ) } function getSourceUrl(notification: Notification) { const { sourceType, sourceId, sourceUserUsername, sourceContractCreatorUsername, sourceContractSlug, sourceSlug, } = notification if (sourceType === 'follow') return `/${sourceUserUsername}` if (sourceType === 'group' && sourceSlug) return `${groupPath(sourceSlug)}` if ( sourceContractCreatorUsername && sourceContractSlug && sourceType === 'user' ) return `/${sourceContractCreatorUsername}/${sourceContractSlug}` if (sourceType === 'tip') return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${sourceSlug}` if (sourceContractCreatorUsername && sourceContractSlug) return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent( sourceId ?? '', sourceType )}` } function getSourceIdForLinkComponent( sourceId: string, sourceType?: notification_source_types ) { switch (sourceType) { case 'answer': return `answer-${sourceId}` case 'comment': return sourceId case 'contract': return '' default: return sourceId } } function NotificationTextLabel(props: { defaultText: string contract?: Contract | null notification: Notification className?: string justSummary?: boolean }) { const { contract, className, defaultText, notification, justSummary } = props const { sourceUpdateType, sourceType, sourceText, sourceContractTitle } = notification if (sourceType === 'contract') { if (justSummary) return {contract?.question || sourceContractTitle} if (!sourceText) return // Resolved contracts if (sourceType === 'contract' && sourceUpdateType === 'resolved') { { if (sourceText === 'YES' || sourceText == 'NO') { return } if (sourceText.includes('%')) return ( ) if (sourceText === 'CANCEL') return if (sourceText === 'MKT' || sourceText === 'PROB') return } } // Close date will be a number - it looks better without it if (sourceUpdateType === 'closed') { return } // Updated contracts // Description will be in default text if (parseInt(sourceText) > 0) { return ( Updated close time: {new Date(parseInt(sourceText)).toLocaleString()} ) } } else if (sourceType === 'user' && sourceText) { return ( As a thank you, we sent you{' '} {formatMoney(parseInt(sourceText))} ! ) } else if (sourceType === 'liquidity' && sourceText) { return ( {formatMoney(parseInt(sourceText))} ) } else if ((sourceType === 'bonus' || sourceType === 'tip') && sourceText) { return ( {'+' + formatMoney(parseInt(sourceText))} ) } // return default text return ( ) } function getReasonForShowingNotification( notification: Notification, simple?: boolean, replaceOn?: boolean ) { const { sourceType, sourceUpdateType, reason, sourceSlug } = notification let reasonText: string switch (sourceType) { case 'comment': if (reason === 'reply_to_users_answer') reasonText = !simple ? 'replied to you on' : 'replied' else if (reason === 'tagged_user') reasonText = !simple ? 'tagged you on' : 'tagged you' else if (reason === 'reply_to_users_comment') reasonText = !simple ? 'replied to you 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 on` else reasonText = `commented on` break case 'contract': if (reason === 'you_follow_user') reasonText = 'asked' else if (sourceUpdateType === 'resolved') reasonText = `resolved` else if (sourceUpdateType === 'closed') reasonText = `Please resolve your question` else reasonText = `updated` break case 'answer': if (reason === 'on_users_contract') reasonText = `answered your question ` else 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 case 'liquidity': reasonText = 'added liquidity to your question' break case 'group': reasonText = 'added you to the group' break case 'user': if (sourceSlug && reason === 'user_joined_to_bet_on_your_market') reasonText = 'joined to bet on your market' else if (sourceSlug) reasonText = 'joined because you shared' else reasonText = 'joined because of you' break default: reasonText = '' } return ( {replaceOn ? reasonText.replace(' on', '') : reasonText} ) } // TODO: where should we put referral bonus notifications? function NotificationSettings() { const user = useUser() const [notificationSettings, setNotificationSettings] = useState('all') const [emailNotificationSettings, setEmailNotificationSettings] = useState('all') const [privateUser, setPrivateUser] = useState(null) 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 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}`, } ) } useEffect(() => { if (privateUser && privateUser.notificationPreferences) setNotificationSettings(privateUser.notificationPreferences) else setNotificationSettings('all') }, [privateUser]) if (!privateUser) { return } function NotificationSettingLine(props: { label: string highlight: boolean }) { const { label, highlight } = props return ( {highlight ? : } {label} ) } return ( In App Notifications changeInAppNotificationSettings( choice as notification_subscribe_types ) } className={'col-span-4 p-2'} toggleClassName={'w-24'} /> You will receive notifications for: Email Notifications changeEmailNotifications(choice as notification_subscribe_types) } className={'col-span-4 p-2'} toggleClassName={'w-24'} /> You will receive emails for: ) }
Showing{' '} {page === 1 ? page : (page - 1) * NOTIFICATIONS_PER_PAGE} {' '} to{' '} {page * NOTIFICATIONS_PER_PAGE} {' '} of{' '} {allGroupedNotifications.length} {' '} results