Daily trading bonuses (#618)

* first commit, WIP

* Give trading bonuses & paginate notifications

* Move read & update into transaction

* Move request bonus logic to notifs icon
This commit is contained in:
Ian Philips 2022-07-05 11:29:26 -06:00 committed by GitHub
parent 53b4a28944
commit b26648c1ce
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 525 additions and 157 deletions

View File

@ -34,6 +34,7 @@ export type notification_source_types =
| 'admin_message'
| 'group'
| 'user'
| 'bonus'
export type notification_source_update_types =
| 'created'
@ -56,3 +57,4 @@ export type notification_reason_types =
| 'added_you_to_group'
| 'you_referred_user'
| 'user_joined_to_bet_on_your_market'
| 'unique_bettors_on_your_contract'

View File

@ -3,3 +3,4 @@ export const NUMERIC_FIXED_VAR = 0.005
export const NUMERIC_GRAPH_COLOR = '#5fa5f9'
export const NUMERIC_TEXT_COLOR = 'text-blue-500'
export const UNIQUE_BETTOR_BONUS_AMOUNT = 5

View File

@ -1,6 +1,6 @@
// A txn (pronounced "texan") respresents a payment between two ids on Manifold
// Shortened from "transaction" to distinguish from Firebase transactions (and save chars)
type AnyTxnType = Donation | Tip | Manalink | Referral
type AnyTxnType = Donation | Tip | Manalink | Referral | Bonus
type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK'
export type Txn<T extends AnyTxnType = AnyTxnType> = {
@ -16,7 +16,8 @@ export type Txn<T extends AnyTxnType = AnyTxnType> = {
amount: number
token: 'M$' // | 'USD' | MarketOutcome
category: 'CHARITY' | 'MANALINK' | 'TIP' | 'REFERRAL' // | 'BET'
category: 'CHARITY' | 'MANALINK' | 'TIP' | 'REFERRAL' | 'UNIQUE_BETTOR_BONUS'
// Any extra data
data?: { [key: string]: any }
@ -52,6 +53,12 @@ type Referral = {
category: 'REFERRAL'
}
type Bonus = {
fromType: 'BANK'
toType: 'USER'
category: 'UNIQUE_BETTOR_BONUS'
}
export type DonationTxn = Txn & Donation
export type TipTxn = Txn & Tip
export type ManalinkTxn = Txn & Manalink

View File

@ -57,6 +57,7 @@ export type PrivateUser = {
initialIpAddress?: string
apiKey?: string
notificationPreferences?: notification_subscribe_types
lastTimeCheckedBonuses?: number
}
export type notification_subscribe_types = 'all' | 'less' | 'none'

View File

@ -267,6 +267,15 @@ export const createNotification = async (
}
}
const notifyContractCreatorOfUniqueBettorsBonus = async (
userToReasonTexts: user_to_reason_texts,
userId: string
) => {
userToReasonTexts[userId] = {
reason: 'unique_bettors_on_your_contract',
}
}
const getUsersToNotify = async () => {
const userToReasonTexts: user_to_reason_texts = {}
// The following functions modify the userToReasonTexts object in place.
@ -309,6 +318,12 @@ export const createNotification = async (
})
} else if (sourceType === 'liquidity' && sourceUpdateType === 'created') {
await notifyContractCreator(userToReasonTexts, sourceContract)
} else if (sourceType === 'bonus' && sourceUpdateType === 'created') {
// Note: the daily bonus won't have a contract attached to it
await notifyContractCreatorOfUniqueBettorsBonus(
userToReasonTexts,
sourceContract.creatorId
)
}
return userToReasonTexts
}

View File

@ -0,0 +1,139 @@
import { APIError, newEndpoint } from './api'
import { log } from './utils'
import * as admin from 'firebase-admin'
import { PrivateUser } from '../../common/lib/user'
import { uniq } from 'lodash'
import { Bet } from '../../common/lib/bet'
const firestore = admin.firestore()
import { HOUSE_LIQUIDITY_PROVIDER_ID } from '../../common/antes'
import { runTxn, TxnData } from './transact'
import { createNotification } from './create-notification'
import { User } from '../../common/lib/user'
import { Contract } from '../../common/lib/contract'
import { UNIQUE_BETTOR_BONUS_AMOUNT } from '../../common/numeric-constants'
const BONUS_START_DATE = new Date('2022-07-01T00:00:00.000Z').getTime()
const QUERY_LIMIT_SECONDS = 60
export const getdailybonuses = newEndpoint({}, async (req, auth) => {
const { user, lastTimeCheckedBonuses } = await firestore.runTransaction(
async (trans) => {
const userSnap = await trans.get(
firestore.doc(`private-users/${auth.uid}`)
)
if (!userSnap.exists) throw new APIError(400, 'User not found.')
const user = userSnap.data() as PrivateUser
const lastTimeCheckedBonuses = user.lastTimeCheckedBonuses ?? 0
if (Date.now() - lastTimeCheckedBonuses < QUERY_LIMIT_SECONDS * 1000)
throw new APIError(
400,
`Limited to one query per user per ${QUERY_LIMIT_SECONDS} seconds.`
)
await trans.update(userSnap.ref, {
lastTimeCheckedBonuses: Date.now(),
})
return {
user,
lastTimeCheckedBonuses,
}
}
)
// TODO: switch to prod id
// const fromUserId = '94YYTk1AFWfbWMpfYcvnnwI1veP2' // dev manifold account
const fromUserId = HOUSE_LIQUIDITY_PROVIDER_ID // prod manifold account
const fromSnap = await firestore.doc(`users/${fromUserId}`).get()
if (!fromSnap.exists) throw new APIError(400, 'From user not found.')
const fromUser = fromSnap.data() as User
// Get all users contracts made since implementation time
const userContractsSnap = await firestore
.collection(`contracts`)
.where('creatorId', '==', user.id)
.where('createdTime', '>=', BONUS_START_DATE)
.get()
const userContracts = userContractsSnap.docs.map(
(doc) => doc.data() as Contract
)
const nullReturn = { status: 'no bets', txn: null }
for (const contract of userContracts) {
const result = await firestore.runTransaction(async (trans) => {
const contractId = contract.id
// Get all bets made on user's contracts
const bets = (
await firestore
.collection(`contracts/${contractId}/bets`)
.where('userId', '!=', user.id)
.get()
).docs.map((bet) => bet.ref)
if (bets.length === 0) {
return nullReturn
}
const contractBetsSnap = await trans.getAll(...bets)
const contractBets = contractBetsSnap.map((doc) => doc.data() as Bet)
const uniqueBettorIdsBeforeLastResetTime = uniq(
contractBets
.filter((bet) => bet.createdTime < lastTimeCheckedBonuses)
.map((bet) => bet.userId)
)
// Filter users for ONLY those that have made bets since the last daily bonus received time
const uniqueBettorIdsWithBetsAfterLastResetTime = uniq(
contractBets
.filter((bet) => bet.createdTime > lastTimeCheckedBonuses)
.map((bet) => bet.userId)
)
// Filter for users only present in the above list
const newUniqueBettorIds =
uniqueBettorIdsWithBetsAfterLastResetTime.filter(
(userId) => !uniqueBettorIdsBeforeLastResetTime.includes(userId)
)
newUniqueBettorIds.length > 0 &&
log(
`Got ${newUniqueBettorIds.length} new unique bettors since last bonus`
)
if (newUniqueBettorIds.length === 0) {
return nullReturn
}
// Create combined txn for all unique bettors
const bonusTxnDetails = {
contractId: contractId,
uniqueBettors: newUniqueBettorIds.length,
}
const bonusTxn: TxnData = {
fromId: fromUser.id,
fromType: 'BANK',
toId: user.id,
toType: 'USER',
amount: UNIQUE_BETTOR_BONUS_AMOUNT * newUniqueBettorIds.length,
token: 'M$',
category: 'UNIQUE_BETTOR_BONUS',
description: JSON.stringify(bonusTxnDetails),
}
return await runTxn(trans, bonusTxn)
})
if (result.status != 'success' || !result.txn) {
result.status != nullReturn.status &&
log(`No bonus for user: ${user.id} - reason:`, result.status)
} else {
log(`Bonus txn for user: ${user.id} completed:`, result.txn?.id)
await createNotification(
result.txn.id,
'bonus',
'created',
fromUser,
result.txn.id,
result.txn.amount + '',
contract,
undefined,
// No need to set the user id, we'll use the contract creator id
undefined,
contract.slug,
contract.question
)
}
}
return { userId: user.id, message: 'success' }
})

View File

@ -38,3 +38,4 @@ export * from './create-contract'
export * from './withdraw-liquidity'
export * from './create-group'
export * from './resolve-market'
export * from './get-daily-bonuses'

View File

@ -182,7 +182,6 @@ export default function Sidebar(props: { className?: string }) {
const { className } = props
const router = useRouter()
const currentPage = router.pathname
const user = useUser()
const navigationOptions = !user ? signedOutNavigation : getNavigation()
const mobileNavigationOptions = !user

View File

@ -2,17 +2,29 @@ import { BellIcon } from '@heroicons/react/outline'
import clsx from 'clsx'
import { Row } from 'web/components/layout/row'
import { useEffect, useState } from 'react'
import { useUser } from 'web/hooks/use-user'
import { usePrivateUser, useUser } from 'web/hooks/use-user'
import { useRouter } from 'next/router'
import { usePreferredGroupedNotifications } from 'web/hooks/use-notifications'
import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications'
import { requestBonuses } from 'web/lib/firebase/api-call'
export default function NotificationsIcon(props: { className?: string }) {
const user = useUser()
const notifications = usePreferredGroupedNotifications(user?.id, {
const privateUser = usePrivateUser(user?.id)
const notifications = usePreferredGroupedNotifications(privateUser?.id, {
unseenOnly: true,
})
const [seen, setSeen] = useState(false)
useEffect(() => {
if (!privateUser) return
if (Date.now() - (privateUser.lastTimeCheckedBonuses ?? 0) > 60 * 1000)
requestBonuses({}).catch((error) => {
console.log("couldn't get bonuses:", error.message)
})
}, [privateUser])
const router = useRouter()
useEffect(() => {
if (router.pathname.endsWith('notifications')) return setSeen(true)
@ -24,7 +36,9 @@ export default function NotificationsIcon(props: { className?: string }) {
<div className={'relative'}>
{!seen && notifications && notifications.length > 0 && (
<div className="-mt-0.75 absolute ml-3.5 min-w-[15px] rounded-full bg-indigo-500 p-[2px] text-center text-[10px] leading-3 text-white lg:-mt-1 lg:ml-2">
{notifications.length}
{notifications.length > NOTIFICATIONS_PER_PAGE
? `${NOTIFICATIONS_PER_PAGE}+`
: notifications.length}
</div>
)}
<BellIcon className={clsx(props.className)} />

View File

@ -7,9 +7,10 @@ import { groupBy, map } from 'lodash'
export type NotificationGroup = {
notifications: Notification[]
sourceContractId: string
groupedById: string
isSeen: boolean
timePeriod: string
type: 'income' | 'normal'
}
export function usePreferredGroupedNotifications(
@ -37,25 +38,43 @@ export function groupNotifications(notifications: Notification[]) {
new Date(notification.createdTime).toDateString()
)
Object.keys(notificationGroupsByDay).forEach((day) => {
// Group notifications by contract:
const notificationsGroupedByDay = notificationGroupsByDay[day]
const bonusNotifications = notificationsGroupedByDay.filter(
(notification) => notification.sourceType === 'bonus'
)
const normalNotificationsGroupedByDay = notificationsGroupedByDay.filter(
(notification) => notification.sourceType !== 'bonus'
)
if (bonusNotifications.length > 0) {
notificationGroups = notificationGroups.concat({
notifications: bonusNotifications,
groupedById: 'income' + day,
isSeen: bonusNotifications[0].isSeen,
timePeriod: day,
type: 'income',
})
}
// Group notifications by contract, filtering out bonuses:
const groupedNotificationsByContractId = groupBy(
notificationGroupsByDay[day],
normalNotificationsGroupedByDay,
(notification) => {
return notification.sourceContractId
}
)
notificationGroups = notificationGroups.concat(
map(groupedNotificationsByContractId, (notifications, contractId) => {
const notificationsForContractId = groupedNotificationsByContractId[
contractId
].sort((a, b) => {
return b.createdTime - a.createdTime
})
// 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,
notifications: notificationsForContractId,
groupedById: contractId,
isSeen: notificationsForContractId[0].isSeen,
timePeriod: day,
type: 'normal',
}
return notificationGroup
})

View File

@ -73,3 +73,7 @@ export function sellBet(params: any) {
export function createGroup(params: any) {
return call(getFunctionUrl('creategroup'), 'POST', params)
}
export function requestBonuses(params: any) {
return call(getFunctionUrl('getdailybonuses'), 'POST', params)
}

View File

@ -1,12 +1,7 @@
import { Tabs } from 'web/components/layout/tabs'
import { useUser } from 'web/hooks/use-user'
import React, { useEffect, useState } from 'react'
import {
Notification,
notification_reason_types,
notification_source_types,
notification_source_update_types,
} from 'common/notification'
import { Notification } from 'common/notification'
import { Avatar, EmptyAvatar } from 'web/components/avatar'
import { Row } from 'web/components/layout/row'
import { Page } from 'web/components/page'
@ -31,47 +26,40 @@ import {
ProbPercentLabel,
} from 'web/components/outcome-label'
import {
groupNotifications,
NotificationGroup,
usePreferredGroupedNotifications,
} from 'web/hooks/use-notifications'
import { CheckIcon, XIcon } from '@heroicons/react/outline'
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 } from 'lodash'
export const NOTIFICATIONS_PER_PAGE = 30
export const HIGHLIGHT_DURATION = 30 * 1000
export default function Notifications() {
const user = useUser()
const [unseenNotificationGroups, setUnseenNotificationGroups] = useState<
NotificationGroup[] | undefined
>(undefined)
const allNotificationGroups = usePreferredGroupedNotifications(user?.id, {
const [page, setPage] = useState(1)
const groupedNotifications = usePreferredGroupedNotifications(user?.id, {
unseenOnly: false,
})
const [paginatedNotificationGroups, setPaginatedNotificationGroups] =
useState<NotificationGroup[]>([])
useEffect(() => {
if (!allNotificationGroups) return
// Don't re-add notifications that are visible right now or have been seen already.
const currentlyVisibleUnseenNotificationIds = Object.values(
unseenNotificationGroups ?? []
)
.map((n) => n.notifications.map((n) => n.id))
.flat()
const unseenGroupedNotifications = groupNotifications(
allNotificationGroups
.map((notification: NotificationGroup) => notification.notifications)
.flat()
.filter(
(notification: Notification) =>
!notification.isSeen ||
currentlyVisibleUnseenNotificationIds.includes(notification.id)
)
)
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
}, [allNotificationGroups])
if (!groupedNotifications) return
const start = (page - 1) * NOTIFICATIONS_PER_PAGE
const end = start + NOTIFICATIONS_PER_PAGE
const maxNotificationsToShow = groupedNotifications.slice(start, end)
const remainingNotification = groupedNotifications.slice(end)
for (const notification of remainingNotification) {
if (notification.isSeen) break
else setNotificationsAsSeen(notification.notifications)
}
setPaginatedNotificationGroups(maxNotificationsToShow)
}, [groupedNotifications, page])
if (user === undefined) {
return <LoadingIndicator />
@ -80,7 +68,6 @@ export default function Notifications() {
return <Custom404 />
}
// TODO: use infinite scroll
return (
<Page>
<div className={'p-2 sm:p-4'}>
@ -90,53 +77,74 @@ export default function Notifications() {
defaultIndex={0}
tabs={[
{
title: 'New Notifications',
content: unseenNotificationGroups ? (
title: 'Notifications',
content: groupedNotifications ? (
<div className={''}>
{unseenNotificationGroups.length === 0 &&
"You don't have any new notifications."}
{unseenNotificationGroups.map((notification) =>
{paginatedNotificationGroups.length === 0 &&
"You don't have any notifications. Try changing your settings to see more."}
{paginatedNotificationGroups.map((notification) =>
notification.notifications.length === 1 ? (
<NotificationItem
notification={notification.notifications[0]}
key={notification.notifications[0].id}
/>
) : notification.type === 'income' ? (
<IncomeNotificationGroupItem
notificationGroup={notification}
key={notification.groupedById + notification.timePeriod}
/>
) : (
<NotificationGroupItem
notificationGroup={notification}
key={
notification.sourceContractId +
notification.timePeriod
}
key={notification.groupedById + notification.timePeriod}
/>
)
)}
</div>
) : (
<LoadingIndicator />
),
},
{
title: 'All Notifications',
content: allNotificationGroups ? (
<div className={''}>
{allNotificationGroups.length === 0 &&
"You don't have any notifications. Try changing your settings to see more."}
{allNotificationGroups.map((notification) =>
notification.notifications.length === 1 ? (
<NotificationItem
notification={notification.notifications[0]}
key={notification.notifications[0].id}
/>
) : (
<NotificationGroupItem
notificationGroup={notification}
key={
notification.sourceContractId +
notification.timePeriod
}
/>
)
{groupedNotifications.length > NOTIFICATIONS_PER_PAGE && (
<nav
className="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6"
aria-label="Pagination"
>
<div className="hidden sm:block">
<p className="text-sm text-gray-700">
Showing{' '}
<span className="font-medium">
{page === 1
? page
: (page - 1) * NOTIFICATIONS_PER_PAGE}
</span>{' '}
to{' '}
<span className="font-medium">
{page * NOTIFICATIONS_PER_PAGE}
</span>{' '}
of{' '}
<span className="font-medium">
{groupedNotifications.length}
</span>{' '}
results
</p>
</div>
<div className="flex flex-1 justify-between sm:justify-end">
<a
href="#"
className="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
onClick={() => page > 1 && setPage(page - 1)}
>
Previous
</a>
<a
href="#"
className="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
onClick={() =>
page <
groupedNotifications?.length /
NOTIFICATIONS_PER_PAGE && setPage(page + 1)
}
>
Next
</a>
</div>
</nav>
)}
</div>
) : (
@ -164,7 +172,6 @@ const setNotificationsAsSeen = (notifications: Notification[]) => {
updateDoc(
doc(db, `users/${notification.userId}/notifications/`, notification.id),
{
...notification,
isSeen: true,
viewTime: new Date(),
}
@ -173,6 +180,152 @@ const setNotificationsAsSeen = (notifications: Notification[]) => {
return notifications
}
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(false)
useEffect(() => {
if (notifications.some((n) => !n.isSeen)) {
setHighlighted(true)
setTimeout(() => {
setHighlighted(false)
}, HIGHLIGHT_DURATION)
}
setNotificationsAsSeen(notifications)
}, [notifications])
useEffect(() => {
if (expanded) setHighlighted(false)
}, [expanded])
const totalIncome = notifications.reduce(
(acc, notification) =>
acc +
(notification.sourceType &&
notification.sourceText &&
notification.sourceType === 'bonus'
? parseInt(notification.sourceText)
: 0),
0
)
// loop through the contracts and combine the notification items into one
function combineNotificationsByAddingSourceTextsAndReturningTheRest(
notifications: Notification[]
) {
const newNotifications = []
const groupedNotificationsByContractId = groupBy(
notifications,
(notification) => {
return notification.sourceContractId
}
)
for (const contractId in groupedNotificationsByContractId) {
const notificationsForContractId =
groupedNotificationsByContractId[contractId]
let sum = 0
notificationsForContractId.forEach(
(notification) =>
notification.sourceText &&
(sum = parseInt(notification.sourceText) + sum)
)
const newNotification =
notificationsForContractId.length === 1
? notificationsForContractId[0]
: {
...notificationsForContractId[0],
sourceText: sum.toString(),
}
newNotifications.push(newNotification)
}
return newNotifications
}
const combinedNotifs =
combineNotificationsByAddingSourceTextsAndReturningTheRest(notifications)
return (
<div
className={clsx(
'relative cursor-pointer bg-white px-2 pt-6 text-sm',
className,
!expanded ? 'hover:bg-gray-100' : '',
highlighted && !expanded ? 'bg-indigo-200 hover:bg-indigo-100' : ''
)}
onClick={() => setExpanded(!expanded)}
>
{expanded && (
<span
className="absolute top-14 left-6 -ml-px h-[calc(100%-5rem)] w-0.5 bg-gray-200"
aria-hidden="true"
/>
)}
<Row className={'items-center text-gray-500 sm:justify-start'}>
<TrendingUpIcon className={'text-primary h-7 w-7'} />
<div className={'flex-1 overflow-hidden pl-2 sm:flex'}>
<div
onClick={() => setExpanded(!expanded)}
className={'line-clamp-1 cursor-pointer pl-1 sm:pl-0'}
>
<span>
{'Daily Income Summary: '}
<span className={'text-primary'}>{formatMoney(totalIncome)}</span>
</span>
</div>
<RelativeTimestamp time={notifications[0].createdTime} />
</div>
</Row>
<div>
<div className={clsx('mt-1 md:text-base', expanded ? 'pl-4' : '')}>
{' '}
<div className={'line-clamp-4 mt-1 ml-1 gap-1 whitespace-pre-line'}>
{!expanded ? (
<>
{combinedNotifs
.slice(0, numSummaryLines)
.map((notification) => {
return (
<NotificationItem
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) => (
<NotificationItem
notification={notification}
key={notification.id}
justSummary={false}
/>
))}
</>
)}
</div>
</div>
<div className={'mt-6 border-b border-gray-300'} />
</div>
</div>
)
}
function NotificationGroupItem(props: {
notificationGroup: NotificationGroup
className?: string
@ -187,17 +340,28 @@ function NotificationGroupItem(props: {
const numSummaryLines = 3
const [expanded, setExpanded] = useState(false)
const [highlighted, setHighlighted] = useState(false)
useEffect(() => {
if (notifications.some((n) => !n.isSeen)) {
setHighlighted(true)
setTimeout(() => {
setHighlighted(false)
}, HIGHLIGHT_DURATION)
}
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' : ''
!expanded ? 'hover:bg-gray-100' : '',
highlighted && !expanded ? 'bg-indigo-200 hover:bg-indigo-100' : ''
)}
onClick={() => setExpanded(!expanded)}
>
@ -432,7 +596,7 @@ function NotificationSettings() {
/>
<NotificationSettingLine
highlight={notificationSettings !== 'none'}
label={"Referral bonuses you've received"}
label={"Income & referral bonuses you've received"}
/>
<NotificationSettingLine
label={"Activity on questions you've ever bet or commented on"}
@ -476,17 +640,6 @@ function NotificationSettings() {
)
}
function isNotificationAboutContractResolution(
sourceType: notification_source_types | undefined,
sourceUpdateType: notification_source_update_types | undefined,
contract: Contract | null | undefined
) {
return (
(sourceType === 'contract' && sourceUpdateType === 'resolved') ||
(sourceType === 'contract' && !sourceUpdateType && contract?.resolution)
)
}
function NotificationItem(props: {
notification: Notification
justSummary?: boolean
@ -522,6 +675,16 @@ function NotificationItem(props: {
}
}, [reasonText, sourceText])
const [highlighted, setHighlighted] = useState(false)
useEffect(() => {
if (!notification.isSeen) {
setHighlighted(true)
setTimeout(() => {
setHighlighted(false)
}, HIGHLIGHT_DURATION)
}
}, [notification.isSeen])
useEffect(() => {
setNotificationsAsSeen([notification])
}, [notification])
@ -559,22 +722,21 @@ function NotificationItem(props: {
<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'}
/>
{sourceType != 'bonus' && (
<UserLink
name={sourceUserName || ''}
username={sourceUserUsername || ''}
className={'mr-0 flex-shrink-0'}
/>
)}
<div className={'inline-flex overflow-hidden text-ellipsis pl-1'}>
<span className={'flex-shrink-0'}>
{sourceType &&
reason &&
getReasonForShowingNotification(
sourceType,
reason,
sourceUpdateType,
undefined,
true
).replace(' on', '')}
getReasonForShowingNotification(notification, true).replace(
' on',
''
)}
</span>
<div className={'ml-1 text-black'}>
<NotificationTextLabel
@ -593,37 +755,41 @@ function NotificationItem(props: {
}
return (
<div className={'bg-white px-2 pt-6 text-sm sm:px-4'}>
<div
className={clsx(
'bg-white px-2 pt-6 text-sm sm:px-4',
highlighted && 'bg-indigo-200'
)}
>
<a href={getSourceUrl()}>
<Row className={'items-center text-gray-500 sm:justify-start'}>
<Avatar
avatarUrl={sourceUserAvatarUrl}
size={'sm'}
className={'mr-2'}
username={sourceUserName}
/>
{sourceType != 'bonus' ? (
<Avatar
avatarUrl={sourceUserAvatarUrl}
size={'sm'}
className={'mr-2'}
username={sourceUserName}
/>
) : (
<TrendingUpIcon className={'text-primary h-7 w-7'} />
)}
<div className={'flex-1 overflow-hidden sm:flex'}>
<div
className={
'flex max-w-xl shrink overflow-hidden text-ellipsis pl-1 sm:pl-0'
}
>
<UserLink
name={sourceUserName || ''}
username={sourceUserUsername || ''}
className={'mr-0 flex-shrink-0'}
/>
{sourceType != 'bonus' && sourceUpdateType != 'closed' && (
<UserLink
name={sourceUserName || ''}
username={sourceUserUsername || ''}
className={'mr-0 flex-shrink-0'}
/>
)}
<div className={'inline-flex overflow-hidden text-ellipsis pl-1'}>
{sourceType && reason && (
<div className={'inline truncate'}>
{getReasonForShowingNotification(
sourceType,
reason,
sourceUpdateType,
undefined,
false,
sourceSlug
)}
{getReasonForShowingNotification(notification, false)}
<a
href={
sourceContractCreatorUsername
@ -684,13 +850,7 @@ function NotificationTextLabel(props: {
return <span>{contract?.question || sourceContractTitle}</span>
if (!sourceText) return <div />
// Resolved contracts
if (
isNotificationAboutContractResolution(
sourceType,
sourceUpdateType,
contract
)
) {
if (sourceType === 'contract' && sourceUpdateType === 'resolved') {
{
if (sourceText === 'YES' || sourceText == 'NO') {
return <BinaryOutcomeLabel outcome={sourceText as any} />
@ -730,6 +890,12 @@ function NotificationTextLabel(props: {
return (
<span className="text-blue-400">{formatMoney(parseInt(sourceText))}</span>
)
} else if (sourceType === 'bonus' && sourceText) {
return (
<span className="text-primary">
{'+' + formatMoney(parseInt(sourceText))}
</span>
)
}
// return default text
return (
@ -740,15 +906,13 @@ function NotificationTextLabel(props: {
}
function getReasonForShowingNotification(
source: notification_source_types,
reason: notification_reason_types,
sourceUpdateType: notification_source_update_types | undefined,
contract: Contract | undefined | null,
simple?: boolean,
sourceSlug?: string
notification: Notification,
simple?: boolean
) {
const { sourceType, sourceUpdateType, sourceText, reason, sourceSlug } =
notification
let reasonText: string
switch (source) {
switch (sourceType) {
case 'comment':
if (reason === 'reply_to_users_answer')
reasonText = !simple ? 'replied to your answer on' : 'replied'
@ -768,16 +932,9 @@ function getReasonForShowingNotification(
break
case 'contract':
if (reason === 'you_follow_user') reasonText = 'created a new question'
else if (
isNotificationAboutContractResolution(
source,
sourceUpdateType,
contract
)
)
reasonText = `resolved`
else if (sourceUpdateType === 'resolved') reasonText = `resolved`
else if (sourceUpdateType === 'closed')
reasonText = `please resolve your question`
reasonText = `Please resolve your question`
else reasonText = `updated`
break
case 'answer':
@ -805,6 +962,15 @@ function getReasonForShowingNotification(
else if (sourceSlug) reasonText = 'joined because you shared'
else reasonText = 'joined because of you'
break
case 'bonus':
if (reason === 'unique_bettors_on_your_contract' && sourceText)
reasonText = !simple
? `You had ${
parseInt(sourceText) / UNIQUE_BETTOR_BONUS_AMOUNT
} unique bettors on`
: 'You earned Mana for unique bettors:'
else reasonText = 'You earned your daily manna'
break
default:
reasonText = ''
}