manifold/web/pages/notifications.tsx

1266 lines
38 KiB
TypeScript
Raw Normal View History

import { ControlledTabs } from 'web/components/layout/tabs'
import React, { useEffect, useMemo, useState } from 'react'
import Router, { useRouter } from 'next/router'
2022-09-15 19:39:46 +00:00
import {
BetFillData,
2022-09-15 21:25:19 +00:00
ContractResolutionData,
2022-09-15 19:39:46 +00:00
Notification,
notification_source_types,
} from 'common/notification'
2022-06-23 17:55:41 +00:00
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'
2022-07-15 17:53:30 +00:00
import {
MANIFOLD_AVATAR_URL,
MANIFOLD_USERNAME,
PrivateUser,
} from 'common/user'
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,
useGroupedNotifications,
2022-09-12 20:17:39 +00:00
useUnseenGroupedNotification,
} from 'web/hooks/use-notifications'
2022-07-19 20:24:36 +00:00
import { TrendingUpIcon } from '@heroicons/react/outline'
import { formatMoney } from 'common/util/format'
import { groupPath } from 'web/lib/firebase/groups'
2022-08-25 11:10:24 +00:00
import {
BETTING_STREAK_BONUS_AMOUNT,
UNIQUE_BETTOR_BONUS_AMOUNT,
} from 'common/economy'
import { groupBy, sum, uniqBy } from 'lodash'
2022-07-07 21:06:29 +00:00
import { track } from '@amplitude/analytics-browser'
import { Pagination } from 'web/components/pagination'
2022-07-14 17:48:04 +00:00
import { useWindowSize } from 'web/hooks/use-window-size'
import { safeLocalStorage } from 'web/lib/util/local'
import { SiteLink } from 'web/components/site-link'
import { NotificationSettings } from 'web/components/notification-settings'
2022-08-15 04:55:11 +00:00
import { SEO } from 'web/components/SEO'
import { usePrivateUser, useUser } from 'web/hooks/use-user'
import { UserLink } from 'web/components/user-link'
import { LoadingIndicator } from 'web/components/loading-indicator'
import {
MultiUserLinkInfo,
MultiUserTransactionLink,
} from 'web/components/multi-user-transaction-link'
import { Col } from 'web/components/layout/col'
export const NOTIFICATIONS_PER_PAGE = 30
2022-07-15 22:57:58 +00:00
const HIGHLIGHT_CLASS = 'bg-indigo-50'
export default function Notifications() {
const privateUser = usePrivateUser()
const router = useRouter()
const [navigateToSection, setNavigateToSection] = useState<string>()
const [activeIndex, setActiveIndex] = useState(0)
useEffect(() => {
if (privateUser === null) Router.push('/')
})
useEffect(() => {
const query = { ...router.query }
2022-09-12 20:41:30 +00:00
if (query.tab === 'settings') {
setActiveIndex(1)
}
if (query.section) {
setNavigateToSection(query.section as string)
}
}, [router.query])
return (
<Page>
<div className={'px-2 pt-4 sm:px-4 lg:pt-0'}>
<Title text={'Notifications'} className={'hidden md:block'} />
2022-08-15 04:55:11 +00:00
<SEO title="Notifications" description="Manifold user notifications" />
{privateUser && router.isReady && (
<div>
<ControlledTabs
currentPageForAnalytics={'notifications'}
labelClassName={'pb-2 pt-1 '}
className={'mb-0 sm:mb-2'}
activeIndex={activeIndex}
onClick={(title, i) => {
router.replace(
{
query: {
...router.query,
tab: title.toLowerCase(),
section: '',
},
},
undefined,
{ shallow: true }
)
setActiveIndex(i)
}}
tabs={[
{
title: 'Notifications',
content: <NotificationsList privateUser={privateUser} />,
},
{
title: 'Settings',
content: (
<NotificationSettings
navigateToSection={navigateToSection}
privateUser={privateUser}
/>
),
},
]}
/>
</div>
)}
</div>
</Page>
)
}
function RenderNotificationGroups(props: {
notificationGroups: NotificationGroup[]
}) {
const { notificationGroups } = props
return (
<>
{notificationGroups.map((notification) =>
notification.type === 'income' ? (
<IncomeNotificationGroupItem
notificationGroup={notification}
key={notification.groupedById + notification.timePeriod}
/>
) : notification.notifications.length === 1 ? (
<NotificationItem
notification={notification.notifications[0]}
key={notification.notifications[0].id}
2022-09-15 19:39:46 +00:00
justSummary={false}
/>
) : (
<NotificationGroupItem
notificationGroup={notification}
key={notification.groupedById + notification.timePeriod}
/>
)
)}
</>
)
}
function NotificationsList(props: { privateUser: PrivateUser }) {
const { privateUser } = props
const [page, setPage] = useState(0)
const allGroupedNotifications = useGroupedNotifications(privateUser)
2022-09-12 20:17:39 +00:00
const unseenGroupedNotifications = useUnseenGroupedNotification(privateUser)
const paginatedGroupedNotifications = useMemo(() => {
if (!allGroupedNotifications) return
const start = page * NOTIFICATIONS_PER_PAGE
const end = start + NOTIFICATIONS_PER_PAGE
const maxNotificationsToShow = allGroupedNotifications.slice(start, end)
2022-09-12 20:17:39 +00:00
const local = safeLocalStorage()
local?.setItem(
'notification-groups',
2022-07-19 15:29:12 +00:00
JSON.stringify(allGroupedNotifications)
)
return maxNotificationsToShow
}, [allGroupedNotifications, page])
2022-09-12 20:17:39 +00:00
// Set all notifications that don't fit on the first page to seen
useEffect(() => {
if (
paginatedGroupedNotifications &&
paginatedGroupedNotifications?.length >= NOTIFICATIONS_PER_PAGE
) {
const allUnseenNotifications = unseenGroupedNotifications
?.map((ng) => ng.notifications)
.flat()
allUnseenNotifications && setNotificationsAsSeen(allUnseenNotifications)
}
}, [paginatedGroupedNotifications, unseenGroupedNotifications])
if (!paginatedGroupedNotifications || !allGroupedNotifications)
return <LoadingIndicator />
return (
2022-08-23 13:55:26 +00:00
<div className={'min-h-[100vh] text-sm'}>
{paginatedGroupedNotifications.length === 0 && (
<div className={'mt-2'}>
You don't have any notifications. Try changing your settings to see
more.
</div>
)}
<RenderNotificationGroups
notificationGroups={paginatedGroupedNotifications}
/>
{paginatedGroupedNotifications.length > 0 &&
allGroupedNotifications.length > NOTIFICATIONS_PER_PAGE && (
<Pagination
page={page}
itemsPerPage={NOTIFICATIONS_PER_PAGE}
totalItems={allGroupedNotifications.length}
setPage={setPage}
scrollToTop
2022-07-19 20:16:20 +00:00
nextTitle={'Older'}
prevTitle={'Newer'}
/>
)}
</div>
)
}
function IncomeNotificationGroupItem(props: {
notificationGroup: NotificationGroup
className?: string
}) {
const { notificationGroup, className } = props
const { notifications } = notificationGroup
const numSummaryLines = 3
const [expanded, setExpanded] = useState(
notifications.length <= numSummaryLines
)
const [highlighted, setHighlighted] = useState(
notifications.some((n) => !n.isSeen)
)
const onClickHandler = (event: React.MouseEvent<HTMLDivElement>) => {
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: Notification[] = []
const groupedNotificationsBySourceType = groupBy(
notifications,
(n) => n.sourceType
)
for (const sourceType in groupedNotificationsBySourceType) {
// Source title splits by contracts, groups, betting streak bonus
2022-07-11 14:11:52 +00:00
const groupedNotificationsBySourceTitle = groupBy(
groupedNotificationsBySourceType[sourceType],
(notification) => {
2022-07-14 17:48:04 +00:00
return notification.sourceTitle ?? notification.sourceContractTitle
}
)
for (const sourceTitle in groupedNotificationsBySourceTitle) {
const notificationsForSourceTitle =
groupedNotificationsBySourceTitle[sourceTitle]
let sum = 0
notificationsForSourceTitle.forEach(
(notification) =>
(sum = parseInt(notification.sourceText ?? '0') + sum)
)
const uniqueUsers = uniqBy(
notificationsForSourceTitle.map((notification) => {
let thisSum = 0
notificationsForSourceTitle
.filter(
(n) => n.sourceUserUsername === notification.sourceUserUsername
)
.forEach(
(n) => (thisSum = parseInt(n.sourceText ?? '0') + thisSum)
)
return {
username: notification.sourceUserUsername,
name: notification.sourceUserName,
avatarUrl: notification.sourceUserAvatarUrl,
amount: thisSum,
} as MultiUserLinkInfo
}),
(n) => n.username
)
const newNotification = {
...notificationsForSourceTitle[0],
sourceText: sum.toString(),
sourceUserUsername: notificationsForSourceTitle[0].sourceUserUsername,
data: { uniqueUsers },
}
newNotifications.push(newNotification)
}
}
return newNotifications
}
const combinedNotifs = combineNotificationsByAddingNumericSourceTexts(
notifications.filter((n) => n.sourceType !== 'betting_streak_bonus')
)
// Because the server's reset time will never align with the client's, we may
// erroneously sum 2 betting streak bonuses, therefore just show the most recent
const mostRecentBettingStreakBonus = notifications
.filter((n) => n.sourceType === 'betting_streak_bonus')
.sort((a, b) => a.createdTime - b.createdTime)
.pop()
if (mostRecentBettingStreakBonus)
combinedNotifs.unshift(mostRecentBettingStreakBonus)
return (
<div
className={clsx(
'relative cursor-pointer bg-white px-2 pt-6 text-sm',
className,
!expanded ? 'hover:bg-gray-100' : '',
2022-07-15 22:57:58 +00:00
highlighted && !expanded ? HIGHLIGHT_CLASS : ''
)}
onClick={onClickHandler}
>
{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'}>
2022-07-07 23:17:10 +00:00
<TrendingUpIcon
className={'text-primary ml-1 h-7 w-7 flex-shrink-0 sm:ml-2'}
/>
<div
2022-07-07 23:17:10 +00:00
className={'ml-2 flex w-full flex-row flex-wrap truncate'}
onClick={onClickHandler}
>
<div className={'flex w-full flex-row justify-between'}>
<div>
{'Daily Income Summary: '}
2022-07-06 13:45:47 +00:00
<span className={'text-primary'}>
{'+' + formatMoney(totalIncome)}
</span>
</div>
<div className={'inline-block'}>
<RelativeTimestamp time={notifications[0].createdTime} />
</div>
</div>
</div>
</Row>
<div>
<div className={clsx('mt-1 md:text-base', expanded ? 'pl-4' : '')}>
{' '}
<div
className={clsx(
'mt-1 ml-1 gap-1 whitespace-pre-line',
!expanded ? 'line-clamp-4' : ''
)}
>
{!expanded ? (
<>
{combinedNotifs
.slice(0, numSummaryLines)
.map((notification) => (
<IncomeNotificationItem
notification={notification}
justSummary={true}
key={notification.id}
/>
))}
<div className={'text-sm text-gray-500 hover:underline '}>
{combinedNotifs.length - numSummaryLines > 0
? 'And ' +
(combinedNotifs.length - numSummaryLines) +
' more...'
: ''}
</div>
</>
) : (
<>
{combinedNotifs.map((notification) => (
<IncomeNotificationItem
notification={notification}
key={notification.id}
justSummary={false}
/>
))}
</>
)}
</div>
</div>
<div className={'mt-6 border-b border-gray-300'} />
</div>
</div>
)
}
function IncomeNotificationItem(props: {
notification: Notification
justSummary?: boolean
}) {
const { notification, justSummary } = props
const { sourceType, sourceUserUsername, sourceText, data } = notification
const [highlighted] = useState(!notification.isSeen)
2022-07-14 17:48:04 +00:00
const { width } = useWindowSize()
const isMobile = (width && width < 768) || false
2022-08-25 04:40:27 +00:00
const user = useUser()
const isTip = sourceType === 'tip' || sourceType === 'tip_and_like'
const isUniqueBettorBonus = sourceType === 'bonus'
const userLinks: MultiUserLinkInfo[] =
isTip || isUniqueBettorBonus ? data?.uniqueUsers ?? [] : []
2022-08-25 04:40:27 +00:00
useEffect(() => {
setNotificationsAsSeen([notification])
}, [notification])
function reasonAndLink(simple: boolean) {
const { sourceText } = notification
let reasonText = ''
2022-08-20 18:47:23 +00:00
if (sourceType === 'bonus' && sourceText) {
reasonText = !simple
2022-07-06 23:24:53 +00:00
? `Bonus for ${
parseInt(sourceText) / UNIQUE_BETTOR_BONUS_AMOUNT
2022-09-13 22:19:52 +00:00
} new predictors on`
2022-07-06 23:24:53 +00:00
: 'bonus on'
} else if (sourceType === 'tip') {
reasonText = !simple ? `tipped you on` : `in tips on`
2022-08-20 18:47:23 +00:00
} else if (sourceType === 'betting_streak_bonus') {
if (sourceText && +sourceText === 50) reasonText = '(max) for your'
else reasonText = 'for your'
} else if (sourceType === 'loan' && sourceText) {
2022-09-13 22:19:52 +00:00
reasonText = `of your invested predictions returned as a`
// TODO: support just 'like' notification without a tip
} else if (sourceType === 'tip_and_like' && sourceText) {
reasonText = !simple ? `liked` : `in likes on`
}
2022-08-20 18:47:23 +00:00
const streakInDays = notification.data?.streak
? notification.data?.streak
: Date.now() - notification.createdTime > 24 * 60 * 60 * 1000
? parseInt(sourceText ?? '0') / BETTING_STREAK_BONUS_AMOUNT
: user?.currentBettingStreak ?? 0
2022-08-20 18:47:23 +00:00
const bettingStreakText =
sourceType === 'betting_streak_bonus' &&
2022-09-13 22:19:52 +00:00
(sourceText
? `🔥 ${streakInDays} day Prediction Streak`
: 'Prediction Streak')
2022-08-20 18:47:23 +00:00
return (
<>
{reasonText}
{sourceType === 'loan' ? (
simple ? (
2022-08-22 15:31:23 +00:00
<span className={'ml-1 font-bold'}>🏦 Loan</span>
) : (
<SiteLink className={'ml-1 font-bold'} href={'/loans'}>
2022-08-22 15:31:23 +00:00
🏦 Loan
</SiteLink>
)
) : sourceType === 'betting_streak_bonus' ? (
simple ? (
2022-08-20 18:47:23 +00:00
<span className={'ml-1 font-bold'}>{bettingStreakText}</span>
) : (
<SiteLink
className={'ml-1 font-bold'}
href={'/betting-streak-bonus'}
>
2022-08-20 18:47:23 +00:00
{bettingStreakText}
</SiteLink>
)
) : (
<QuestionOrGroupLink
notification={notification}
ignoreClick={isMobile}
/>
)}
</>
)
}
const incomeNotificationLabel = () => {
return sourceText ? (
<span className="text-primary">
{'+' + formatMoney(parseInt(sourceText))}
</span>
) : (
<div />
)
}
const getIncomeSourceUrl = () => {
const {
sourceId,
sourceContractCreatorUsername,
sourceContractSlug,
sourceSlug,
} = notification
if (sourceType === 'tip' && sourceContractSlug)
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${sourceSlug}`
if (sourceType === 'tip' && sourceSlug) return `${groupPath(sourceSlug)}`
if (sourceType === 'challenge') return `${sourceSlug}`
if (sourceType === 'betting_streak_bonus')
return `/${sourceUserUsername}/?show=betting-streak`
if (sourceType === 'loan') return `/${sourceUserUsername}/?show=loans`
if (sourceContractCreatorUsername && sourceContractSlug)
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent(
sourceId ?? '',
sourceType
)}`
}
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'}>
<div className={'inline-flex overflow-hidden text-ellipsis pl-1'}>
<div className={'mr-1 text-black'}>
{incomeNotificationLabel()}
</div>
<span className={'flex truncate'}>{reasonAndLink(true)}</span>
</div>
</div>
</div>
</Row>
)
}
return (
<div
className={clsx(
'bg-white px-2 pt-6 text-sm sm:px-4',
2022-07-15 22:57:58 +00:00
highlighted && HIGHLIGHT_CLASS
)}
>
<div className={'relative'}>
<SiteLink
href={getIncomeSourceUrl() ?? ''}
className={'absolute left-0 right-0 top-0 bottom-0 z-0'}
/>
<Col className={'justify-start text-gray-500'}>
{(isTip || isUniqueBettorBonus) && (
<MultiUserTransactionLink
userInfos={userLinks}
2022-09-13 22:19:52 +00:00
modalLabel={isTip ? 'Who tipped you' : 'Unique predictors'}
/>
)}
<Row className={'line-clamp-2 flex max-w-xl'}>
<span>{incomeNotificationLabel()}</span>
<span className={'mx-1'}>
{isTip &&
(userLinks.length > 1
? 'Multiple users'
: userLinks.length > 0
? userLinks[0].name
: '')}
</span>
<span>{reasonAndLink(false)}</span>
</Row>
</Col>
<div className={'border-b border-gray-300 pt-4'} />
</div>
</div>
)
}
function NotificationGroupItem(props: {
notificationGroup: NotificationGroup
className?: string
}) {
const { notificationGroup, className } = props
const { notifications } = notificationGroup
const { sourceContractTitle } = notifications[0]
2022-07-14 17:48:04 +00:00
const { width } = useWindowSize()
const isMobile = (width && width < 768) || false
const numSummaryLines = 3
const [expanded, setExpanded] = useState(
notifications.length <= numSummaryLines
)
const [highlighted, setHighlighted] = useState(
notifications.some((n) => !n.isSeen)
)
const onClickHandler = (event: React.MouseEvent<HTMLDivElement>) => {
if (event.ctrlKey || event.metaKey) return
setExpanded(!expanded)
}
useEffect(() => {
setNotificationsAsSeen(notifications)
}, [notifications])
useEffect(() => {
if (expanded) setHighlighted(false)
}, [expanded])
return (
<div
className={clsx(
'relative cursor-pointer bg-white px-2 pt-6 text-sm',
className,
!expanded ? 'hover:bg-gray-100' : '',
2022-07-15 22:57:58 +00:00
highlighted && !expanded ? HIGHLIGHT_CLASS : ''
)}
onClick={onClickHandler}
>
{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'}>
2022-06-23 17:55:41 +00:00
<EmptyAvatar multi />
<div
className={'line-clamp-2 flex w-full flex-row flex-wrap pl-1 sm:pl-0'}
>
{sourceContractTitle ? (
<div className={'flex w-full flex-row justify-between'}>
<div className={'ml-2'}>
Activity on
2022-07-14 17:48:04 +00:00
<QuestionOrGroupLink
notification={notifications[0]}
ignoreClick={!expanded && isMobile}
/>
</div>
<div className={'hidden sm:inline-block'}>
<RelativeTimestamp time={notifications[0].createdTime} />
</div>
</div>
) : (
<span>
Other activity
<RelativeTimestamp time={notifications[0].createdTime} />
</span>
)}
</div>
</Row>
<div>
<div className={clsx('mt-1 md:text-base', expanded ? 'pl-4' : '')}>
{' '}
<div
className={clsx(
'mt-1 ml-1 gap-1 whitespace-pre-line',
!expanded ? 'line-clamp-4' : ''
)}
>
{' '}
{!expanded ? (
<>
{notifications.slice(0, numSummaryLines).map((notification) => {
return (
<NotificationItem
notification={notification}
justSummary={true}
key={notification.id}
/>
)
})}
<div className={'text-sm text-gray-500 hover:underline '}>
{notifications.length - numSummaryLines > 0
? 'And ' +
(notifications.length - numSummaryLines) +
' more...'
: ''}
</div>
</>
) : (
<>
{notifications.map((notification) => (
<NotificationItem
notification={notification}
key={notification.id}
justSummary={false}
isChildOfGroup={true}
/>
))}
</>
)}
</div>
</div>
<div className={'mt-6 border-b border-gray-300'} />
</div>
</div>
)
}
2022-07-06 21:06:41 +00:00
function NotificationItem(props: {
notification: Notification
2022-09-15 19:39:46 +00:00
justSummary: boolean
isChildOfGroup?: boolean
2022-07-06 21:06:41 +00:00
}) {
const { notification, justSummary, isChildOfGroup } = props
2022-09-15 21:25:19 +00:00
const { sourceType, reason, sourceUpdateType } = notification
2022-07-06 21:06:41 +00:00
const [highlighted] = useState(!notification.isSeen)
useEffect(() => {
2022-07-06 21:06:41 +00:00
setNotificationsAsSeen([notification])
}, [notification])
2022-09-15 19:39:46 +00:00
// TODO Any new notification should be its own component
if (reason === 'bet_fill') {
return (
<BetFillNotification
notification={notification}
isChildOfGroup={isChildOfGroup}
highlighted={highlighted}
justSummary={justSummary}
/>
)
2022-09-15 21:25:19 +00:00
} else if (sourceType === 'contract' && sourceUpdateType === 'resolved') {
return (
<ContractResolvedNotification
notification={notification}
isChildOfGroup={isChildOfGroup}
highlighted={highlighted}
justSummary={justSummary}
/>
)
2022-09-15 19:39:46 +00:00
}
// TODO Add new notification components here
2022-07-15 17:53:30 +00:00
2022-07-06 21:06:41 +00:00
if (justSummary) {
return (
2022-09-15 19:39:46 +00:00
<NotificationSummaryFrame
notification={notification}
subtitle={
(sourceType &&
reason &&
getReasonForShowingNotification(notification, true)) ??
''
}
>
<NotificationTextLabel
className={'line-clamp-1'}
notification={notification}
justSummary={true}
/>
</NotificationSummaryFrame>
)
}
2022-09-15 19:39:46 +00:00
return (
<NotificationFrame
notification={notification}
subtitle={getReasonForShowingNotification(
notification,
isChildOfGroup ?? false
)}
highlighted={highlighted}
>
<div className={'mt-1 ml-1 md:text-base'}>
<NotificationTextLabel notification={notification} />
</div>
</NotificationFrame>
)
}
function NotificationSummaryFrame(props: {
notification: Notification
subtitle: string
children: React.ReactNode
}) {
const { notification, subtitle, children } = props
const { sourceUserName, sourceUserUsername } = notification
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'}
short={true}
/>
<div className={'inline-flex overflow-hidden text-ellipsis pl-1'}>
<span className={'flex-shrink-0'}>{subtitle}</span>
<div className={'line-clamp-1 ml-1 text-black'}>{children}</div>
</div>
</div>
</div>
</Row>
)
}
function NotificationFrame(props: {
notification: Notification
highlighted: boolean
subtitle: string
children: React.ReactNode
isChildOfGroup?: boolean
}) {
const { notification, isChildOfGroup, highlighted, subtitle, children } =
props
const {
sourceType,
sourceUserName,
sourceUserAvatarUrl,
sourceUpdateType,
reason,
reasonText,
sourceUserUsername,
sourceText,
} = notification
const questionNeedsResolution = sourceUpdateType == 'closed'
2022-09-15 21:25:19 +00:00
const { width } = useWindowSize()
const isMobile = (width ?? 0) < 600
return (
2022-07-06 21:06:41 +00:00
<div
className={clsx(
'bg-white px-2 pt-6 text-sm sm:px-4',
2022-07-15 22:57:58 +00:00
highlighted && HIGHLIGHT_CLASS
2022-07-06 21:06:41 +00:00
)}
>
<div className={'relative cursor-pointer'}>
<SiteLink
href={getSourceUrl(notification) ?? ''}
className={'absolute left-0 right-0 top-0 bottom-0 z-0'}
onClick={() =>
track('Notification Clicked', {
type: 'notification item',
sourceType,
sourceUserName,
sourceUserAvatarUrl,
sourceUpdateType,
reasonText,
reason,
sourceUserUsername,
sourceText,
})
}
/>
2022-07-06 21:06:41 +00:00
<Row className={'items-center text-gray-500 sm:justify-start'}>
<Avatar
2022-07-15 17:53:30 +00:00
avatarUrl={
questionNeedsResolution
? MANIFOLD_AVATAR_URL
: sourceUserAvatarUrl
}
2022-07-06 21:06:41 +00:00
size={'sm'}
className={'z-10 mr-2'}
2022-07-15 17:53:30 +00:00
username={
questionNeedsResolution ? MANIFOLD_USERNAME : sourceUserUsername
}
/>
<div className={'flex w-full flex-row pl-1 sm:pl-0'}>
2022-07-06 21:06:41 +00:00
<div
className={
'line-clamp-2 sm:line-clamp-none flex w-full flex-row justify-between'
2022-07-06 21:06:41 +00:00
}
>
<div>
2022-09-15 19:39:46 +00:00
<UserLink
name={sourceUserName || ''}
username={sourceUserUsername || ''}
className={'relative mr-1 flex-shrink-0'}
2022-09-15 21:25:19 +00:00
short={isMobile}
2022-09-15 19:39:46 +00:00
/>
{subtitle}
{isChildOfGroup ? (
<RelativeTimestamp time={notification.createdTime} />
) : (
<QuestionOrGroupLink notification={notification} />
)}
</div>
2022-07-06 21:06:41 +00:00
</div>
{!isChildOfGroup && (
<div className={'hidden sm:inline-block'}>
<RelativeTimestamp time={notification.createdTime} />
</div>
)}
2022-07-06 21:06:41 +00:00
</div>
</Row>
2022-09-15 19:39:46 +00:00
<div className={'mt-1 ml-1 md:text-base'}>{children}</div>
2022-07-06 21:06:41 +00:00
<div className={'mt-6 border-b border-gray-300'} />
2022-07-15 17:53:30 +00:00
</div>
</div>
)
}
2022-09-15 19:39:46 +00:00
function BetFillNotification(props: {
notification: Notification
highlighted: boolean
justSummary: boolean
isChildOfGroup?: boolean
}) {
const { notification, isChildOfGroup, highlighted, justSummary } = props
const { sourceText, data } = notification
2022-09-16 15:02:58 +00:00
const { creatorOutcome, probability, limitOrderTotal, limitOrderRemaining } =
(data as BetFillData) ?? {}
2022-09-15 19:39:46 +00:00
const subtitle = 'bet against you'
const amount = formatMoney(parseInt(sourceText ?? '0'))
const description =
creatorOutcome && probability ? (
<span>
2022-09-16 15:02:58 +00:00
of your {limitOrderTotal ? formatMoney(limitOrderTotal) : ''}
2022-09-15 19:39:46 +00:00
<span
2022-09-16 15:02:58 +00:00
className={clsx(
'mx-1',
2022-09-15 19:39:46 +00:00
creatorOutcome === 'YES'
? 'text-primary'
: creatorOutcome === 'NO'
? 'text-red-500'
: 'text-blue-500'
2022-09-16 15:02:58 +00:00
)}
2022-09-15 19:39:46 +00:00
>
2022-09-16 15:02:58 +00:00
{creatorOutcome}
2022-09-15 19:39:46 +00:00
</span>
2022-09-16 15:02:58 +00:00
limit order at {Math.round(probability * 100)}% was filled{' '}
{limitOrderRemaining
? `(${formatMoney(limitOrderRemaining)} remaining)`
: ''}
2022-09-15 19:39:46 +00:00
</span>
) : (
<span>of your limit order was filled</span>
)
if (justSummary) {
return (
<NotificationSummaryFrame notification={notification} subtitle={subtitle}>
<Row className={'line-clamp-1'}>
<span className={'text-primary mr-1'}>{amount}</span>
<span>{description}</span>
</Row>
</NotificationSummaryFrame>
)
}
return (
<NotificationFrame
notification={notification}
isChildOfGroup={isChildOfGroup}
highlighted={highlighted}
subtitle={subtitle}
>
<Row>
<span>
<span className="text-primary mr-1">{amount}</span>
{description}
</span>
</Row>
</NotificationFrame>
)
}
2022-09-15 21:25:19 +00:00
function ContractResolvedNotification(props: {
notification: Notification
highlighted: boolean
justSummary: boolean
isChildOfGroup?: boolean
}) {
const { notification, isChildOfGroup, highlighted, justSummary } = props
const { sourceText, data } = notification
const { userInvestment, userPayout } = (data as ContractResolutionData) ?? {}
const subtitle = 'resolved the market'
2022-09-17 20:01:45 +00:00
2022-09-15 21:25:19 +00:00
const resolutionDescription = () => {
if (!sourceText) return <div />
2022-09-17 20:01:45 +00:00
2022-09-15 21:25:19 +00:00
if (sourceText === 'YES' || sourceText == 'NO') {
return <BinaryOutcomeLabel outcome={sourceText as any} />
}
2022-09-17 20:01:45 +00:00
2022-09-15 21:25:19 +00:00
if (sourceText.includes('%'))
2022-09-17 20:01:45 +00:00
return (
<ProbPercentLabel
prob={parseFloat(sourceText.replace('%', '')) / 100}
/>
)
2022-09-15 21:25:19 +00:00
if (sourceText === 'CANCEL') return <CancelLabel />
if (sourceText === 'MKT' || sourceText === 'PROB') return <MultiLabel />
// Numeric market
if (parseFloat(sourceText))
return <NumericValueLabel value={parseFloat(sourceText)} />
// Free response market
return (
<div className={'line-clamp-1 text-blue-400'}>
<Linkify text={sourceText} />
</div>
)
}
const description =
2022-09-15 21:50:26 +00:00
userInvestment && userPayout !== undefined ? (
2022-09-15 21:25:19 +00:00
<Row className={'gap-1 '}>
2022-09-17 20:01:45 +00:00
Resolved: {resolutionDescription()}
2022-09-15 21:25:19 +00:00
Invested:
<span className={'text-primary'}>{formatMoney(userInvestment)} </span>
Payout:
<span
className={clsx(
userPayout > 0 ? 'text-primary' : 'text-red-500',
'truncate'
)}
>
{formatMoney(userPayout)}
2022-09-15 21:50:26 +00:00
{` (${userPayout > 0 ? '+' : ''}${Math.round(
2022-09-15 21:25:19 +00:00
((userPayout - userInvestment) / userInvestment) * 100
)}%)`}
</span>
</Row>
) : (
2022-09-17 20:01:45 +00:00
<span>Resolved {resolutionDescription()}</span>
2022-09-15 21:25:19 +00:00
)
if (justSummary) {
return (
<NotificationSummaryFrame notification={notification} subtitle={subtitle}>
<Row className={'line-clamp-1'}>{description}</Row>
</NotificationSummaryFrame>
)
}
return (
<NotificationFrame
notification={notification}
isChildOfGroup={isChildOfGroup}
highlighted={highlighted}
subtitle={subtitle}
>
<Row>
<span>{description}</span>
</Row>
</NotificationFrame>
)
}
export const setNotificationsAsSeen = async (notifications: Notification[]) => {
const unseenNotifications = notifications.filter((n) => !n.isSeen)
return await Promise.all(
unseenNotifications.map((n) => {
const notificationDoc = doc(db, `users/${n.userId}/notifications/`, n.id)
return updateDoc(notificationDoc, { isSeen: true, viewTime: new Date() })
})
)
2022-07-06 21:06:41 +00:00
}
function QuestionOrGroupLink(props: {
notification: Notification
2022-07-07 21:06:29 +00:00
ignoreClick?: boolean
}) {
2022-07-07 21:06:29 +00:00
const { notification, ignoreClick } = props
2022-07-06 13:45:47 +00:00
const {
sourceType,
sourceContractTitle,
sourceContractCreatorUsername,
sourceContractSlug,
sourceSlug,
sourceTitle,
} = notification
2022-07-07 21:06:29 +00:00
if (ignoreClick)
return (
<span className={'ml-1 font-bold '}>
{sourceContractTitle || sourceTitle}
</span>
)
2022-07-06 13:45:47 +00:00
return (
<SiteLink
className={'relative ml-1 font-bold'}
2022-07-06 13:45:47 +00:00
href={
sourceContractCreatorUsername
? `/${sourceContractCreatorUsername}/${sourceContractSlug}`
2022-07-18 16:40:44 +00:00
: // User's added to group or received a tip there
(sourceType === 'group' || sourceType === 'tip') && sourceSlug
2022-07-06 13:45:47 +00:00
? `${groupPath(sourceSlug)}`
2022-07-18 16:40:44 +00:00
: // User referral via group
sourceSlug?.includes('/group/')
? `${sourceSlug}`
2022-07-06 13:45:47 +00:00
: ''
}
2022-07-07 21:06:29 +00:00
onClick={() =>
track('Notification Clicked', {
type: 'question title',
sourceType,
sourceContractTitle,
sourceContractCreatorUsername,
sourceContractSlug,
sourceSlug,
sourceTitle,
})
}
2022-07-06 13:45:47 +00:00
>
{sourceContractTitle || sourceTitle}
</SiteLink>
2022-07-06 13:45:47 +00:00
)
}
function getSourceUrl(notification: Notification) {
const {
sourceType,
sourceId,
sourceUserUsername,
sourceContractCreatorUsername,
sourceContractSlug,
sourceSlug,
} = notification
if (sourceType === 'follow') return `/${sourceUserUsername}`
if (sourceType === 'group' && sourceSlug) return `${groupPath(sourceSlug)}`
2022-07-18 16:40:44 +00:00
// User referral via contract:
if (
sourceContractCreatorUsername &&
sourceContractSlug &&
sourceType === 'user'
)
return `/${sourceContractCreatorUsername}/${sourceContractSlug}`
2022-07-18 16:40:44 +00:00
// User referral:
if (sourceType === 'user' && !sourceContractSlug)
return `/${sourceUserUsername}`
Challenge Bets (#679) * Challenge bets * Store avatar url * Fix before and after probs * Check balance before creation * Calculate winning shares * pretty * Change winning value * Set shares to equal each other * Fix share challenge link * pretty * remove lib refs * Probability of bet is set to market * Remove peer pill * Cleanup * Button on contract page * don't show challenge if not binary or if resolved * challenge button (WIP) * fix accept challenge: don't change pool/probability * Opengraph preview [WIP] * elim lib * Edit og card props * Change challenge text * New card gen attempt * Get challenge on server * challenge button styling * Use env domain * Remove other window ref * Use challenge creator as avatar * Remove user name * Remove s from property, replace prob with outcome * challenge form * share text * Add in challenge parts to template and url * Challenge url params optional * Add challenge params to parse request * Parse please * Don't remove prob * Challenge card styling * Challenge card styling * Challenge card styling * Challenge card styling * Challenge card styling * Challenge card styling * Challenge card styling * Challenge card styling * Add to readme about how to dev og-image * Add emojis * button: gradient background, 2xl size * beautify accept bet screen * update question button * Add separate challenge template * Accepted challenge sharing card, fix accept bet call * accept challenge button * challenge winner page * create challenge screen * Your outcome/cost=> acceptorOutcome/cost * New create challenge panel * Fix main merge * Add challenge slug to bet and filter by it * Center title * Add helper text * Add FAQ section * Lint * Columnize the user areas in preview link too * Absolutely position * Spacing * Orientation * Restyle challenges list, cache contract name * Make copying easy on mobile * Link spacing * Fix spacing * qr codes! * put your challenges first * eslint * Changes to contract buttons and create challenge modal * Change titles around for current bet * Add back in contract title after winning * Cleanup * Add challenge enabled flag * Spacing of switch button * Put sharing qr code in modal Co-authored-by: mantikoros <sgrugett@gmail.com>
2022-08-04 21:27:02 +00:00
if (sourceType === 'challenge') return `${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 ''
🧾 Limit orders! (#495) * Simple limit order UI * Update bet schema * Restrict bet panel / bet row to only CPMMBinaryContracts (all binary DPM are resolved) * Limit orders partway implemented * Update follow leaderboard copy * Change cpmm code to take some state instead of whole contract * Write more of matching algorithm * Fill in more of placebet * Use client side contract search for emulator * More correct matching * Merge branch 'main' into limit-orders * Some cleanup * Listen for unfilled bets in bet panel. Calculate how the probability moves based on open limit orders. * Simpler switching between bet & limit bet. * Render your open bets (unfilled limit orders) * Cancel bet endpoint. * Fix build error * Rename open bets to limit bets. Tweak payout calculation * Limit probability selector to 1-99 * Deduct user balance only on each fill. Store orderAmount of bet. Timestamp of fills. * Use floating equal to check if have shares * Add limit order switcher to mobile bet dialog * Support limit orders on numeric markets * Allow CORS exception for Vercel deployments * Remove console.logs * Update user balance by new bet amount * Tweak vercel cors * Try another regexp for vercel cors * Test another vercel regex * Slight notifications refactor * Fix docs edit link (#624) * Fix docs edit link * Update github links * Small groups UX changes * Groups UX on mobile * Leaderboards => Rankings on groups * Unused vars * create: remove automatic setting of log scale * Use react-query to cache notifications (#625) * Use react-query to cache notifications * Fix imports * Cleanup * Limit unseen notifs query * Catch the bounced query * Don't use interval * Unused var * Avoid flash of page nav * Give notification question priority & 2 lines * Right justify timestamps * Rewording * Margin * Simplify error msg * Be explicit about limit for unseen notifs * Pass limit > 0 * Remove category filters * Remove category selector references * Track notification clicks * Analyze tab usage * Bold more on new group chats * Add API route for listing a bets by user (#567) * Add API route for getting a user's bets * Refactor bets API to use /bets * Update /markets to use zod validation * Update docs * Clone missing indexes from firestore * Minor notif spacing adjustments * Enable tipping on group chats w/ notif (#629) * Tweak cors regex for vercel * Your limit bets * Implement selling shares * Merge branch 'main' into limit-orders * Fix lint * Move binary search to util file * Add note that there might be closed form * Add tooltip to explain limit probability * Tweak * Cancel your limit orders if you run out of money * Don't show amount error in probability input * Require limit prob to be >= .1% and <= 99.9% * Fix focus input bug * Simplify mobile betting dialog * Move mobile limit bets list into bet dialog. * Small fixes to existing sell shares client * Lint * Refactor useSaveShares to actually read from localStorage, use less bug-prone interface. * Fix NaN error * Remove TODO * Simple bet fill notification * Tweak wording * Sort limit bets by limit prob * Padding on limit bets * Match header size Co-authored-by: Ian Philips <iansphilips@gmail.com> Co-authored-by: ahalekelly <ahalekelly@gmail.com> Co-authored-by: mantikoros <sgrugett@gmail.com> Co-authored-by: Ben Congdon <ben@congdon.dev> Co-authored-by: Austin Chen <akrolsmir@gmail.com>
2022-07-10 18:05:44 +00:00
case 'bet':
return ''
default:
return sourceId
}
}
function NotificationTextLabel(props: {
notification: Notification
className?: string
justSummary?: boolean
}) {
2022-07-11 13:51:48 +00:00
const { className, notification, justSummary } = props
2022-07-15 17:53:30 +00:00
const { sourceUpdateType, sourceType, sourceText, reasonText } = notification
2022-07-11 13:51:48 +00:00
const defaultText = sourceText ?? reasonText ?? ''
if (sourceType === 'contract') {
2022-07-15 17:53:30 +00:00
if (justSummary || !sourceText) return <div />
// Resolved contracts
2022-07-13 13:41:58 +00:00
// Close date will be a number - it looks better without it
if (sourceUpdateType === 'closed') {
return <div />
}
// Updated contracts
// Description will be in default text
if (parseInt(sourceText) > 0) {
return (
<span>
Updated close time: {new Date(parseInt(sourceText)).toLocaleString()}
</span>
)
}
} else if (sourceType === 'user' && sourceText) {
return (
<span>
As a thank you, we sent you{' '}
<span className="text-primary">
{formatMoney(parseInt(sourceText))}
</span>
!
</span>
)
} else if (sourceType === 'liquidity' && sourceText) {
return (
<span className="text-blue-400">{formatMoney(parseInt(sourceText))}</span>
)
Challenge Bets (#679) * Challenge bets * Store avatar url * Fix before and after probs * Check balance before creation * Calculate winning shares * pretty * Change winning value * Set shares to equal each other * Fix share challenge link * pretty * remove lib refs * Probability of bet is set to market * Remove peer pill * Cleanup * Button on contract page * don't show challenge if not binary or if resolved * challenge button (WIP) * fix accept challenge: don't change pool/probability * Opengraph preview [WIP] * elim lib * Edit og card props * Change challenge text * New card gen attempt * Get challenge on server * challenge button styling * Use env domain * Remove other window ref * Use challenge creator as avatar * Remove user name * Remove s from property, replace prob with outcome * challenge form * share text * Add in challenge parts to template and url * Challenge url params optional * Add challenge params to parse request * Parse please * Don't remove prob * Challenge card styling * Challenge card styling * Challenge card styling * Challenge card styling * Challenge card styling * Challenge card styling * Challenge card styling * Challenge card styling * Add to readme about how to dev og-image * Add emojis * button: gradient background, 2xl size * beautify accept bet screen * update question button * Add separate challenge template * Accepted challenge sharing card, fix accept bet call * accept challenge button * challenge winner page * create challenge screen * Your outcome/cost=> acceptorOutcome/cost * New create challenge panel * Fix main merge * Add challenge slug to bet and filter by it * Center title * Add helper text * Add FAQ section * Lint * Columnize the user areas in preview link too * Absolutely position * Spacing * Orientation * Restyle challenges list, cache contract name * Make copying easy on mobile * Link spacing * Fix spacing * qr codes! * put your challenges first * eslint * Changes to contract buttons and create challenge modal * Change titles around for current bet * Add back in contract title after winning * Cleanup * Add challenge enabled flag * Spacing of switch button * Put sharing qr code in modal Co-authored-by: mantikoros <sgrugett@gmail.com>
2022-08-04 21:27:02 +00:00
} else if (sourceType === 'challenge' && sourceText) {
return (
<>
<span> for </span>
<span className="text-primary">
{formatMoney(parseInt(sourceText))}
</span>
</>
)
}
return (
<div className={className ? className : 'line-clamp-4 whitespace-pre-line'}>
<Linkify text={defaultText} />
</div>
)
}
function getReasonForShowingNotification(
notification: Notification,
2022-07-15 17:53:30 +00:00
justSummary: boolean
) {
const { sourceType, sourceUpdateType, reason, sourceSlug } = notification
let reasonText: string
// TODO: we could leave out this switch and just use the reason field now that they have more information
if (reason === 'tagged_user')
reasonText = justSummary ? 'tagged you' : 'tagged you on'
else
switch (sourceType) {
case 'comment':
if (reason === 'reply_to_users_answer')
reasonText = justSummary ? 'replied' : 'replied to 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 === 'contract_from_followed_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 === 'answer_on_your_contract')
reasonText = `answered your question `
else reasonText = `answered`
break
case 'follow':
reasonText = 'followed you'
break
case 'liquidity':
reasonText = 'added a subsidy 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 'challenge':
reasonText = 'accepted your challenge'
break
default:
reasonText = ''
}
2022-07-15 17:53:30 +00:00
return reasonText
}