diff --git a/common/antes.ts b/common/antes.ts
index d4cb2ff9..b3dd990b 100644
--- a/common/antes.ts
+++ b/common/antes.ts
@@ -15,6 +15,7 @@ import { ENV_CONFIG } from './envs/constants'
export const FIXED_ANTE = ENV_CONFIG.fixedAnte ?? 100
export const HOUSE_LIQUIDITY_PROVIDER_ID = 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2' // @ManifoldMarkets' id
+export const DEV_HOUSE_LIQUIDITY_PROVIDER_ID = '94YYTk1AFWfbWMpfYcvnnwI1veP2' // @ManifoldMarkets' id
export function getCpmmInitialLiquidity(
providerId: string,
diff --git a/common/notification.ts b/common/notification.ts
index 16444c48..da8a045a 100644
--- a/common/notification.ts
+++ b/common/notification.ts
@@ -61,3 +61,4 @@ export type notification_reason_types =
| 'user_joined_to_bet_on_your_market'
| 'unique_bettors_on_your_contract'
| 'on_group_you_are_member_of'
+ | 'tip_received'
diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts
index 45db1c4e..49bff5f7 100644
--- a/functions/src/create-notification.ts
+++ b/functions/src/create-notification.ts
@@ -66,9 +66,7 @@ export const createNotification = async (
sourceUserAvatarUrl: sourceUser.avatarUrl,
sourceText,
sourceContractCreatorUsername: sourceContract?.creatorUsername,
- // TODO: move away from sourceContractTitle to sourceTitle
sourceContractTitle: sourceContract?.question,
- // TODO: move away from sourceContractSlug to sourceSlug
sourceContractSlug: sourceContract?.slug,
sourceSlug: sourceSlug ? sourceSlug : sourceContract?.slug,
sourceTitle: sourceTitle ? sourceTitle : sourceContract?.question,
@@ -278,13 +276,22 @@ export const createNotification = async (
}
const notifyOtherGroupMembersOfComment = async (
+ userToReasons: user_to_reason_texts,
+ userId: string
+ ) => {
+ if (shouldGetNotification(userId, userToReasons))
+ userToReasons[userId] = {
+ reason: 'on_group_you_are_member_of',
+ isSeeOnHref: sourceSlug,
+ }
+ }
+ const notifyTippedUserOfNewTip = async (
userToReasonTexts: user_to_reason_texts,
userId: string
) => {
if (shouldGetNotification(userId, userToReasonTexts))
userToReasonTexts[userId] = {
- reason: 'on_group_you_are_member_of',
- isSeeOnHref: sourceSlug,
+ reason: 'tip_received',
}
}
@@ -304,6 +311,7 @@ export const createNotification = async (
// The following functions need sourceContract to be defined.
if (!sourceContract) return userToReasonTexts
+
if (
sourceType === 'comment' ||
sourceType === 'answer' ||
@@ -338,6 +346,8 @@ export const createNotification = async (
userToReasonTexts,
sourceContract.creatorId
)
+ } else if (sourceType === 'tip' && relatedUserId) {
+ await notifyTippedUserOfNewTip(userToReasonTexts, relatedUserId)
}
return userToReasonTexts
}
diff --git a/functions/src/get-daily-bonuses.ts b/functions/src/get-daily-bonuses.ts
index c5c1a1b3..017c32fc 100644
--- a/functions/src/get-daily-bonuses.ts
+++ b/functions/src/get-daily-bonuses.ts
@@ -1,11 +1,14 @@
import { APIError, newEndpoint } from './api'
-import { log } from './utils'
+import { isProd, 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 {
+ DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
+ HOUSE_LIQUIDITY_PROVIDER_ID,
+} from '../../common/antes'
import { runTxn, TxnData } from './transact'
import { createNotification } from './create-notification'
import { User } from '../../common/lib/user'
@@ -38,9 +41,9 @@ export const getdailybonuses = newEndpoint({}, async (req, auth) => {
}
}
)
- // TODO: switch to prod id
- // const fromUserId = '94YYTk1AFWfbWMpfYcvnnwI1veP2' // dev manifold account
- const fromUserId = HOUSE_LIQUIDITY_PROVIDER_ID // prod manifold account
+ const fromUserId = isProd()
+ ? HOUSE_LIQUIDITY_PROVIDER_ID
+ : DEV_HOUSE_LIQUIDITY_PROVIDER_ID
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
diff --git a/functions/src/index.ts b/functions/src/index.ts
index d9b7a255..8d1756f2 100644
--- a/functions/src/index.ts
+++ b/functions/src/index.ts
@@ -29,6 +29,7 @@ export * from './on-update-group'
export * from './on-create-group'
export * from './on-update-user'
export * from './on-create-comment-on-group'
+export * from './on-create-txn'
// v2
export * from './health'
diff --git a/functions/src/on-create-txn.ts b/functions/src/on-create-txn.ts
new file mode 100644
index 00000000..d877ecac
--- /dev/null
+++ b/functions/src/on-create-txn.ts
@@ -0,0 +1,68 @@
+import * as functions from 'firebase-functions'
+import { Txn } from 'common/txn'
+import { getContract, getUser, log } from './utils'
+import { createNotification } from './create-notification'
+import * as admin from 'firebase-admin'
+import { Comment } from 'common/comment'
+
+const firestore = admin.firestore()
+
+export const onCreateTxn = functions.firestore
+ .document('txns/{txnId}')
+ .onCreate(async (change, context) => {
+ const txn = change.data() as Txn
+ const { eventId } = context
+
+ if (txn.category === 'TIP') {
+ await handleTipTxn(txn, eventId)
+ }
+ })
+
+async function handleTipTxn(txn: Txn, eventId: string) {
+ // get user sending and receiving tip
+ const [sender, receiver] = await Promise.all([
+ getUser(txn.fromId),
+ getUser(txn.toId),
+ ])
+ if (!sender || !receiver) {
+ log('Could not find corresponding users')
+ return
+ }
+
+ if (!txn.data?.contractId || !txn.data?.commentId) {
+ log('No contractId or comment id in tip txn.data')
+ return
+ }
+
+ const contract = await getContract(txn.data.contractId)
+ if (!contract) {
+ log('Could not find contract')
+ return
+ }
+
+ const commentSnapshot = await firestore
+ .collection('contracts')
+ .doc(contract.id)
+ .collection('comments')
+ .doc(txn.data.commentId)
+ .get()
+ if (!commentSnapshot.exists) {
+ log('Could not find comment')
+ return
+ }
+ const comment = commentSnapshot.data() as Comment
+
+ await createNotification(
+ txn.id,
+ 'tip',
+ 'created',
+ sender,
+ eventId,
+ txn.amount.toString(),
+ contract,
+ 'comment',
+ receiver.id,
+ txn.data?.commentId,
+ comment.text
+ )
+}
diff --git a/web/components/avatar.tsx b/web/components/avatar.tsx
index e6506c03..53257deb 100644
--- a/web/components/avatar.tsx
+++ b/web/components/avatar.tsx
@@ -53,7 +53,7 @@ export function EmptyAvatar(props: { size?: number; multi?: boolean }) {
return (
diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx
index ed02128e..c327d8af 100644
--- a/web/components/feed/feed-comments.tsx
+++ b/web/components/feed/feed-comments.tsx
@@ -224,7 +224,7 @@ export function FeedComment(props: {
return (
diff --git a/web/hooks/use-notifications.ts b/web/hooks/use-notifications.ts
index 539573dd..98b0f2fd 100644
--- a/web/hooks/use-notifications.ts
+++ b/web/hooks/use-notifications.ts
@@ -39,17 +39,19 @@ export function groupNotifications(notifications: Notification[]) {
)
Object.keys(notificationGroupsByDay).forEach((day) => {
const notificationsGroupedByDay = notificationGroupsByDay[day]
- const bonusNotifications = notificationsGroupedByDay.filter(
- (notification) => notification.sourceType === 'bonus'
+ const incomeNotifications = notificationsGroupedByDay.filter(
+ (notification) =>
+ notification.sourceType === 'bonus' || notification.sourceType === 'tip'
)
const normalNotificationsGroupedByDay = notificationsGroupedByDay.filter(
- (notification) => notification.sourceType !== 'bonus'
+ (notification) =>
+ notification.sourceType !== 'bonus' && notification.sourceType !== 'tip'
)
- if (bonusNotifications.length > 0) {
+ if (incomeNotifications.length > 0) {
notificationGroups = notificationGroups.concat({
- notifications: bonusNotifications,
+ notifications: incomeNotifications,
groupedById: 'income' + day,
- isSeen: bonusNotifications[0].isSeen,
+ isSeen: incomeNotifications[0].isSeen,
timePeriod: day,
type: 'income',
})
diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx
index 2c6c2433..45ca234a 100644
--- a/web/pages/notifications.tsx
+++ b/web/pages/notifications.tsx
@@ -1,7 +1,7 @@
import { Tabs } from 'web/components/layout/tabs'
import { useUser } from 'web/hooks/use-user'
import React, { useEffect, useState } from 'react'
-import { Notification } from 'common/notification'
+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'
@@ -34,10 +34,10 @@ 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'
+import { groupBy, sum, uniq } from 'lodash'
export const NOTIFICATIONS_PER_PAGE = 30
-export const HIGHLIGHT_DURATION = 30 * 1000
+const MULTIPLE_USERS_KEY = 'multipleUsers'
export default function Notifications() {
const user = useUser()
@@ -187,16 +187,12 @@ function IncomeNotificationGroupItem(props: {
const { notificationGroup, className } = props
const { notifications } = notificationGroup
const numSummaryLines = 3
-
const [expanded, setExpanded] = useState(false)
- const [highlighted, setHighlighted] = useState(false)
+ const [highlighted, setHighlighted] = useState(
+ notifications.some((n) => !n.isSeen)
+ )
+
useEffect(() => {
- if (notifications.some((n) => !n.isSeen)) {
- setHighlighted(true)
- setTimeout(() => {
- setHighlighted(false)
- }, HIGHLIGHT_DURATION)
- }
setNotificationsAsSeen(notifications)
}, [notifications])
@@ -204,51 +200,62 @@ function IncomeNotificationGroupItem(props: {
if (expanded) setHighlighted(false)
}, [expanded])
- const totalIncome = notifications.reduce(
- (acc, notification) =>
- acc +
- (notification.sourceType &&
- notification.sourceText &&
- notification.sourceType === 'bonus'
- ? parseInt(notification.sourceText)
- : 0),
- 0
+ const totalIncome = sum(
+ notifications.map((notification) =>
+ notification.sourceText ? parseInt(notification.sourceText) : 0
+ )
)
- // loop through the contracts and combine the notification items into one
- function combineNotificationsByAddingSourceTextsAndReturningTheRest(
+ // Loop through the contracts and combine the notification items into one
+ function combineNotificationsByAddingNumericSourceTexts(
notifications: Notification[]
) {
const newNotifications = []
- const groupedNotificationsByContractId = groupBy(
+ const groupedNotificationsBySourceType = groupBy(
notifications,
- (notification) => {
- return notification.sourceContractId
- }
+ (n) => n.sourceType
)
- for (const contractId in groupedNotificationsByContractId) {
- const notificationsForContractId =
- groupedNotificationsByContractId[contractId]
- let sum = 0
- notificationsForContractId.forEach(
- (notification) =>
- notification.sourceText &&
- (sum = parseInt(notification.sourceText) + sum)
+ 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.length === 1
- ? notificationsForContractId[0]
- : {
- ...notificationsForContractId[0],
- sourceText: sum.toString(),
- }
- newNotifications.push(newNotification)
+ const newNotification = {
+ ...notificationsForContractId[0],
+ sourceText: sum.toString(),
+ sourceUserUsername:
+ uniqueUsers.length > 1
+ ? MULTIPLE_USERS_KEY
+ : notificationsForContractId[0].sourceType,
+ }
+ newNotifications.push(newNotification)
+ }
}
return newNotifications
}
const combinedNotifs =
- combineNotificationsByAddingSourceTextsAndReturningTheRest(notifications)
+ combineNotificationsByAddingNumericSourceTexts(notifications)
return (
{' '}
-
+
{!expanded ? (
<>
{combinedNotifs
.slice(0, numSummaryLines)
- .map((notification) => {
- return (
-
-
-
-
-
-
-
-
- {getReasonForShowingNotification(
- notification,
- true
- )}
- {` on`}
-
-
-
-
-
-
- )
- })}
+ .map((notification) => (
+
+ ))}
{combinedNotifs.length - numSummaryLines > 0
? 'And ' +
@@ -344,7 +321,7 @@ function IncomeNotificationGroupItem(props: {
) : (
<>
{combinedNotifs.map((notification) => (
-
{
+ 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 for unique bettors on'
+ } else if (sourceType === 'tip') {
+ reasonText = !simple ? `tipped you` : `in tips on`
+ }
+ return {reasonText}
+ }
+
+ if (justSummary) {
+ return (
+
+
+
+
+
+
+
+
+ {getReasonForShowingIncomeNotification(true)}
+
+
+
+
+
+
+ )
+ }
+
+ return (
+
+ )
+}
+
function NotificationGroupItem(props: {
notificationGroup: NotificationGroup
className?: string
}) {
const { notificationGroup, className } = props
const { notifications } = notificationGroup
- const {
- sourceContractTitle,
- sourceContractSlug,
- sourceContractCreatorUsername,
- } = notifications[0]
+ const { sourceContractTitle } = notifications[0]
const numSummaryLines = 3
const [expanded, setExpanded] = useState(false)
- const [highlighted, setHighlighted] = useState(false)
+ const [highlighted, setHighlighted] = useState(
+ notifications.some((n) => !n.isSeen)
+ )
useEffect(() => {
- if (notifications.some((n) => !n.isSeen)) {
- setHighlighted(true)
- setTimeout(() => {
- setHighlighted(false)
- }, HIGHLIGHT_DURATION)
- }
setNotificationsAsSeen(notifications)
}, [notifications])
@@ -408,27 +487,18 @@ function NotificationGroupItem(props: {
)}
-
+
setExpanded(!expanded)}
- className={'line-clamp-1 cursor-pointer pl-1 sm:pl-0'}
+ className={' flex cursor-pointer truncate pl-1 sm:pl-0'}
>
{sourceContractTitle ? (
-
- {'Activity on '}
-
- {sourceContractTitle}
-
-
+ <>
+
{'Activity on '}
+
+
+
+ >
) : (
'Other activity'
)}
@@ -439,7 +509,13 @@ function NotificationGroupItem(props: {
{' '}
-
+
+ {' '}
{!expanded ? (
<>
{notifications.slice(0, numSummaryLines).map((notification) => {
@@ -466,6 +542,7 @@ function NotificationGroupItem(props: {
notification={notification}
key={notification.id}
justSummary={false}
+ hideTitle={true}
/>
))}
>
@@ -695,7 +772,7 @@ function NotificationLink(props: { notification: Notification }) {
: ''
}
className={
- 'ml-1 font-bold hover:underline hover:decoration-indigo-400 hover:decoration-2'
+ 'ml-1 inline max-w-xs truncate font-bold text-gray-500 hover:underline hover:decoration-indigo-400 hover:decoration-2 sm:max-w-sm'
}
>
{sourceContractTitle || sourceTitle}
@@ -703,11 +780,54 @@ function NotificationLink(props: { notification: Notification }) {
)
}
+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 NotificationItem(props: {
notification: Notification
justSummary?: boolean
+ hideTitle?: boolean
}) {
- const { notification, justSummary } = props
+ const { notification, justSummary, hideTitle } = props
const {
sourceType,
sourceId,
@@ -721,7 +841,6 @@ function NotificationItem(props: {
sourceText,
sourceContractCreatorUsername,
sourceContractSlug,
- sourceSlug,
} = notification
const [defaultNotificationText, setDefaultNotificationText] =
@@ -736,48 +855,12 @@ function NotificationItem(props: {
}
}, [reasonText, sourceText])
- const [highlighted, setHighlighted] = useState(false)
- useEffect(() => {
- if (!notification.isSeen) {
- setHighlighted(true)
- setTimeout(() => {
- setHighlighted(false)
- }, HIGHLIGHT_DURATION)
- }
- }, [notification.isSeen])
+ const [highlighted] = useState(!notification.isSeen)
useEffect(() => {
setNotificationsAsSeen([notification])
}, [notification])
- function getSourceUrl() {
- if (sourceType === 'follow') return `/${sourceUserUsername}`
- if (sourceType === 'group' && sourceSlug) return `${groupPath(sourceSlug)}`
- if (
- sourceContractCreatorUsername &&
- sourceContractSlug &&
- sourceType === 'user'
- )
- return `/${sourceContractCreatorUsername}/${sourceContractSlug}`
- if (sourceContractCreatorUsername && sourceContractSlug)
- return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent(
- sourceId ?? ''
- )}`
- }
-
- function getSourceIdForLinkComponent(sourceId: string) {
- switch (sourceType) {
- case 'answer':
- return `answer-${sourceId}`
- case 'comment':
- return sourceId
- case 'contract':
- return ''
- default:
- return sourceId
- }
- }
-
if (justSummary) {
return (
@@ -793,10 +876,7 @@ function NotificationItem(props: {
{sourceType &&
reason &&
- getReasonForShowingNotification(notification, true).replace(
- ' on',
- ''
- )}
+ getReasonForShowingNotification(notification, true, true)}
-
+
- {sourceType != 'bonus' ? (
-
- ) : (
-
- )}
+
- {sourceType != 'bonus' && sourceUpdateType != 'closed' && (
+ {sourceUpdateType != 'closed' && (
)}
-
- {sourceType && reason && (
-
- {getReasonForShowingNotification(notification, false)}
+ {sourceType && reason && (
+
+
+ {getReasonForShowingNotification(notification, false, true)}
+
+ {!hideTitle && (
-
- )}
-
+ )}
+
+ )}
+ {sourceId &&
+ sourceContractSlug &&
+ sourceContractCreatorUsername ? (
+
+ ) : (
+
+ )}
- {sourceId && sourceContractSlug && sourceContractCreatorUsername ? (
-
- ) : (
-
- )}
@@ -938,9 +1018,11 @@ function NotificationTextLabel(props: {
return (
{formatMoney(parseInt(sourceText))}
)
- } else if (sourceType === 'bonus' && sourceText) {
+ } else if ((sourceType === 'bonus' || sourceType === 'tip') && sourceText) {
return (
- {formatMoney(parseInt(sourceText))}
+
+ {'+' + formatMoney(parseInt(sourceText))}
+
)
}
// return default text
@@ -953,19 +1035,19 @@ function NotificationTextLabel(props: {
function getReasonForShowingNotification(
notification: Notification,
- simple?: boolean
+ simple?: boolean,
+ replaceOn?: boolean
) {
- const { sourceType, sourceUpdateType, sourceText, reason, sourceSlug } =
- notification
+ const { sourceType, sourceUpdateType, reason, sourceSlug } = notification
let reasonText: string
switch (sourceType) {
case 'comment':
if (reason === 'reply_to_users_answer')
- reasonText = !simple ? 'replied to your answer on' : 'replied'
+ reasonText = !simple ? 'replied to you on' : 'replied'
else if (reason === 'tagged_user')
- reasonText = !simple ? 'tagged you in a comment on' : 'tagged you'
+ reasonText = !simple ? 'tagged you on' : 'tagged you'
else if (reason === 'reply_to_users_comment')
- reasonText = !simple ? 'replied to your comment on' : 'replied'
+ 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')
@@ -973,7 +1055,7 @@ function getReasonForShowingNotification(
else if (reason === 'on_contract_with_users_answer')
reasonText = `commented on`
else if (reason === 'on_contract_with_users_shares_in')
- reasonText = `commented`
+ reasonText = `commented on`
else reasonText = `commented on`
break
case 'contract':
@@ -1008,17 +1090,13 @@ 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`
- : ' for unique bettors'
- else reasonText = 'You earned your daily manna'
- break
default:
reasonText = ''
}
- return reasonText
+
+ return (
+
+ {replaceOn ? reasonText.replace(' on', '') : reasonText}
+
+ )
}