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 ( +
+ + +
+ {sourceType && reason && ( +
+ + + + + {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, - 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} + + ) }