From 71fac10e4433fb33430a9be52727205fa38b9ecf Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Thu, 6 Oct 2022 21:16:23 -0400 Subject: [PATCH] Add notifications for badge awards --- common/badge.ts | 5 +- common/scoring.ts | 2 + functions/src/create-notification.ts | 49 ++++++- functions/src/on-create-bet.ts | 3 + functions/src/on-create-contract.ts | 7 +- functions/src/on-update-contract.ts | 13 +- .../add-new-notification-preference.ts | 2 +- .../contract/contract-leaderboard.tsx | 30 +++-- web/components/notification-settings.tsx | 1 + web/components/profile/badges-modal.tsx | 4 +- web/pages/[username]/[contractSlug].tsx | 6 +- web/pages/notifications.tsx | 127 ++++++++++++++---- web/public/award.svg | 28 ++++ 13 files changed, 223 insertions(+), 54 deletions(-) create mode 100644 web/public/award.svg diff --git a/common/badge.ts b/common/badge.ts index b4c6b975..fa8cfc04 100644 --- a/common/badge.ts +++ b/common/badge.ts @@ -4,13 +4,12 @@ export type Badge = { type: BadgeTypes createdTime: number data: { [key: string]: any } - name: string + name: 'Proven Correct' | 'Streaker' | 'Market Maker' } export type BadgeTypes = 'PROVEN_CORRECT' | 'STREAKER' | 'MARKET_CREATOR' export type ProvenCorrectBadgeData = { - name: 'Proven Correct' type: 'PROVEN_CORRECT' data: { contractSlug: string @@ -22,7 +21,6 @@ export type ProvenCorrectBadgeData = { } export type MarketCreatorBadgeData = { - name: 'Market Maker' type: 'MARKET_CREATOR' data: { totalContractsCreated: number @@ -30,7 +28,6 @@ export type MarketCreatorBadgeData = { } export type StreakerBadgeData = { - name: 'Streaker' type: 'STREAKER' data: { totalBettingStreak: number diff --git a/common/scoring.ts b/common/scoring.ts index fbff1d7d..a8f62631 100644 --- a/common/scoring.ts +++ b/common/scoring.ts @@ -79,6 +79,7 @@ export function scoreCommentorsAndBettors( comments, (c) => c.betId && -profitById[c.betId] )[0]?.id + const topCommentBetId = commentsById[topCommentId]?.betId return { topCommentId, @@ -87,5 +88,6 @@ export function scoreCommentorsAndBettors( profitById, commentsById, betsById, + topCommentBetId, } } diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 9bd73d05..a0134634 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -6,7 +6,12 @@ import { Notification, notification_reason_types, } from '../../common/notification' -import { User } from '../../common/user' +import { + MANIFOLD_AVATAR_URL, + MANIFOLD_USER_NAME, + MANIFOLD_USER_USERNAME, + User, +} from '../../common/user' import { Contract } from '../../common/contract' import { getPrivateUser, getValues } from './utils' import { Comment } from '../../common/comment' @@ -30,6 +35,7 @@ import { import { filterDefined } from '../../common/util/array' import { getNotificationDestinationsForUser } from '../../common/user-notification-preferences' import { ContractFollow } from '../../common/follow' +import { Badge } from 'common/badge' const firestore = admin.firestore() type recipients_to_reason_texts = { @@ -1087,6 +1093,43 @@ export const createBountyNotification = async ( sourceTitle: contract.question, } return await notificationRef.set(removeUndefinedProps(notification)) - - // maybe TODO: send email notification to comment creator +} + +export const createBadgeAwardedNotification = async ( + user: User, + badge: Badge +) => { + const privateUser = await getPrivateUser(user.id) + if (!privateUser) return + const { sendToBrowser } = getNotificationDestinationsForUser( + privateUser, + 'badges_awarded' + ) + if (!sendToBrowser) return + + const notificationRef = firestore + .collection(`/users/${user.id}/notifications`) + .doc() + const notification: Notification = { + id: notificationRef.id, + userId: user.id, + reason: 'badges_awarded', + createdTime: Date.now(), + isSeen: false, + sourceId: badge.type, + sourceType: 'badge', + sourceUpdateType: 'created', + sourceUserName: MANIFOLD_USER_NAME, + sourceUserUsername: MANIFOLD_USER_USERNAME, + sourceUserAvatarUrl: MANIFOLD_AVATAR_URL, + sourceText: `You earned a new ${badge.name} badge!`, + sourceSlug: `/${user.username}?show=badges&badge=${badge.type}`, + sourceTitle: badge.name, + data: { + badge, + }, + } + return await notificationRef.set(removeUndefinedProps(notification)) + + // TODO send email notification } diff --git a/functions/src/on-create-bet.ts b/functions/src/on-create-bet.ts index 9be38ec7..3de3a101 100644 --- a/functions/src/on-create-bet.ts +++ b/functions/src/on-create-bet.ts @@ -12,6 +12,7 @@ import { revalidateStaticProps, } from './utils' import { + createBadgeAwardedNotification, createBetFillNotification, createBettingStreakBonusNotification, createUniqueBettorBonusNotification, @@ -317,6 +318,7 @@ async function handleBettingStreakBadgeAward( if (newBettingStreak in streakerBadgeRarityThresholds) { const badge = { type: 'STREAKER', + name: 'Streaker', data: { totalBettingStreak: newBettingStreak, }, @@ -335,5 +337,6 @@ async function handleBettingStreakBadgeAward( }, }, }) + await createBadgeAwardedNotification(user, badge) } } diff --git a/functions/src/on-create-contract.ts b/functions/src/on-create-contract.ts index 2cbe2dbd..6a21654a 100644 --- a/functions/src/on-create-contract.ts +++ b/functions/src/on-create-contract.ts @@ -1,7 +1,10 @@ import * as functions from 'firebase-functions' import { getUser, getValues } from './utils' -import { createNewContractNotification } from './create-notification' +import { + createBadgeAwardedNotification, + createNewContractNotification, +} from './create-notification' import { Contract } from '../../common/contract' import { parseMentions, richTextToString } from '../../common/util/parse' import { JSONContent } from '@tiptap/core' @@ -49,6 +52,7 @@ async function handleMarketCreatorBadgeAward(contractCreator: User) { if (contracts.length in marketMakerBadgeRarityThresholds) { const badge = { type: 'MARKET_CREATOR', + name: 'Market Maker', data: { totalContractsCreated: contracts.length, }, @@ -72,5 +76,6 @@ async function handleMarketCreatorBadgeAward(contractCreator: User) { }, }, }) + await createBadgeAwardedNotification(contractCreator, badge) } } diff --git a/functions/src/on-update-contract.ts b/functions/src/on-update-contract.ts index 74eb79c1..ccb1c941 100644 --- a/functions/src/on-update-contract.ts +++ b/functions/src/on-update-contract.ts @@ -1,6 +1,9 @@ import * as functions from 'firebase-functions' import { getUser, getValues } from './utils' -import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification' +import { + createBadgeAwardedNotification, + createCommentOrAnswerOrUpdatedContractNotification, +} from './create-notification' import { Contract } from '../../common/contract' import { Bet } from '../../common/bet' import * as admin from 'firebase-admin' @@ -50,18 +53,19 @@ async function handleResolvedContract(contract: Contract) { firestore.collection(`contracts/${contract.id}/comments`) ) - const { topCommentId, profitById, commentsById, betsById } = + const { topCommentId, profitById, commentsById, betsById, topCommentBetId } = scoreCommentorsAndBettors(contract, bets, comments) - if (topCommentId && profitById[topCommentId] > 0) { + if (topCommentBetId && profitById[topCommentBetId] > 0) { // award proven correct badge to user const comment = commentsById[topCommentId] - const bet = betsById[topCommentId] + const bet = betsById[topCommentBetId] const user = await getUser(comment.userId) if (!user) return const newProvenCorrectBadge = { createdTime: Date.now(), type: 'PROVEN_CORRECT', + name: 'Proven Correct', data: { contractSlug: contract.slug, contractCreatorUsername: contract.creatorUsername, @@ -87,6 +91,7 @@ async function handleResolvedContract(contract: Contract) { }, }, }) + await createBadgeAwardedNotification(user, newProvenCorrectBadge) } } diff --git a/functions/src/scripts/add-new-notification-preference.ts b/functions/src/scripts/add-new-notification-preference.ts index 405cea8b..4237c8f2 100644 --- a/functions/src/scripts/add-new-notification-preference.ts +++ b/functions/src/scripts/add-new-notification-preference.ts @@ -17,7 +17,7 @@ async function main() { .update({ notificationPreferences: { ...privateUser.notificationPreferences, - badges_awarded: [], + badges_awarded: ['browser'], }, }) }) diff --git a/web/components/contract/contract-leaderboard.tsx b/web/components/contract/contract-leaderboard.tsx index 51ac009d..b4669156 100644 --- a/web/components/contract/contract-leaderboard.tsx +++ b/web/components/contract/contract-leaderboard.tsx @@ -3,8 +3,7 @@ import { resolvedPayout } from 'common/calculate' import { Contract } from 'common/contract' import { formatMoney } from 'common/util/format' -import { groupBy, mapValues, sumBy, sortBy } from 'lodash' -import { CommentTipMap } from 'web/hooks/use-tip-txns' +import { groupBy, mapValues, sumBy } from 'lodash' import { FeedBet } from '../feed/feed-bets' import { FeedComment } from '../feed/feed-comments' import { Spacer } from '../layout/spacer' @@ -57,29 +56,34 @@ export function ContractTopTrades(props: { contract: Contract bets: Bet[] comments: ContractComment[] - tips: CommentTipMap }) { - const { contract, bets, comments, tips } = props - const { topBetId, topBettor, profitById, betsById } = - scoreCommentorsAndBettors(contract, bets, comments) - - // And also the comment with the highest profit - const topComment = sortBy(comments, (c) => c.betId && -profitById[c.betId])[0] - + const { contract, bets, comments } = props + const { + topBetId, + topBettor, + profitById, + betsById, + topCommentId, + commentsById, + topCommentBetId, + } = scoreCommentorsAndBettors(contract, bets, comments) return (
- {topComment && profitById[topComment.id] > 0 && ( + {topCommentBetId && profitById[topCommentBetId] > 0 && ( <> <div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4"> - <FeedComment contract={contract} comment={topComment} /> + <FeedComment + contract={contract} + comment={commentsById[topCommentId]} + /> </div> <Spacer h={16} /> </> )} {/* If they're the same, only show the comment; otherwise show both */} - {topBettor && topBetId !== topComment?.betId && profitById[topBetId] > 0 && ( + {topBettor && topBetId !== topCommentId && profitById[topBetId] > 0 && ( <> <Title text="💸 Best bet" className="!mt-0" /> <div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4"> diff --git a/web/components/notification-settings.tsx b/web/components/notification-settings.tsx index ad9adbdf..166653e2 100644 --- a/web/components/notification-settings.tsx +++ b/web/components/notification-settings.tsx @@ -131,6 +131,7 @@ export function NotificationSettings(props: { 'betting_streaks', 'referral_bonuses', 'unique_bettors_on_your_contract', + 'badges_awarded', ], } const otherBalances: SectionData = { diff --git a/web/components/profile/badges-modal.tsx b/web/components/profile/badges-modal.tsx index 34e658d5..8fe5ae72 100644 --- a/web/components/profile/badges-modal.tsx +++ b/web/components/profile/badges-modal.tsx @@ -151,7 +151,7 @@ function StreakerBadgeItem(props: { badge: StreakerBadge; rarity: rarities }) { const { badge, rarity } = props const { totalBettingStreak } = badge.data return ( - <Col className={'text-center'}> + <Col className={'cursor-default text-center'}> <Medal rarity={rarity} /> <Tooltip text={`Make predictions ${totalBettingStreak} day @@ -179,7 +179,7 @@ function MarketCreatorBadgeItem(props: { const { badge, rarity } = props const { totalContractsCreated } = badge.data return ( - <Col className={'text-center'}> + <Col className={'cursor-default text-center'}> <Medal rarity={rarity} /> <Tooltip text={`Make ${totalContractsCreated} market${ diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 1de472c5..70026e5e 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -270,7 +270,11 @@ export function ContractPageContent( <> <div className="grid grid-cols-1 sm:grid-cols-2"> <ContractLeaderboard contract={contract} bets={bets} /> - <ContractTopTrades contract={contract} bets={bets} /> + <ContractTopTrades + contract={contract} + bets={bets} + comments={comments} + /> </div> <Spacer h={12} /> </> diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 4bd2d7da..67759989 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -13,11 +13,7 @@ import { Page } from 'web/components/page' import { Title } from 'web/components/title' import { doc, updateDoc } from 'firebase/firestore' import { db } from 'web/lib/firebase/init' -import { - MANIFOLD_AVATAR_URL, - MANIFOLD_USER_USERNAME, - PrivateUser, -} from 'common/user' +import { MANIFOLD_AVATAR_URL, PAST_BETS, PrivateUser } from 'common/user' import clsx from 'clsx' import { RelativeTimestamp } from 'web/components/relative-timestamp' import { Linkify } from 'web/components/linkify' @@ -736,6 +732,24 @@ function NotificationItem(props: { justSummary={justSummary} /> ) + } else if (sourceType === 'badge') { + return ( + <BadgeNotification + notification={notification} + isChildOfGroup={isChildOfGroup} + highlighted={highlighted} + justSummary={justSummary} + /> + ) + } else if (sourceType === 'contract' && sourceUpdateType === 'closed') { + return ( + <MarketClosedNotification + notification={notification} + isChildOfGroup={isChildOfGroup} + highlighted={highlighted} + justSummary={justSummary} + /> + ) } // TODO Add new notification components here @@ -809,9 +823,16 @@ function NotificationFrame(props: { subtitle: string children: React.ReactNode isChildOfGroup?: boolean + showUserName?: boolean }) { - const { notification, isChildOfGroup, highlighted, subtitle, children } = - props + const { + notification, + isChildOfGroup, + highlighted, + subtitle, + children, + showUserName, + } = props const { sourceType, sourceUserName, @@ -822,7 +843,7 @@ function NotificationFrame(props: { sourceUserUsername, sourceText, } = notification - const questionNeedsResolution = sourceUpdateType == 'closed' + const { width } = useWindowSize() const isMobile = (width ?? 0) < 600 return ( @@ -852,18 +873,10 @@ function NotificationFrame(props: { /> <Row className={'items-center text-gray-500 sm:justify-start'}> <Avatar - avatarUrl={ - questionNeedsResolution - ? MANIFOLD_AVATAR_URL - : sourceUserAvatarUrl - } + avatarUrl={sourceUserAvatarUrl} size={'sm'} className={'z-10 mr-2'} - username={ - questionNeedsResolution - ? MANIFOLD_USER_USERNAME - : sourceUserUsername - } + username={sourceUserUsername} /> <div className={'flex w-full flex-row pl-1 sm:pl-0'}> <div @@ -872,12 +885,14 @@ function NotificationFrame(props: { } > <div> - <UserLink - name={sourceUserName || ''} - username={sourceUserUsername || ''} - className={'relative mr-1 flex-shrink-0'} - short={isMobile} - /> + {showUserName && ( + <UserLink + name={sourceUserName || ''} + username={sourceUserUsername || ''} + className={'relative mr-1 flex-shrink-0'} + short={isMobile} + /> + )} {subtitle} {isChildOfGroup ? ( <RelativeTimestamp time={notification.createdTime} /> @@ -965,6 +980,64 @@ function BetFillNotification(props: { </NotificationFrame> ) } +function MarketClosedNotification(props: { + notification: Notification + highlighted: boolean + justSummary: boolean + isChildOfGroup?: boolean +}) { + const { notification, isChildOfGroup, highlighted, justSummary } = props + notification.sourceUserAvatarUrl = MANIFOLD_AVATAR_URL + return ( + <NotificationFrame + notification={notification} + isChildOfGroup={isChildOfGroup} + highlighted={highlighted} + subtitle={'Please resolve'} + > + <Row> + <span> + {`Your market has closed. Please resolve it to pay out ${PAST_BETS}.`} + </span> + </Row> + </NotificationFrame> + ) +} + +function BadgeNotification(props: { + notification: Notification + highlighted: boolean + justSummary: boolean + isChildOfGroup?: boolean +}) { + const { notification, isChildOfGroup, highlighted, justSummary } = props + const { sourceText } = notification + const subtitle = 'You earned a new badge!' + notification.sourceUserAvatarUrl = '/award.svg' + if (justSummary) { + return ( + <NotificationSummaryFrame notification={notification} subtitle={subtitle}> + <Row className={'line-clamp-1'}> + <span>{sourceText} 🎉</span> + </Row> + </NotificationSummaryFrame> + ) + } + + return ( + <NotificationFrame + notification={notification} + isChildOfGroup={isChildOfGroup} + highlighted={highlighted} + subtitle={subtitle} + showUserName={false} + > + <Row> + <span>{sourceText} 🎉</span> + </Row> + </NotificationFrame> + ) +} function ContractResolvedNotification(props: { notification: Notification @@ -1136,6 +1209,11 @@ function getSourceUrl(notification: Notification) { sourceId ?? '', sourceType )}` + else if (sourceSlug) + return `/${sourceSlug}#${getSourceIdForLinkComponent( + sourceId ?? '', + sourceType + )}` } function getSourceIdForLinkComponent( @@ -1235,7 +1313,6 @@ function getReasonForShowingNotification( 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': diff --git a/web/public/award.svg b/web/public/award.svg new file mode 100644 index 00000000..3140c5b2 --- /dev/null +++ b/web/public/award.svg @@ -0,0 +1,28 @@ +<svg id="emoji" viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg"> + <g id="color"> + <polyline fill="#92d3f5" stroke="#92d3f5" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" points="54.9988,4.0221 43,16.0208 36,16.0208 30.9584,10.9792 37.9207,4.0169 54.9988,4.0169"/> + <polyline fill="#ea5a47" stroke="#ea5a47" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" points="23.9831,4.0039 36,16.0208 29,16.0208 16.9675,3.9883 23.9831,3.9883"/> + <polyline fill="#fcea2b" stroke="none" points="28,22.4271 28,17 44,17 44,22.4271"/> + <circle cx="36" cy="45.0208" r="23" fill="#fcea2b" stroke="none" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2"/> + <polygon fill="#f1b31c" stroke="none" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" points="35.9861,28 30.8575,38.4014 19.3815,40.0733 27.6891,48.1652 25.7329,59.5961 35.9958,54.1957 46.2628,59.5885 44.2981,48.159 52.5996,40.061 41.1225,38.3976"/> + </g> + <g id="hair"/> + <g id="skin"/> + <g id="skin-shadow"/> + <g id="line"> + <circle cx="36" cy="45.0208" r="23" fill="none" stroke="#000000" stroke-miterlimit="10" stroke-width="2"/> + <circle cx="36" cy="45.0208" r="23" fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2"/> + <circle cx="36" cy="45.0208" r="23" fill="none" stroke="#000000" stroke-miterlimit="10" stroke-width="2"/> + <line x1="29" x2="29" y1="19" y2="16.0208" fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2"/> + <line x1="43" x2="43" y1="19" y2="16.0208" fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2"/> + <line x1="29" x2="43" y1="16.0208" y2="16.0208" fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2"/> + <line x1="25.9896" x2="16.9675" y1="13.0104" y2="3.9883" fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2"/> + <line x1="31.9896" x2="23.9831" y1="12.0104" y2="4.0039" fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2"/> + <line x1="34" x2="37.9207" y1="8" y2="4.0169" fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2"/> + <line x1="46" x2="54.9988" y1="13" y2="4.0221" fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2"/> + <line x1="16.9675" x2="23.9831" y1="3.9883" y2="3.9883" fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2"/> + <line x1="37.9207" x2="54.9988" y1="4.0169" y2="4.0169" fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2"/> + <circle cx="36" cy="45.0208" r="23" fill="none" stroke="#000000" stroke-miterlimit="10" stroke-width="2"/> + <polygon fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" points="35.9861,28 30.8575,38.4014 19.3815,40.0733 27.6891,48.1652 25.7329,59.5961 35.9958,54.1957 46.2628,59.5885 44.2981,48.159 52.5996,40.061 41.1225,38.3976"/> + </g> +</svg>