import { Tabs } from 'web/components/layout/tabs'
import { usePrivateUser } from 'web/hooks/use-user'
import React, { useEffect, useMemo, 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 { UserLink } from 'web/components/user-page'
import {
MANIFOLD_AVATAR_URL,
MANIFOLD_USERNAME,
PrivateUser,
User,
} from 'common/user'
import { getUser } from 'web/lib/firebase/users'
import clsx from 'clsx'
import { RelativeTimestamp } from 'web/components/relative-timestamp'
import { Linkify } from 'web/components/linkify'
import {
BinaryOutcomeLabel,
CancelLabel,
MultiLabel,
NumericValueLabel,
ProbPercentLabel,
} from 'web/components/outcome-label'
import {
NotificationGroup,
usePreferredGroupedNotifications,
} from 'web/hooks/use-notifications'
import { TrendingUpIcon } from '@heroicons/react/outline'
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'
import { track } from '@amplitude/analytics-browser'
import { Pagination } from 'web/components/pagination'
import { useWindowSize } from 'web/hooks/use-window-size'
import { safeLocalStorage } from 'web/lib/util/local'
import {
getServerAuthenticatedUid,
redirectIfLoggedOut,
} from 'web/lib/firebase/server-auth'
import { SiteLink } from 'web/components/site-link'
import { NotificationSettings } from 'web/components/NotificationSettings'
export const NOTIFICATIONS_PER_PAGE = 30
const MULTIPLE_USERS_KEY = 'multipleUsers'
const HIGHLIGHT_CLASS = 'bg-indigo-50'
export const getServerSideProps = redirectIfLoggedOut('/', async (ctx) => {
const uid = await getServerAuthenticatedUid(ctx)
if (!uid) {
return { props: { user: null } }
}
const user = await getUser(uid)
return { props: { user } }
})
export default function Notifications(props: { user: User }) {
const { user } = props
const privateUser = usePrivateUser(user?.id)
const local = safeLocalStorage()
let localNotifications = [] as Notification[]
const localSavedNotificationGroups = local?.getItem('notification-groups')
let localNotificationGroups = [] as NotificationGroup[]
if (localSavedNotificationGroups) {
localNotificationGroups = JSON.parse(localSavedNotificationGroups)
localNotifications = localNotificationGroups
.map((g) => g.notifications)
.flat()
}
if (!user) return
return (
) : (
),
},
{
title: 'Settings',
content: (
),
},
]}
/>
)
}
function RenderNotificationGroups(props: {
notificationGroups: NotificationGroup[]
}) {
const { notificationGroups } = props
return (
<>
{notificationGroups.map((notification) =>
notification.type === 'income' ? (
) : notification.notifications.length === 1 ? (
) : (
)
)}
>
)
}
function NotificationsList(props: {
privateUser: PrivateUser
cachedNotifications: Notification[]
}) {
const { privateUser, cachedNotifications } = props
const [page, setPage] = useState(0)
const allGroupedNotifications = usePreferredGroupedNotifications(
privateUser,
cachedNotifications
)
const paginatedGroupedNotifications = useMemo(() => {
if (!allGroupedNotifications) return
const start = page * 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)
}
const local = safeLocalStorage()
local?.setItem(
'notification-groups',
JSON.stringify(allGroupedNotifications)
)
return 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.length > 0 &&
allGroupedNotifications.length > NOTIFICATIONS_PER_PAGE && (
)}
)
}
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)
)
const onClickHandler = (event: React.MouseEvent) => {
if (event.ctrlKey || event.metaKey) return
setExpanded(!expanded)
}
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) {
// Source title splits by contracts and groups
const groupedNotificationsBySourceTitle = groupBy(
groupedNotificationsBySourceType[sourceType],
(notification) => {
return notification.sourceTitle ?? notification.sourceContractTitle
}
)
for (const contractId in groupedNotificationsBySourceTitle) {
const notificationsForContractId =
groupedNotificationsBySourceTitle[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 (
{expanded && (
)}
{'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, sourceUserUsername } = notification
const [highlighted] = useState(!notification.isSeen)
const { width } = useWindowSize()
const isMobile = (width && width < 768) || false
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 traders`
: 'bonus on'
} else if (sourceType === 'tip') {
reasonText = !simple ? `tipped you` : `in tips on`
}
return reasonText
}
if (justSummary) {
return (
{getReasonForShowingIncomeNotification(true)}
)
}
return (
{sourceType != 'bonus' &&
(sourceUserUsername === MULTIPLE_USERS_KEY ? (
Multiple users
) : (
))}
{getReasonForShowingIncomeNotification(false)} {' on'}
)
}
function NotificationGroupItem(props: {
notificationGroup: NotificationGroup
className?: string
}) {
const { notificationGroup, className } = props
const { notifications } = notificationGroup
const { sourceContractTitle } = notifications[0]
const { width } = useWindowSize()
const isMobile = (width && width < 768) || false
const numSummaryLines = 3
const [expanded, setExpanded] = useState(false)
const [highlighted, setHighlighted] = useState(
notifications.some((n) => !n.isSeen)
)
const onClickHandler = (event: React.MouseEvent) => {
if (event.ctrlKey || event.metaKey) return
setExpanded(!expanded)
}
useEffect(() => {
setNotificationsAsSeen(notifications)
}, [notifications])
useEffect(() => {
if (expanded) setHighlighted(false)
}, [expanded])
return (
{expanded && (
)}
{sourceContractTitle ? (
) : (
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
isChildOfGroup?: boolean
}) {
const { notification, justSummary, isChildOfGroup } = props
const {
sourceType,
sourceUserName,
sourceUserAvatarUrl,
sourceUpdateType,
reasonText,
reason,
sourceUserUsername,
sourceText,
} = notification
const [highlighted] = useState(!notification.isSeen)
useEffect(() => {
setNotificationsAsSeen([notification])
}, [notification])
const questionNeedsResolution = sourceUpdateType == 'closed'
if (justSummary) {
return (
{sourceType &&
reason &&
getReasonForShowingNotification(notification, true)}
)
}
return (
track('Notification Clicked', {
type: 'notification item',
sourceType,
sourceUserName,
sourceUserAvatarUrl,
sourceUpdateType,
reasonText,
reason,
sourceUserUsername,
sourceText,
})
}
/>
{!questionNeedsResolution && (
)}
{getReasonForShowingNotification(
notification,
isChildOfGroup ?? false
)}
{isChildOfGroup ? (
) : (
)}
{!isChildOfGroup && (
)}
)
}
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 QuestionOrGroupLink(props: {
notification: Notification
ignoreClick?: boolean
}) {
const { notification, ignoreClick } = props
const {
sourceType,
sourceContractTitle,
sourceContractCreatorUsername,
sourceContractSlug,
sourceSlug,
sourceTitle,
} = notification
if (ignoreClick)
return (
{sourceContractTitle || sourceTitle}
)
return (
track('Notification Clicked', {
type: 'question title',
sourceType,
sourceContractTitle,
sourceContractCreatorUsername,
sourceContractSlug,
sourceSlug,
sourceTitle,
})
}
>
{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)}`
// User referral via contract:
if (
sourceContractCreatorUsername &&
sourceContractSlug &&
sourceType === 'user'
)
return `/${sourceContractCreatorUsername}/${sourceContractSlug}`
// User referral:
if (sourceType === 'user' && !sourceContractSlug)
return `/${sourceUserUsername}`
if (sourceType === 'tip' && sourceContractSlug)
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${sourceSlug}`
if (sourceType === 'tip' && sourceSlug) return `${groupPath(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 ''
case 'bet':
return ''
default:
return sourceId
}
}
function NotificationTextLabel(props: {
notification: Notification
className?: string
justSummary?: boolean
}) {
const { className, notification, justSummary } = props
const { sourceUpdateType, sourceType, sourceText, reasonText } = notification
const defaultText = sourceText ?? reasonText ?? ''
if (sourceType === 'contract') {
if (justSummary || !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
// Numeric market
if (parseFloat(sourceText))
return
// Free response market
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))}
)
} else if (sourceType === 'bet' && sourceText) {
return (
<>
{formatMoney(parseInt(sourceText))}
{' '}
of your limit order was filled
>
)
}
return (
)
}
function getReasonForShowingNotification(
notification: Notification,
justSummary: boolean
) {
const { sourceType, sourceUpdateType, reason, sourceSlug } = notification
let reasonText: string
switch (sourceType) {
case 'comment':
if (reason === 'reply_to_users_answer')
reasonText = justSummary ? 'replied' : 'replied to you on'
else if (reason === 'tagged_user')
reasonText = justSummary ? 'tagged you' : 'tagged you on'
else if (reason === 'reply_to_users_comment')
reasonText = justSummary ? 'replied' : 'replied to you on'
else reasonText = justSummary ? `commented` : `commented on`
break
case 'contract':
if (reason === 'you_follow_user')
reasonText = justSummary ? 'asked the question' : 'asked'
else if (sourceUpdateType === 'resolved')
reasonText = justSummary ? `resolved the question` : `resolved`
else if (sourceUpdateType === 'closed') reasonText = `Please resolve`
else reasonText = justSummary ? 'updated the question' : `updated`
break
case 'answer':
if (reason === 'on_users_contract') reasonText = `answered your question `
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
case 'bet':
reasonText = 'bet against you'
break
default:
reasonText = ''
}
return reasonText
}