From a0f62ba1729ebf226f4779fe96f32921b4515f50 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Fri, 19 Aug 2022 11:43:57 -0600 Subject: [PATCH 01/19] Markets emails (#764) * Send out email template for 3 trending markets * Rich text to plaintext descriptions, other ui changes * Lint * Filter for closed markets * Change sign * First order must be closeTime * Send 6 emails, check flag twice * Exclude contracts with trump and president in the name * interesting markets email * sendInterestingMarketsEmail * Change subject line back Co-authored-by: mantikoros --- common/contract-details.ts | 151 ++++++ common/user.ts | 1 + firestore.rules | 2 +- .../email-templates/interesting-markets.html | 476 ++++++++++++++++++ functions/src/emails.ts | 59 +++ functions/src/index.ts | 2 + functions/src/unsubscribe.ts | 4 + functions/src/utils.ts | 6 + functions/src/weekly-markets-emails.ts | 82 +++ web/components/SEO.tsx | 56 +-- .../contract/contract-card-preview.tsx | 44 -- web/components/contract/contract-details.tsx | 26 +- web/components/contract/quick-bet.tsx | 3 +- web/components/feed/feed-items.tsx | 2 +- web/lib/firebase/contracts.ts | 29 +- web/pages/[username]/[contractSlug].tsx | 2 +- .../[contractSlug]/[challengeSlug].tsx | 2 +- 17 files changed, 791 insertions(+), 156 deletions(-) create mode 100644 common/contract-details.ts create mode 100644 functions/src/email-templates/interesting-markets.html create mode 100644 functions/src/weekly-markets-emails.ts delete mode 100644 web/components/contract/contract-card-preview.tsx diff --git a/common/contract-details.ts b/common/contract-details.ts new file mode 100644 index 00000000..02af6359 --- /dev/null +++ b/common/contract-details.ts @@ -0,0 +1,151 @@ +import { Challenge } from './challenge' +import { BinaryContract, Contract } from './contract' +import { getFormattedMappedValue } from './pseudo-numeric' +import { getProbability } from './calculate' +import { richTextToString } from './util/parse' +import { getCpmmProbability } from './calculate-cpmm' +import { getDpmProbability } from './calculate-dpm' +import { formatMoney, formatPercent } from './util/format' + +export function contractMetrics(contract: Contract) { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const dayjs = require('dayjs') + const { createdTime, resolutionTime, isResolved } = contract + + const createdDate = dayjs(createdTime).format('MMM D') + + const resolvedDate = isResolved + ? dayjs(resolutionTime).format('MMM D') + : undefined + + const volumeLabel = `${formatMoney(contract.volume)} bet` + + return { volumeLabel, createdDate, resolvedDate } +} + +// String version of the above, to send to the OpenGraph image generator +export function contractTextDetails(contract: Contract) { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const dayjs = require('dayjs') + const { closeTime, tags } = contract + const { createdDate, resolvedDate, volumeLabel } = contractMetrics(contract) + + const hashtags = tags.map((tag) => `#${tag}`) + + return ( + `${resolvedDate ? `${createdDate} - ${resolvedDate}` : createdDate}` + + (closeTime + ? ` • ${closeTime > Date.now() ? 'Closes' : 'Closed'} ${dayjs( + closeTime + ).format('MMM D, h:mma')}` + : '') + + ` • ${volumeLabel}` + + (hashtags.length > 0 ? ` • ${hashtags.join(' ')}` : '') + ) +} + +export function getBinaryProb(contract: BinaryContract) { + const { pool, resolutionProbability, mechanism } = contract + + return ( + resolutionProbability ?? + (mechanism === 'cpmm-1' + ? getCpmmProbability(pool, contract.p) + : getDpmProbability(contract.totalShares)) + ) +} + +export const getOpenGraphProps = (contract: Contract) => { + const { + resolution, + question, + creatorName, + creatorUsername, + outcomeType, + creatorAvatarUrl, + description: desc, + } = contract + const probPercent = + outcomeType === 'BINARY' + ? formatPercent(getBinaryProb(contract)) + : undefined + + const numericValue = + outcomeType === 'PSEUDO_NUMERIC' + ? getFormattedMappedValue(contract)(getProbability(contract)) + : undefined + + const stringDesc = typeof desc === 'string' ? desc : richTextToString(desc) + + const description = resolution + ? `Resolved ${resolution}. ${stringDesc}` + : probPercent + ? `${probPercent} chance. ${stringDesc}` + : stringDesc + + return { + question, + probability: probPercent, + metadata: contractTextDetails(contract), + creatorName, + creatorUsername, + creatorAvatarUrl, + description, + numericValue, + } +} + +export type OgCardProps = { + question: string + probability?: string + metadata: string + creatorName: string + creatorUsername: string + creatorAvatarUrl?: string + numericValue?: string +} + +export function buildCardUrl(props: OgCardProps, challenge?: Challenge) { + const { + creatorAmount, + acceptances, + acceptorAmount, + creatorOutcome, + acceptorOutcome, + } = challenge || {} + const { userName, userAvatarUrl } = acceptances?.[0] ?? {} + + const probabilityParam = + props.probability === undefined + ? '' + : `&probability=${encodeURIComponent(props.probability ?? '')}` + + const numericValueParam = + props.numericValue === undefined + ? '' + : `&numericValue=${encodeURIComponent(props.numericValue ?? '')}` + + const creatorAvatarUrlParam = + props.creatorAvatarUrl === undefined + ? '' + : `&creatorAvatarUrl=${encodeURIComponent(props.creatorAvatarUrl ?? '')}` + + const challengeUrlParams = challenge + ? `&creatorAmount=${creatorAmount}&creatorOutcome=${creatorOutcome}` + + `&challengerAmount=${acceptorAmount}&challengerOutcome=${acceptorOutcome}` + + `&acceptedName=${userName ?? ''}&acceptedAvatarUrl=${userAvatarUrl ?? ''}` + : '' + + // URL encode each of the props, then add them as query params + return ( + `https://manifold-og-image.vercel.app/m.png` + + `?question=${encodeURIComponent(props.question)}` + + probabilityParam + + numericValueParam + + `&metadata=${encodeURIComponent(props.metadata)}` + + `&creatorName=${encodeURIComponent(props.creatorName)}` + + creatorAvatarUrlParam + + `&creatorUsername=${encodeURIComponent(props.creatorUsername)}` + + challengeUrlParams + ) +} diff --git a/common/user.ts b/common/user.ts index 8ad4c91b..2910c54e 100644 --- a/common/user.ts +++ b/common/user.ts @@ -59,6 +59,7 @@ export type PrivateUser = { unsubscribedFromCommentEmails?: boolean unsubscribedFromAnswerEmails?: boolean unsubscribedFromGenericEmails?: boolean + unsubscribedFromWeeklyTrendingEmails?: boolean manaBonusEmailSent?: boolean initialDeviceToken?: string initialIpAddress?: string diff --git a/firestore.rules b/firestore.rules index 81ab4eed..c0d17dac 100644 --- a/firestore.rules +++ b/firestore.rules @@ -63,7 +63,7 @@ service cloud.firestore { allow read: if userId == request.auth.uid || isAdmin(); allow update: if (userId == request.auth.uid || isAdmin()) && request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'notificationPreferences' ]); + .hasOnly(['apiKey', 'unsubscribedFromResolutionEmails', 'unsubscribedFromCommentEmails', 'unsubscribedFromAnswerEmails', 'notificationPreferences', 'unsubscribedFromWeeklyTrendingEmails' ]); } match /private-users/{userId}/views/{viewId} { diff --git a/functions/src/email-templates/interesting-markets.html b/functions/src/email-templates/interesting-markets.html new file mode 100644 index 00000000..fc067643 --- /dev/null +++ b/functions/src/email-templates/interesting-markets.html @@ -0,0 +1,476 @@ + + + + + Interesting markets on Manifold + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + +
+ + + + + + + +
+ + + + banner logo + + + +
+ +
+ +
+ + +
+ +
+ +
+ + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+

+ Hi {{name}},

+
+
+
+

+ Here is a selection of markets on Manifold you might find + interesting!

+
+
+
+ + {{question1Title}} + +
+ + + + + +
+ + View market + +
+ +
+
+ + {{question2Title}} + +
+ + + + + +
+ + View market + +
+ +
+
+ + {{question3Title}} + +
+ + + + + +
+ + View market + +
+ +
+
+ + {{question4Title}} + +
+ + + + + +
+ + View market + +
+ +
+
+ + {{question5Title}} + +
+ + + + + +
+ + View market + +
+ +
+
+ + {{question6Title}} + +
+ + + + + +
+ + View market + +
+ +
+
+ +
+
+ + +
+ + + + • What are they? - You get a reward for every consecutive day that you place a bet. The - more days you bet in a row, the more you earn! + You get {formatMoney(BETTING_STREAK_BONUS_AMOUNT)} more for each day + of consecutive betting up to {formatMoney(BETTING_STREAK_BONUS_MAX)} + . The more days you bet in a row, the more you earn! • Where can I check my streak? From 634196d8f1e7f7ab069e5dc1469b6b9e2fc20fb3 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Fri, 19 Aug 2022 14:45:04 -0600 Subject: [PATCH 03/19] Slice the popular emails to the top 20 --- functions/src/weekly-markets-emails.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/functions/src/weekly-markets-emails.ts b/functions/src/weekly-markets-emails.ts index 62a06a7f..c75d6617 100644 --- a/functions/src/weekly-markets-emails.ts +++ b/functions/src/weekly-markets-emails.ts @@ -43,13 +43,15 @@ async function sendTrendingMarketsEmailsToAllUsers() { const privateUsersToSendEmailsTo = privateUsers.filter((user) => { return !user.unsubscribedFromWeeklyTrendingEmails }) - const trendingContracts = (await getTrendingContracts()).filter( - (contract) => - !( - contract.question.toLowerCase().includes('trump') && - contract.question.toLowerCase().includes('president') - ) && (contract?.closeTime ?? 0) > Date.now() + DAY_MS - ) + const trendingContracts = (await getTrendingContracts()) + .filter( + (contract) => + !( + contract.question.toLowerCase().includes('trump') && + contract.question.toLowerCase().includes('president') + ) && (contract?.closeTime ?? 0) > Date.now() + DAY_MS + ) + .slice(0, 20) for (const privateUser of privateUsersToSendEmailsTo) { if (!privateUser.email) { log(`No email for ${privateUser.username}`) From 1196ec4375f76266d6f5d8a3ba76a15eb5401864 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Fri, 19 Aug 2022 15:01:53 -0600 Subject: [PATCH 04/19] Send 6 trending emails to all users monday 12pm PT --- functions/src/weekly-markets-emails.ts | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/functions/src/weekly-markets-emails.ts b/functions/src/weekly-markets-emails.ts index c75d6617..19f38be7 100644 --- a/functions/src/weekly-markets-emails.ts +++ b/functions/src/weekly-markets-emails.ts @@ -2,15 +2,15 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' import { Contract } from '../../common/contract' -import { getPrivateUser, getUser, getValues, isProd, log } from './utils' -import { filterDefined } from '../../common/util/array' +import { getAllPrivateUsers, getUser, getValues, log } from './utils' import { sendInterestingMarketsEmail } from './emails' import { createRNG, shuffle } from '../../common/util/random' import { DAY_MS } from '../../common/util/time' export const weeklyMarketsEmails = functions .runWith({ secrets: ['MAILGUN_KEY'] }) - .pubsub.schedule('every 1 minutes') + // every Monday at 12pm PT (UTC -07:00) + .pubsub.schedule('0 19 * * 1') .onRun(async () => { await sendTrendingMarketsEmailsToAllUsers() }) @@ -32,13 +32,8 @@ export async function getTrendingContracts() { } async function sendTrendingMarketsEmailsToAllUsers() { - const numEmailsToSend = 6 - // const privateUsers = await getAllPrivateUsers() - // uses dev ian's private user for testing - const privateUser = await getPrivateUser( - isProd() ? 'AJwLWoo3xue32XIiAVrL5SyR1WB2' : '6hHpzvRG0pMq8PNJs7RZj2qlZGn2' - ) - const privateUsers = filterDefined([privateUser]) + const numContractsToSend = 6 + const privateUsers = await getAllPrivateUsers() // get all users that haven't unsubscribed from weekly emails const privateUsersToSendEmailsTo = privateUsers.filter((user) => { return !user.unsubscribedFromWeeklyTrendingEmails @@ -60,14 +55,14 @@ async function sendTrendingMarketsEmailsToAllUsers() { const contractsAvailableToSend = trendingContracts.filter((contract) => { return !contract.uniqueBettorIds?.includes(privateUser.id) }) - if (contractsAvailableToSend.length < numEmailsToSend) { + if (contractsAvailableToSend.length < numContractsToSend) { log('not enough new, unbet-on contracts to send to user', privateUser.id) continue } // choose random subset of contracts to send to user const contractsToSend = chooseRandomSubset( contractsAvailableToSend, - numEmailsToSend + numContractsToSend ) const user = await getUser(privateUser.id) From 39c312cf9f2c96b52765a78d684a1001efe0beb3 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Fri, 19 Aug 2022 15:19:52 -0600 Subject: [PATCH 05/19] Explicitly pass utc timezone --- functions/src/reset-betting-streaks.ts | 1 + functions/src/weekly-markets-emails.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/functions/src/reset-betting-streaks.ts b/functions/src/reset-betting-streaks.ts index 0600fa56..e1c3af8f 100644 --- a/functions/src/reset-betting-streaks.ts +++ b/functions/src/reset-betting-streaks.ts @@ -9,6 +9,7 @@ const firestore = admin.firestore() export const resetBettingStreaksForUsers = functions.pubsub .schedule(`0 ${BETTING_STREAK_RESET_HOUR} * * *`) + .timeZone('utc') .onRun(async () => { await resetBettingStreaksInternal() }) diff --git a/functions/src/weekly-markets-emails.ts b/functions/src/weekly-markets-emails.ts index 19f38be7..1e43b7dc 100644 --- a/functions/src/weekly-markets-emails.ts +++ b/functions/src/weekly-markets-emails.ts @@ -11,6 +11,7 @@ export const weeklyMarketsEmails = functions .runWith({ secrets: ['MAILGUN_KEY'] }) // every Monday at 12pm PT (UTC -07:00) .pubsub.schedule('0 19 * * 1') + .timeZone('utc') .onRun(async () => { await sendTrendingMarketsEmailsToAllUsers() }) From b67a26ad61cde4c7a104d2ba0e7fb5c468510d9e Mon Sep 17 00:00:00 2001 From: James Grugett Date: Fri, 19 Aug 2022 16:51:52 -0500 Subject: [PATCH 06/19] Don't show bets streak modal on navigate each tab --- web/components/user-page.tsx | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index 62f73bf8..407983fc 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -70,10 +70,25 @@ export function UserPage(props: { user: User }) { useEffect(() => { const claimedMana = router.query['claimed-mana'] === 'yes' - setShowConfetti(claimedMana) const showBettingStreak = router.query['show'] === 'betting-streak' setShowBettingStreakModal(showBettingStreak) - }, [router]) + setShowConfetti(claimedMana || showBettingStreak) + + const query = { ...router.query } + if (query.claimedMana || query.show) { + delete query['claimed-mana'] + delete query['show'] + router.replace( + { + pathname: router.pathname, + query, + }, + undefined, + { shallow: true } + ) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) const profit = user.profitCached.allTime @@ -84,10 +99,9 @@ export function UserPage(props: { user: User }) { description={user.bio ?? ''} url={`/${user.username}`} /> - {showConfetti || - (showBettingStreakModal && ( - - ))} + {showConfetti && ( + + )} Date: Fri, 19 Aug 2022 16:00:40 -0600 Subject: [PATCH 07/19] =?UTF-8?q?=F0=9F=94=A5=F0=9F=94=A5=F0=9F=94=A5?= =?UTF-8?q?=F0=9F=94=A5=F0=9F=94=A5=F0=9F=94=A5=F0=9F=94=A5=F0=9F=94=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/components/nav/nav-bar.tsx | 22 +++++++++++++++------- web/components/nav/profile-menu.tsx | 10 +++++++++- web/components/user-page.tsx | 2 +- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/web/components/nav/nav-bar.tsx b/web/components/nav/nav-bar.tsx index 680b8946..23d2f3c0 100644 --- a/web/components/nav/nav-bar.tsx +++ b/web/components/nav/nav-bar.tsx @@ -19,6 +19,7 @@ import { useIsIframe } from 'web/hooks/use-is-iframe' import { trackCallback } from 'web/lib/service/analytics' import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications' import { PrivateUser } from 'common/user' +import { Row } from 'web/components/layout/row' function getNavigation() { return [ @@ -69,13 +70,20 @@ export function BottomNavBar() { trackingEventName: 'profile', href: `/${user.username}?tab=bets`, icon: () => ( - + + + {user.currentBettingStreak && user.currentBettingStreak > 0 && ( +
+ 🔥{user.currentBettingStreak} +
+ )} +
), }} /> diff --git a/web/components/nav/profile-menu.tsx b/web/components/nav/profile-menu.tsx index 9e869c40..8eeac832 100644 --- a/web/components/nav/profile-menu.tsx +++ b/web/components/nav/profile-menu.tsx @@ -4,6 +4,7 @@ import { User } from 'web/lib/firebase/users' import { formatMoney } from 'common/util/format' import { Avatar } from '../avatar' import { trackCallback } from 'web/lib/service/analytics' +import { Row } from 'web/components/layout/row' export function ProfileSummary(props: { user: User }) { const { user } = props @@ -17,7 +18,14 @@ export function ProfileSummary(props: { user: User }) {
{user.name}
-
{formatMoney(Math.floor(user.balance))}
+ + {formatMoney(Math.floor(user.balance))} + {user.currentBettingStreak && user.currentBettingStreak > 0 && ( +
+ 🔥{user.currentBettingStreak} +
+ )} +
diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index 407983fc..b06b1066 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -124,7 +124,7 @@ export function UserPage(props: { user: User }) { {/* Top right buttons (e.g. edit, follow) */} -
+
{!isCurrentUser && } {isCurrentUser && ( From 0cbc0010c194e745abf0ffe2c803e7741ae4a3d3 Mon Sep 17 00:00:00 2001 From: mantikoros Date: Fri, 19 Aug 2022 17:02:52 -0500 Subject: [PATCH 08/19] schedule emails from onCreateUser; send interesting markets on D1 --- functions/src/create-user.ts | 5 +--- functions/src/emails.ts | 5 ++-- functions/src/index.ts | 2 +- functions/src/on-create-user.ts | 41 +++++++++++++++++++++++++++++++++ 4 files changed, 45 insertions(+), 8 deletions(-) create mode 100644 functions/src/on-create-user.ts diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts index c0b03e23..7156855e 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -16,7 +16,6 @@ import { cleanDisplayName, cleanUsername, } from '../../common/util/clean-username' -import { sendPersonalFollowupEmail, sendWelcomeEmail } from './emails' import { isWhitelisted } from '../../common/envs/constants' import { CATEGORIES_GROUP_SLUG_POSTFIX, @@ -93,10 +92,8 @@ export const createuser = newEndpoint(opts, async (req, auth) => { } await firestore.collection('private-users').doc(auth.uid).create(privateUser) - await addUserToDefaultGroups(user) - await sendWelcomeEmail(user, privateUser) - await sendPersonalFollowupEmail(user, privateUser) + await track(auth.uid, 'create user', { username }, { ip: req.ip }) return { user, privateUser } diff --git a/functions/src/emails.ts b/functions/src/emails.ts index 97ffce10..6768e8ea 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -1,4 +1,3 @@ -import * as dayjs from 'dayjs' import { DOMAIN } from '../../common/envs/constants' import { Answer } from '../../common/answer' @@ -170,7 +169,8 @@ export const sendWelcomeEmail = async ( export const sendPersonalFollowupEmail = async ( user: User, - privateUser: PrivateUser + privateUser: PrivateUser, + sendTime: string ) => { if (!privateUser || !privateUser.email) return @@ -192,7 +192,6 @@ Cofounder of Manifold Markets https://manifold.markets ` - const sendTime = dayjs().add(4, 'hours').toString() await sendTextEmail( privateUser.email, diff --git a/functions/src/index.ts b/functions/src/index.ts index ec1947f1..4d7cf42b 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -5,6 +5,7 @@ import { EndpointDefinition } from './api' admin.initializeApp() // v1 +export * from './on-create-user' export * from './on-create-bet' export * from './on-create-comment-on-contract' export * from './on-view' @@ -28,7 +29,6 @@ export * from './score-contracts' export * from './weekly-markets-emails' export * from './reset-betting-streaks' - // v2 export * from './health' export * from './transact' diff --git a/functions/src/on-create-user.ts b/functions/src/on-create-user.ts new file mode 100644 index 00000000..fd951ab4 --- /dev/null +++ b/functions/src/on-create-user.ts @@ -0,0 +1,41 @@ +import * as functions from 'firebase-functions' +import * as dayjs from 'dayjs' +import * as utc from 'dayjs/plugin/utc' +dayjs.extend(utc) + +import { getPrivateUser } from './utils' +import { User } from 'common/user' +import { + sendInterestingMarketsEmail, + sendPersonalFollowupEmail, + sendWelcomeEmail, +} from './emails' +import { getTrendingContracts } from './weekly-markets-emails' + +export const onCreateUser = functions + .runWith({ secrets: ['MAILGUN_KEY'] }) + .firestore.document('users/{userId}') + .onCreate(async (snapshot) => { + const user = snapshot.data() as User + const privateUser = await getPrivateUser(user.id) + if (!privateUser) return + + await sendWelcomeEmail(user, privateUser) + + const followupSendTime = dayjs().add(4, 'hours').toString() + await sendPersonalFollowupEmail(user, privateUser, followupSendTime) + + // skip email if weekly email is about to go out + const day = dayjs().utc().day() + if (day === 0 || (day === 1 && dayjs().utc().hour() <= 19)) return + + const contracts = await getTrendingContracts() + const marketsSendTime = dayjs().add(24, 'hours').toString() + + await sendInterestingMarketsEmail( + user, + privateUser, + contracts, + marketsSendTime + ) + }) From 03d98a7ad768b87b57a3b26501f21f4759ec0fef Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Fri, 19 Aug 2022 17:16:17 -0600 Subject: [PATCH 09/19] Reset hour to 12am utc --- common/numeric-constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/numeric-constants.ts b/common/numeric-constants.ts index 4d04a2c7..3e5af0d3 100644 --- a/common/numeric-constants.ts +++ b/common/numeric-constants.ts @@ -6,4 +6,4 @@ export const NUMERIC_TEXT_COLOR = 'text-blue-500' export const UNIQUE_BETTOR_BONUS_AMOUNT = 10 export const BETTING_STREAK_BONUS_AMOUNT = 5 export const BETTING_STREAK_BONUS_MAX = 100 -export const BETTING_STREAK_RESET_HOUR = 9 +export const BETTING_STREAK_RESET_HOUR = 0 From 51c843d765a0559e26bae6c620a3c4902fdc8bf2 Mon Sep 17 00:00:00 2001 From: Austin Chen Date: Fri, 19 Aug 2022 16:57:23 -0700 Subject: [PATCH 10/19] Use masonry on contract cards, sorted correctly (#773) * Revert "Revert "Tile contract cards in masonry layout (#761)"" This reverts commit 62728e52b72f7cd4e1a3442022ba1f441052e6ca. * Sort the contracts in the correct masonry order * Fix ordering on single columns * Use react-masonry-css to accomplish masonry view * Improve comment * Remove gridClassName Everything is spaced with m-4, too bad --- functions/package.json | 1 + web/components/contract-search.tsx | 3 --- web/components/contract/contracts-grid.tsx | 25 ++++++++++------------ web/components/editor/market-modal.tsx | 3 --- web/pages/group/[...slugs]/index.tsx | 3 --- yarn.lock | 5 +++++ 6 files changed, 17 insertions(+), 23 deletions(-) diff --git a/functions/package.json b/functions/package.json index 5839b5eb..d6278c25 100644 --- a/functions/package.json +++ b/functions/package.json @@ -39,6 +39,7 @@ "lodash": "4.17.21", "mailgun-js": "0.22.0", "module-alias": "2.2.2", + "react-masonry-css": "1.0.16", "stripe": "8.194.0", "zod": "3.17.2" }, diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index ebcba985..feb0de3b 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -83,7 +83,6 @@ export function ContractSearch(props: { highlightOptions?: ContractHighlightOptions onContractClick?: (contract: Contract) => void hideOrderSelector?: boolean - overrideGridClassName?: string cardHideOptions?: { hideGroupLink?: boolean hideQuickBet?: boolean @@ -99,7 +98,6 @@ export function ContractSearch(props: { defaultFilter, additionalFilter, onContractClick, - overrideGridClassName, hideOrderSelector, cardHideOptions, highlightOptions, @@ -183,7 +181,6 @@ export function ContractSearch(props: { loadMore={performQuery} showTime={showTime} onContractClick={onContractClick} - overrideGridClassName={overrideGridClassName} highlightOptions={highlightOptions} cardHideOptions={cardHideOptions} /> diff --git a/web/components/contract/contracts-grid.tsx b/web/components/contract/contracts-grid.tsx index 05c66d56..0839777c 100644 --- a/web/components/contract/contracts-grid.tsx +++ b/web/components/contract/contracts-grid.tsx @@ -9,6 +9,7 @@ import { useCallback } from 'react' import clsx from 'clsx' import { LoadingIndicator } from '../loading-indicator' import { VisibilityObserver } from '../visibility-observer' +import Masonry from 'react-masonry-css' export type ContractHighlightOptions = { contractIds?: string[] @@ -20,7 +21,6 @@ export function ContractsGrid(props: { loadMore?: () => void showTime?: ShowTime onContractClick?: (contract: Contract) => void - overrideGridClassName?: string cardHideOptions?: { hideQuickBet?: boolean hideGroupLink?: boolean @@ -32,7 +32,6 @@ export function ContractsGrid(props: { showTime, loadMore, onContractClick, - overrideGridClassName, cardHideOptions, highlightOptions, } = props @@ -64,12 +63,11 @@ export function ContractsGrid(props: { return (
-
    {contracts.map((contract) => ( ))} -
+ c.id), diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 2ee9fa49..4e42a0bd 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -607,9 +607,6 @@ function AddContractButton(props: { group: Group; user: User }) { user={user} hideOrderSelector={true} onContractClick={addContractToCurrentGroup} - overrideGridClassName={ - 'flex grid grid-cols-1 sm:grid-cols-2 flex-col gap-3 p-1' - } cardHideOptions={{ hideGroupLink: true, hideQuickBet: true }} additionalFilter={{ excludeContractIds: group.contractIds }} highlightOptions={{ diff --git a/yarn.lock b/yarn.lock index b28b373b..bbc13091 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9947,6 +9947,11 @@ react-loadable-ssr-addon-v5-slorber@^1.0.1: dependencies: "@babel/runtime" "^7.10.3" +react-masonry-css@1.0.16: + version "1.0.16" + resolved "https://registry.yarnpkg.com/react-masonry-css/-/react-masonry-css-1.0.16.tgz#72b28b4ae3484e250534700860597553a10f1a2c" + integrity sha512-KSW0hR2VQmltt/qAa3eXOctQDyOu7+ZBevtKgpNDSzT7k5LA/0XntNa9z9HKCdz3QlxmJHglTZ18e4sX4V8zZQ== + react-motion@^0.5.2: version "0.5.2" resolved "https://registry.yarnpkg.com/react-motion/-/react-motion-0.5.2.tgz#0dd3a69e411316567927917c6626551ba0607316" From c850cfe97f2891390f6f9c79840eb74cfdfa9dbc Mon Sep 17 00:00:00 2001 From: Sinclair Chen Date: Fri, 19 Aug 2022 16:59:42 -0700 Subject: [PATCH 11/19] Revert "Revert "fix firefox visual glitch - single card wrapping"" This reverts commit 63a5241b2ecd153fd80bcca9ac964fc2c05492a1. --- web/components/contract/contracts-grid.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/contract/contracts-grid.tsx b/web/components/contract/contracts-grid.tsx index 0839777c..f7b7eeac 100644 --- a/web/components/contract/contracts-grid.tsx +++ b/web/components/contract/contracts-grid.tsx @@ -80,7 +80,7 @@ export function ContractsGrid(props: { hideQuickBet={hideQuickBet} hideGroupLink={hideGroupLink} className={clsx( - 'mb-4 break-inside-avoid-column', + 'mb-4 break-inside-avoid-column overflow-hidden', // prevent content from wrapping (needs overflow on firefox) contractIds?.includes(contract.id) && highlightClassName )} /> From 6791da0fc860d5a8f110d3b723391809f5d4a961 Mon Sep 17 00:00:00 2001 From: Austin Chen Date: Fri, 19 Aug 2022 17:28:06 -0700 Subject: [PATCH 12/19] Fix "Most Recent Donor" on /charity --- web/pages/charity/index.tsx | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/web/pages/charity/index.tsx b/web/pages/charity/index.tsx index 80003c81..e9014bfb 100644 --- a/web/pages/charity/index.tsx +++ b/web/pages/charity/index.tsx @@ -26,7 +26,9 @@ import { User } from 'common/user' import { SEO } from 'web/components/SEO' export async function getStaticProps() { - const txns = await getAllCharityTxns() + let txns = await getAllCharityTxns() + // Sort by newest txns first + txns = sortBy(txns, 'createdTime').reverse() const totals = mapValues(groupBy(txns, 'toId'), (txns) => sumBy(txns, (txn) => txn.amount) ) @@ -37,7 +39,8 @@ export async function getStaticProps() { ]) const matches = quadraticMatches(txns, totalRaised) const numDonors = uniqBy(txns, (txn) => txn.fromId).length - const mostRecentDonor = await getUser(txns[txns.length - 1].fromId) + const mostRecentDonor = await getUser(txns[0].fromId) + const mostRecentCharity = txns[0].toId return { props: { @@ -47,6 +50,7 @@ export async function getStaticProps() { txns, numDonors, mostRecentDonor, + mostRecentCharity, }, revalidate: 60, } @@ -71,7 +75,7 @@ function DonatedStats(props: { stats: Stat[] }) { {stat.name} -
+
{stat.url ? ( {stat.stat} ) : ( @@ -91,11 +95,21 @@ export default function Charity(props: { txns: Txn[] numDonors: number mostRecentDonor: User + mostRecentCharity: string }) { - const { totalRaised, charities, matches, numDonors, mostRecentDonor } = props + const { + totalRaised, + charities, + matches, + mostRecentCharity, + mostRecentDonor, + } = props const [query, setQuery] = useState('') const debouncedQuery = debounce(setQuery, 50) + const recentCharityName = + charities.find((charity) => charity.id === mostRecentCharity)?.name ?? + 'Nobody' const filterCharities = useMemo( () => @@ -143,15 +157,16 @@ export default function Charity(props: { name: 'Raised by Manifold users', stat: manaToUSD(totalRaised), }, - { - name: 'Number of donors', - stat: `${numDonors}`, - }, { name: 'Most recent donor', stat: mostRecentDonor.name ?? 'Nobody', url: `/${mostRecentDonor.username}`, }, + { + name: 'Most recent donation', + stat: recentCharityName, + url: `/charity/${mostRecentCharity}`, + }, ]} /> From 474304d2842066cf390d755f994f35252ce56557 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sat, 20 Aug 2022 11:45:13 -0500 Subject: [PATCH 13/19] =?UTF-8?q?Revert=20"=F0=9F=94=A5=F0=9F=94=A5?= =?UTF-8?q?=F0=9F=94=A5=F0=9F=94=A5=F0=9F=94=A5=F0=9F=94=A5=F0=9F=94=A5?= =?UTF-8?q?=F0=9F=94=A5"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit fc8487dca07e2c0e2f209f6b7c0aa661fc9271a6. --- web/components/nav/nav-bar.tsx | 22 +++++++--------------- web/components/nav/profile-menu.tsx | 10 +--------- web/components/user-page.tsx | 2 +- 3 files changed, 9 insertions(+), 25 deletions(-) diff --git a/web/components/nav/nav-bar.tsx b/web/components/nav/nav-bar.tsx index 23d2f3c0..680b8946 100644 --- a/web/components/nav/nav-bar.tsx +++ b/web/components/nav/nav-bar.tsx @@ -19,7 +19,6 @@ import { useIsIframe } from 'web/hooks/use-is-iframe' import { trackCallback } from 'web/lib/service/analytics' import { useUnseenPreferredNotifications } from 'web/hooks/use-notifications' import { PrivateUser } from 'common/user' -import { Row } from 'web/components/layout/row' function getNavigation() { return [ @@ -70,20 +69,13 @@ export function BottomNavBar() { trackingEventName: 'profile', href: `/${user.username}?tab=bets`, icon: () => ( - - - {user.currentBettingStreak && user.currentBettingStreak > 0 && ( -
- 🔥{user.currentBettingStreak} -
- )} -
+ ), }} /> diff --git a/web/components/nav/profile-menu.tsx b/web/components/nav/profile-menu.tsx index 8eeac832..9e869c40 100644 --- a/web/components/nav/profile-menu.tsx +++ b/web/components/nav/profile-menu.tsx @@ -4,7 +4,6 @@ import { User } from 'web/lib/firebase/users' import { formatMoney } from 'common/util/format' import { Avatar } from '../avatar' import { trackCallback } from 'web/lib/service/analytics' -import { Row } from 'web/components/layout/row' export function ProfileSummary(props: { user: User }) { const { user } = props @@ -18,14 +17,7 @@ export function ProfileSummary(props: { user: User }) {
{user.name}
- - {formatMoney(Math.floor(user.balance))} - {user.currentBettingStreak && user.currentBettingStreak > 0 && ( -
- 🔥{user.currentBettingStreak} -
- )} -
+
{formatMoney(Math.floor(user.balance))}
diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index b06b1066..407983fc 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -124,7 +124,7 @@ export function UserPage(props: { user: User }) { {/* Top right buttons (e.g. edit, follow) */} -
+
{!isCurrentUser && } {isCurrentUser && ( From 2fef413d88a715b949224908205464eb76112b50 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sat, 20 Aug 2022 13:46:14 -0500 Subject: [PATCH 14/19] Don't show fantasy football in newest sort --- functions/src/scripts/unlist-contracts.ts | 29 +++++++++++++++++++++++ web/components/contract-search.tsx | 4 ++++ 2 files changed, 33 insertions(+) create mode 100644 functions/src/scripts/unlist-contracts.ts diff --git a/functions/src/scripts/unlist-contracts.ts b/functions/src/scripts/unlist-contracts.ts new file mode 100644 index 00000000..12adcedd --- /dev/null +++ b/functions/src/scripts/unlist-contracts.ts @@ -0,0 +1,29 @@ +import * as admin from 'firebase-admin' + +import { initAdmin } from './script-init' +initAdmin() + +import { Contract } from '../../../common/contract' + +const firestore = admin.firestore() + +async function unlistContracts() { + console.log('Updating some contracts to be unlisted') + + const snapshot = await firestore + .collection('contracts') + .where('groupSlugs', 'array-contains', 'fantasy-football-stock-exchange') + .get() + const contracts = snapshot.docs.map((doc) => doc.data() as Contract) + + console.log('Loaded', contracts.length, 'contracts') + + for (const contract of contracts) { + const contractRef = firestore.doc(`contracts/${contract.id}`) + + console.log('Updating', contract.question) + await contractRef.update({ visibility: 'soft-unlisted' }) + } +} + +if (require.main === module) unlistContracts().then(() => process.exit()) diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index feb0de3b..5cb819a9 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -258,6 +258,10 @@ function ContractSearchControls(props: { filter === 'open' ? 'isResolved:false' : '', filter === 'closed' ? 'isResolved:false' : '', filter === 'resolved' ? 'isResolved:true' : '', + + // Newest sort requires public visibility. + sort === 'newest' ? 'visibility:public' : '', + pillFilter && pillFilter !== 'personal' && pillFilter !== 'your-bets' ? `groupLinks.slug:${pillFilter}` : '', From dd6c5dc97afea26cf9cbfb802efa4b7b72276c4f Mon Sep 17 00:00:00 2001 From: mantikoros Date: Sat, 20 Aug 2022 13:47:23 -0500 Subject: [PATCH 15/19] betting streaks copy --- .../profile/betting-streak-modal.tsx | 2 +- web/pages/notifications.tsx | 20 +++++++++++++------ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/web/components/profile/betting-streak-modal.tsx b/web/components/profile/betting-streak-modal.tsx index 8404b89b..eb90f6d9 100644 --- a/web/components/profile/betting-streak-modal.tsx +++ b/web/components/profile/betting-streak-modal.tsx @@ -16,7 +16,7 @@ export function BettingStreakModal(props: {
🔥 - Betting streaks are here! + Daily betting streaks• What are they? diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index c99b226a..9541ee5b 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -377,6 +377,7 @@ function IncomeNotificationItem(props: { function reasonAndLink(simple: boolean) { const { sourceText } = notification let reasonText = '' + if (sourceType === 'bonus' && sourceText) { reasonText = !simple ? `Bonus for ${ @@ -385,23 +386,30 @@ function IncomeNotificationItem(props: { : 'bonus on' } else if (sourceType === 'tip') { reasonText = !simple ? `tipped you on` : `in tips on` - } else if (sourceType === 'betting_streak_bonus' && sourceText) { - reasonText = `for your ${ - parseInt(sourceText) / BETTING_STREAK_BONUS_AMOUNT - }-day` + } else if (sourceType === 'betting_streak_bonus') { + reasonText = 'for your' } + + const bettingStreakText = + sourceType === 'betting_streak_bonus' && + (sourceText + ? `🔥 ${ + parseInt(sourceText) / BETTING_STREAK_BONUS_AMOUNT + } day Betting Streak` + : 'Betting Streak') + return ( <> {reasonText} {sourceType === 'betting_streak_bonus' ? ( simple ? ( - Betting Streak + {bettingStreakText} ) : ( - Betting Streak + {bettingStreakText} ) ) : ( From 09e8993cd4dd9ebd228444c98ab2b21f624a0156 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sat, 20 Aug 2022 14:31:32 -0500 Subject: [PATCH 16/19] Implement visibility option for new markets --- common/contract.ts | 5 +++- common/new-contract.ts | 6 +++-- functions/src/create-market.ts | 16 +++++++++--- functions/src/scripts/unlist-contracts.ts | 2 +- web/components/contract-search.tsx | 5 ++-- web/pages/create.tsx | 30 ++++++++++++++++++----- 6 files changed, 48 insertions(+), 16 deletions(-) diff --git a/common/contract.ts b/common/contract.ts index c414a332..2a8f897a 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -31,7 +31,7 @@ export type Contract = { description: string | JSONContent // More info about what the contract is about tags: string[] lowercaseTags: string[] - visibility: 'public' | 'unlisted' + visibility: visibility createdTime: number // Milliseconds since epoch lastUpdatedTime?: number // Updated on new bet or comment @@ -143,3 +143,6 @@ export const MAX_DESCRIPTION_LENGTH = 16000 export const MAX_TAG_LENGTH = 60 export const CPMM_MIN_POOL_QTY = 0.01 + +export type visibility = 'public' | 'unlisted' +export const VISIBILITIES = ['public', 'unlisted'] as const diff --git a/common/new-contract.ts b/common/new-contract.ts index ad7dc5a2..17b872ab 100644 --- a/common/new-contract.ts +++ b/common/new-contract.ts @@ -9,6 +9,7 @@ import { Numeric, outcomeType, PseudoNumeric, + visibility, } from './contract' import { User } from './user' import { parseTags, richTextToString } from './util/parse' @@ -34,7 +35,8 @@ export function getNewContract( isLogScale: boolean, // for multiple choice - answers: string[] + answers: string[], + visibility: visibility ) { const tags = parseTags( [ @@ -70,7 +72,7 @@ export function getNewContract( description, tags, lowercaseTags, - visibility: 'public', + visibility, isResolved: false, createdTime: Date.now(), closeTime, diff --git a/functions/src/create-market.ts b/functions/src/create-market.ts index 3e9998ed..eb3a19eb 100644 --- a/functions/src/create-market.ts +++ b/functions/src/create-market.ts @@ -10,6 +10,7 @@ import { MultipleChoiceContract, NumericContract, OUTCOME_TYPES, + VISIBILITIES, } from '../../common/contract' import { slugify } from '../../common/util/slugify' import { randomString } from '../../common/util/random' @@ -69,6 +70,7 @@ const bodySchema = z.object({ ), outcomeType: z.enum(OUTCOME_TYPES), groupId: z.string().min(1).max(MAX_ID_LENGTH).optional(), + visibility: z.enum(VISIBILITIES).optional(), }) const binarySchema = z.object({ @@ -90,8 +92,15 @@ const multipleChoiceSchema = z.object({ }) export const createmarket = newEndpoint({}, async (req, auth) => { - const { question, description, tags, closeTime, outcomeType, groupId } = - validate(bodySchema, req.body) + const { + question, + description, + tags, + closeTime, + outcomeType, + groupId, + visibility = 'public', + } = validate(bodySchema, req.body) let min, max, initialProb, isLogScale, answers @@ -196,7 +205,8 @@ export const createmarket = newEndpoint({}, async (req, auth) => { min ?? 0, max ?? 0, isLogScale ?? false, - answers ?? [] + answers ?? [], + visibility ) if (ante) await chargeUser(user.id, ante, true) diff --git a/functions/src/scripts/unlist-contracts.ts b/functions/src/scripts/unlist-contracts.ts index 12adcedd..63307653 100644 --- a/functions/src/scripts/unlist-contracts.ts +++ b/functions/src/scripts/unlist-contracts.ts @@ -22,7 +22,7 @@ async function unlistContracts() { const contractRef = firestore.doc(`contracts/${contract.id}`) console.log('Updating', contract.question) - await contractRef.update({ visibility: 'soft-unlisted' }) + await contractRef.update({ visibility: 'unlisted' }) } } diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index 5cb819a9..1b55fe97 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -255,13 +255,12 @@ function ContractSearchControls(props: { ? additionalFilters : [ ...additionalFilters, + 'visibility:public', + filter === 'open' ? 'isResolved:false' : '', filter === 'closed' ? 'isResolved:false' : '', filter === 'resolved' ? 'isResolved:true' : '', - // Newest sort requires public visibility. - sort === 'newest' ? 'visibility:public' : '', - pillFilter && pillFilter !== 'personal' && pillFilter !== 'your-bets' ? `groupLinks.slug:${pillFilter}` : '', diff --git a/web/pages/create.tsx b/web/pages/create.tsx index ab566c9e..8a9e363b 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -15,6 +15,7 @@ import { MAX_DESCRIPTION_LENGTH, MAX_QUESTION_LENGTH, outcomeType, + visibility, } from 'common/contract' import { formatMoney } from 'common/util/format' import { removeUndefinedProps } from 'common/util/object' @@ -150,6 +151,7 @@ export function NewContract(props: { undefined ) const [showGroupSelector, setShowGroupSelector] = useState(true) + const [visibility, setVisibility] = useState('public') const closeTime = closeDate ? dayjs(`${closeDate}T${closeHoursMinutes}`).valueOf() @@ -234,6 +236,7 @@ export function NewContract(props: { isLogScale, answers, groupId: selectedGroup?.id, + visibility, }) ) track('create market', { @@ -367,17 +370,32 @@ export function NewContract(props: { )} -
- + + setVisibility(choice as visibility)} + choicesMap={{ + Public: 'public', + Unlisted: 'unlisted', + }} + isSubmitting={isSubmitting} />
+ + + +
+ + +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + + + + +
+
+

+ This e-mail has been sent to + {{name}}, + click here to unsubscribe. +

+
+
+
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/functions/src/emails.ts b/functions/src/emails.ts index acab22d8..97ffce10 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -20,6 +20,7 @@ import { sendTemplateEmail, sendTextEmail } from './send-email' import { getPrivateUser, getUser } from './utils' import { getFunctionUrl } from '../../common/api' import { richTextToString } from '../../common/util/parse' +import { buildCardUrl, getOpenGraphProps } from '../../common/contract-details' const UNSUBSCRIBE_ENDPOINT = getFunctionUrl('unsubscribe') @@ -460,3 +461,61 @@ export const sendNewAnswerEmail = async ( { from } ) } + +export const sendInterestingMarketsEmail = async ( + user: User, + privateUser: PrivateUser, + contractsToSend: Contract[], + deliveryTime?: string +) => { + if ( + !privateUser || + !privateUser.email || + privateUser?.unsubscribedFromWeeklyTrendingEmails + ) + return + + const emailType = 'weekly-trending' + const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${privateUser.id}&type=${emailType}` + + const { name } = user + const firstName = name.split(' ')[0] + + await sendTemplateEmail( + privateUser.email, + `${contractsToSend[0].question} & 5 more interesting markets on Manifold`, + 'interesting-markets', + { + name: firstName, + unsubscribeLink: unsubscribeUrl, + + question1Title: contractsToSend[0].question, + question1Link: contractUrl(contractsToSend[0]), + question1ImgSrc: imageSourceUrl(contractsToSend[0]), + question2Title: contractsToSend[1].question, + question2Link: contractUrl(contractsToSend[1]), + question2ImgSrc: imageSourceUrl(contractsToSend[1]), + question3Title: contractsToSend[2].question, + question3Link: contractUrl(contractsToSend[2]), + question3ImgSrc: imageSourceUrl(contractsToSend[2]), + question4Title: contractsToSend[3].question, + question4Link: contractUrl(contractsToSend[3]), + question4ImgSrc: imageSourceUrl(contractsToSend[3]), + question5Title: contractsToSend[4].question, + question5Link: contractUrl(contractsToSend[4]), + question5ImgSrc: imageSourceUrl(contractsToSend[4]), + question6Title: contractsToSend[5].question, + question6Link: contractUrl(contractsToSend[5]), + question6ImgSrc: imageSourceUrl(contractsToSend[5]), + }, + deliveryTime ? { 'o:deliverytime': deliveryTime } : undefined + ) +} + +function contractUrl(contract: Contract) { + return `https://manifold.markets/${contract.creatorUsername}/${contract.slug}` +} + +function imageSourceUrl(contract: Contract) { + return buildCardUrl(getOpenGraphProps(contract)) +} diff --git a/functions/src/index.ts b/functions/src/index.ts index c9f62484..ec1947f1 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -25,8 +25,10 @@ export * from './on-create-comment-on-group' export * from './on-create-txn' export * from './on-delete-group' export * from './score-contracts' +export * from './weekly-markets-emails' export * from './reset-betting-streaks' + // v2 export * from './health' export * from './transact' diff --git a/functions/src/unsubscribe.ts b/functions/src/unsubscribe.ts index fda20e16..4db91539 100644 --- a/functions/src/unsubscribe.ts +++ b/functions/src/unsubscribe.ts @@ -21,6 +21,7 @@ export const unsubscribe: EndpointDefinition = { 'market-comment', 'market-answer', 'generic', + 'weekly-trending', ].includes(type) ) { res.status(400).send('Invalid type parameter.') @@ -49,6 +50,9 @@ export const unsubscribe: EndpointDefinition = { ...(type === 'generic' && { unsubscribedFromGenericEmails: true, }), + ...(type === 'weekly-trending' && { + unsubscribedFromWeeklyTrendingEmails: true, + }), } await firestore.collection('private-users').doc(id).update(update) diff --git a/functions/src/utils.ts b/functions/src/utils.ts index 721f33d0..2d620728 100644 --- a/functions/src/utils.ts +++ b/functions/src/utils.ts @@ -88,6 +88,12 @@ export const getPrivateUser = (userId: string) => { return getDoc('private-users', userId) } +export const getAllPrivateUsers = async () => { + const firestore = admin.firestore() + const users = await firestore.collection('private-users').get() + return users.docs.map((doc) => doc.data() as PrivateUser) +} + export const getUserByUsername = async (username: string) => { const firestore = admin.firestore() const snap = await firestore diff --git a/functions/src/weekly-markets-emails.ts b/functions/src/weekly-markets-emails.ts new file mode 100644 index 00000000..c5805d0b --- /dev/null +++ b/functions/src/weekly-markets-emails.ts @@ -0,0 +1,82 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' + +import { Contract } from '../../common/contract' +import { getPrivateUser, getUser, getValues, isProd, log } from './utils' +import { filterDefined } from '../../common/util/array' +import { sendInterestingMarketsEmail } from './emails' +import { createRNG, shuffle } from '../../common/util/random' +import { DAY_MS } from '../../common/util/time' + +export const weeklyMarketsEmails = functions + .runWith({ secrets: ['MAILGUN_KEY'] }) + .pubsub.schedule('every 1 minutes') + .onRun(async () => { + await sendTrendingMarketsEmailsToAllUsers() + }) + +const firestore = admin.firestore() + +export async function getTrendingContracts() { + return await getValues( + firestore + .collection('contracts') + .where('isResolved', '==', false) + .where('closeTime', '>', Date.now() + DAY_MS) + .where('visibility', '==', 'public') + .orderBy('closeTime', 'asc') + .orderBy('popularityScore', 'desc') + .limit(15) + ) +} + +async function sendTrendingMarketsEmailsToAllUsers() { + const numEmailsToSend = 6 + // const privateUsers = await getAllPrivateUsers() + // uses dev ian's private user for testing + const privateUser = await getPrivateUser( + isProd() ? 'AJwLWoo3xue32XIiAVrL5SyR1WB2' : '6hHpzvRG0pMq8PNJs7RZj2qlZGn2' + ) + const privateUsers = filterDefined([privateUser]) + // get all users that haven't unsubscribed from weekly emails + const privateUsersToSendEmailsTo = privateUsers.filter((user) => { + return !user.unsubscribedFromWeeklyTrendingEmails + }) + const trendingContracts = (await getTrendingContracts()).filter( + (contract) => + !( + contract.question.toLowerCase().includes('trump') && + contract.question.toLowerCase().includes('president') + ) + ) + for (const privateUser of privateUsersToSendEmailsTo) { + if (!privateUser.email) { + log(`No email for ${privateUser.username}`) + continue + } + const contractsAvailableToSend = trendingContracts.filter((contract) => { + return !contract.uniqueBettorIds?.includes(privateUser.id) + }) + if (contractsAvailableToSend.length < numEmailsToSend) { + log('not enough new, unbet-on contracts to send to user', privateUser.id) + continue + } + // choose random subset of contracts to send to user + const contractsToSend = chooseRandomSubset( + contractsAvailableToSend, + numEmailsToSend + ) + + const user = await getUser(privateUser.id) + if (!user) continue + + await sendInterestingMarketsEmail(user, privateUser, contractsToSend) + } +} + +function chooseRandomSubset(contracts: Contract[], count: number) { + const fiveMinutes = 5 * 60 * 1000 + const seed = Math.round(Date.now() / fiveMinutes).toString() + shuffle(contracts, createRNG(seed)) + return contracts.slice(0, count) +} diff --git a/web/components/SEO.tsx b/web/components/SEO.tsx index 08dee31e..2c9327ec 100644 --- a/web/components/SEO.tsx +++ b/web/components/SEO.tsx @@ -1,61 +1,7 @@ import { ReactNode } from 'react' import Head from 'next/head' import { Challenge } from 'common/challenge' - -export type OgCardProps = { - question: string - probability?: string - metadata: string - creatorName: string - creatorUsername: string - creatorAvatarUrl?: string - numericValue?: string -} - -function buildCardUrl(props: OgCardProps, challenge?: Challenge) { - const { - creatorAmount, - acceptances, - acceptorAmount, - creatorOutcome, - acceptorOutcome, - } = challenge || {} - const { userName, userAvatarUrl } = acceptances?.[0] ?? {} - - const probabilityParam = - props.probability === undefined - ? '' - : `&probability=${encodeURIComponent(props.probability ?? '')}` - - const numericValueParam = - props.numericValue === undefined - ? '' - : `&numericValue=${encodeURIComponent(props.numericValue ?? '')}` - - const creatorAvatarUrlParam = - props.creatorAvatarUrl === undefined - ? '' - : `&creatorAvatarUrl=${encodeURIComponent(props.creatorAvatarUrl ?? '')}` - - const challengeUrlParams = challenge - ? `&creatorAmount=${creatorAmount}&creatorOutcome=${creatorOutcome}` + - `&challengerAmount=${acceptorAmount}&challengerOutcome=${acceptorOutcome}` + - `&acceptedName=${userName ?? ''}&acceptedAvatarUrl=${userAvatarUrl ?? ''}` - : '' - - // URL encode each of the props, then add them as query params - return ( - `https://manifold-og-image.vercel.app/m.png` + - `?question=${encodeURIComponent(props.question)}` + - probabilityParam + - numericValueParam + - `&metadata=${encodeURIComponent(props.metadata)}` + - `&creatorName=${encodeURIComponent(props.creatorName)}` + - creatorAvatarUrlParam + - `&creatorUsername=${encodeURIComponent(props.creatorUsername)}` + - challengeUrlParams - ) -} +import { buildCardUrl, OgCardProps } from 'common/contract-details' export function SEO(props: { title: string diff --git a/web/components/contract/contract-card-preview.tsx b/web/components/contract/contract-card-preview.tsx deleted file mode 100644 index 354fe308..00000000 --- a/web/components/contract/contract-card-preview.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { Contract } from 'common/contract' -import { getBinaryProbPercent } from 'web/lib/firebase/contracts' -import { richTextToString } from 'common/util/parse' -import { contractTextDetails } from 'web/components/contract/contract-details' -import { getFormattedMappedValue } from 'common/pseudo-numeric' -import { getProbability } from 'common/calculate' - -export const getOpenGraphProps = (contract: Contract) => { - const { - resolution, - question, - creatorName, - creatorUsername, - outcomeType, - creatorAvatarUrl, - description: desc, - } = contract - const probPercent = - outcomeType === 'BINARY' ? getBinaryProbPercent(contract) : undefined - - const numericValue = - outcomeType === 'PSEUDO_NUMERIC' - ? getFormattedMappedValue(contract)(getProbability(contract)) - : undefined - - const stringDesc = typeof desc === 'string' ? desc : richTextToString(desc) - - const description = resolution - ? `Resolved ${resolution}. ${stringDesc}` - : probPercent - ? `${probPercent} chance. ${stringDesc}` - : stringDesc - - return { - question, - probability: probPercent, - metadata: contractTextDetails(contract), - creatorName, - creatorUsername, - creatorAvatarUrl, - description, - numericValue, - } -} diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index 5a62313f..833b37eb 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -9,11 +9,7 @@ import { import { Row } from '../layout/row' import { formatMoney } from 'common/util/format' import { UserLink } from '../user-page' -import { - Contract, - contractMetrics, - updateContract, -} from 'web/lib/firebase/contracts' +import { Contract, updateContract } from 'web/lib/firebase/contracts' import dayjs from 'dayjs' import { DateTimeTooltip } from '../datetime-tooltip' import { fromNow } from 'web/lib/util/time' @@ -35,6 +31,7 @@ import { SiteLink } from 'web/components/site-link' import { groupPath } from 'web/lib/firebase/groups' import { insertContent } from '../editor/utils' import clsx from 'clsx' +import { contractMetrics } from 'common/contract-details' export type ShowTime = 'resolve-date' | 'close-date' @@ -245,25 +242,6 @@ export function ContractDetails(props: { ) } -// String version of the above, to send to the OpenGraph image generator -export function contractTextDetails(contract: Contract) { - const { closeTime, tags } = contract - const { createdDate, resolvedDate, volumeLabel } = contractMetrics(contract) - - const hashtags = tags.map((tag) => `#${tag}`) - - return ( - `${resolvedDate ? `${createdDate} - ${resolvedDate}` : createdDate}` + - (closeTime - ? ` • ${closeTime > Date.now() ? 'Closes' : 'Closed'} ${dayjs( - closeTime - ).format('MMM D, h:mma')}` - : '') + - ` • ${volumeLabel}` + - (hashtags.length > 0 ? ` • ${hashtags.join(' ')}` : '') - ) -} - function EditableCloseDate(props: { closeTime: number contract: Contract diff --git a/web/components/contract/quick-bet.tsx b/web/components/contract/quick-bet.tsx index 92cee018..7ef371f0 100644 --- a/web/components/contract/quick-bet.tsx +++ b/web/components/contract/quick-bet.tsx @@ -23,7 +23,7 @@ import { useState } from 'react' import toast from 'react-hot-toast' import { useUserContractBets } from 'web/hooks/use-user-bets' import { placeBet } from 'web/lib/firebase/api' -import { getBinaryProb, getBinaryProbPercent } from 'web/lib/firebase/contracts' +import { getBinaryProbPercent } from 'web/lib/firebase/contracts' import TriangleDownFillIcon from 'web/lib/icons/triangle-down-fill-icon' import TriangleFillIcon from 'web/lib/icons/triangle-fill-icon' import { Col } from '../layout/col' @@ -34,6 +34,7 @@ import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm' import { track } from 'web/lib/service/analytics' import { formatNumericProbability } from 'common/pseudo-numeric' import { useUnfilledBets } from 'web/hooks/use-bets' +import { getBinaryProb } from 'common/contract-details' const BET_SIZE = 10 diff --git a/web/components/feed/feed-items.tsx b/web/components/feed/feed-items.tsx index dcd5743b..62673428 100644 --- a/web/components/feed/feed-items.tsx +++ b/web/components/feed/feed-items.tsx @@ -11,7 +11,6 @@ import clsx from 'clsx' import { OutcomeLabel } from '../outcome-label' import { Contract, - contractMetrics, contractPath, tradingAllowed, } from 'web/lib/firebase/contracts' @@ -38,6 +37,7 @@ import { FeedLiquidity } from './feed-liquidity' import { SignUpPrompt } from '../sign-up-prompt' import { User } from 'common/user' import { PlayMoneyDisclaimer } from '../play-money-disclaimer' +import { contractMetrics } from 'common/contract-details' export function FeedItems(props: { contract: Contract diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index 243a453a..1f83372e 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -1,4 +1,3 @@ -import dayjs from 'dayjs' import { collection, deleteDoc, @@ -17,14 +16,13 @@ import { sortBy, sum } from 'lodash' import { coll, getValues, listenForValue, listenForValues } from './utils' import { BinaryContract, Contract } from 'common/contract' -import { getDpmProbability } from 'common/calculate-dpm' import { createRNG, shuffle } from 'common/util/random' -import { getCpmmProbability } from 'common/calculate-cpmm' import { formatMoney, formatPercent } from 'common/util/format' import { DAY_MS } from 'common/util/time' import { Bet } from 'common/bet' import { Comment } from 'common/comment' import { ENV_CONFIG } from 'common/envs/constants' +import { getBinaryProb } from 'common/contract-details' export const contracts = coll('contracts') @@ -49,20 +47,6 @@ export function contractUrl(contract: Contract) { return `https://${ENV_CONFIG.domain}${contractPath(contract)}` } -export function contractMetrics(contract: Contract) { - const { createdTime, resolutionTime, isResolved } = contract - - const createdDate = dayjs(createdTime).format('MMM D') - - const resolvedDate = isResolved - ? dayjs(resolutionTime).format('MMM D') - : undefined - - const volumeLabel = `${formatMoney(contract.volume)} bet` - - return { volumeLabel, createdDate, resolvedDate } -} - export function contractPool(contract: Contract) { return contract.mechanism === 'cpmm-1' ? formatMoney(contract.totalLiquidity) @@ -71,17 +55,6 @@ export function contractPool(contract: Contract) { : 'Empty pool' } -export function getBinaryProb(contract: BinaryContract) { - const { pool, resolutionProbability, mechanism } = contract - - return ( - resolutionProbability ?? - (mechanism === 'cpmm-1' - ? getCpmmProbability(pool, contract.p) - : getDpmProbability(contract.totalShares)) - ) -} - export function getBinaryProbPercent(contract: BinaryContract) { return formatPercent(getBinaryProb(contract)) } diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 41ad5957..c86f9c55 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -36,13 +36,13 @@ import { AlertBox } from 'web/components/alert-box' import { useTracking } from 'web/hooks/use-tracking' import { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns' import { useSaveReferral } from 'web/hooks/use-save-referral' -import { getOpenGraphProps } from 'web/components/contract/contract-card-preview' import { User } from 'common/user' import { ContractComment } from 'common/comment' import { listUsers } from 'web/lib/firebase/users' import { FeedComment } from 'web/components/feed/feed-comments' import { Title } from 'web/components/title' import { FeedBet } from 'web/components/feed/feed-bets' +import { getOpenGraphProps } from 'common/contract-details' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { diff --git a/web/pages/challenges/[username]/[contractSlug]/[challengeSlug].tsx b/web/pages/challenges/[username]/[contractSlug]/[challengeSlug].tsx index 55e78616..f15c5809 100644 --- a/web/pages/challenges/[username]/[contractSlug]/[challengeSlug].tsx +++ b/web/pages/challenges/[username]/[contractSlug]/[challengeSlug].tsx @@ -28,11 +28,11 @@ import { LoadingIndicator } from 'web/components/loading-indicator' import { useWindowSize } from 'web/hooks/use-window-size' import { Bet, listAllBets } from 'web/lib/firebase/bets' import { SEO } from 'web/components/SEO' -import { getOpenGraphProps } from 'web/components/contract/contract-card-preview' import Custom404 from 'web/pages/404' import { useSaveReferral } from 'web/hooks/use-save-referral' import { BinaryContract } from 'common/contract' import { Title } from 'web/components/title' +import { getOpenGraphProps } from 'common/contract-details' export const getStaticProps = fromPropz(getStaticPropz) From 36bfbe8f4240714fc023c7980f39ff1628a1712a Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Fri, 19 Aug 2022 14:37:16 -0600 Subject: [PATCH 02/19] Change betting streak modal, tweak trending email query --- common/numeric-constants.ts | 1 + functions/src/on-create-bet.ts | 3 ++- functions/src/weekly-markets-emails.ts | 9 +++++---- web/components/profile/betting-streak-modal.tsx | 10 ++++++++-- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/common/numeric-constants.ts b/common/numeric-constants.ts index 9d41d54f..4d04a2c7 100644 --- a/common/numeric-constants.ts +++ b/common/numeric-constants.ts @@ -5,4 +5,5 @@ export const NUMERIC_GRAPH_COLOR = '#5fa5f9' export const NUMERIC_TEXT_COLOR = 'text-blue-500' export const UNIQUE_BETTOR_BONUS_AMOUNT = 10 export const BETTING_STREAK_BONUS_AMOUNT = 5 +export const BETTING_STREAK_BONUS_MAX = 100 export const BETTING_STREAK_RESET_HOUR = 9 diff --git a/functions/src/on-create-bet.ts b/functions/src/on-create-bet.ts index c5648293..45adade5 100644 --- a/functions/src/on-create-bet.ts +++ b/functions/src/on-create-bet.ts @@ -14,6 +14,7 @@ import { Contract } from '../../common/contract' import { runTxn, TxnData } from './transact' import { BETTING_STREAK_BONUS_AMOUNT, + BETTING_STREAK_BONUS_MAX, BETTING_STREAK_RESET_HOUR, UNIQUE_BETTOR_BONUS_AMOUNT, } from '../../common/numeric-constants' @@ -86,7 +87,7 @@ const updateBettingStreak = async ( // Send them the bonus times their streak const bonusAmount = Math.min( BETTING_STREAK_BONUS_AMOUNT * newBettingStreak, - 100 + BETTING_STREAK_BONUS_MAX ) const fromUserId = isProd() ? HOUSE_LIQUIDITY_PROVIDER_ID diff --git a/functions/src/weekly-markets-emails.ts b/functions/src/weekly-markets-emails.ts index c5805d0b..62a06a7f 100644 --- a/functions/src/weekly-markets-emails.ts +++ b/functions/src/weekly-markets-emails.ts @@ -22,11 +22,12 @@ export async function getTrendingContracts() { firestore .collection('contracts') .where('isResolved', '==', false) - .where('closeTime', '>', Date.now() + DAY_MS) .where('visibility', '==', 'public') - .orderBy('closeTime', 'asc') + // can't use multiple inequality (/orderBy) operators on different fields, + // so have to filter for closed contracts separately .orderBy('popularityScore', 'desc') - .limit(15) + // might as well go big and do a quick filter for closed ones later + .limit(500) ) } @@ -47,7 +48,7 @@ async function sendTrendingMarketsEmailsToAllUsers() { !( contract.question.toLowerCase().includes('trump') && contract.question.toLowerCase().includes('president') - ) + ) && (contract?.closeTime ?? 0) > Date.now() + DAY_MS ) for (const privateUser of privateUsersToSendEmailsTo) { if (!privateUser.email) { diff --git a/web/components/profile/betting-streak-modal.tsx b/web/components/profile/betting-streak-modal.tsx index 345d38b1..8404b89b 100644 --- a/web/components/profile/betting-streak-modal.tsx +++ b/web/components/profile/betting-streak-modal.tsx @@ -1,5 +1,10 @@ import { Modal } from 'web/components/layout/modal' import { Col } from 'web/components/layout/col' +import { + BETTING_STREAK_BONUS_AMOUNT, + BETTING_STREAK_BONUS_MAX, +} from 'common/numeric-constants' +import { formatMoney } from 'common/util/format' export function BettingStreakModal(props: { isOpen: boolean @@ -15,8 +20,9 @@ export function BettingStreakModal(props: {