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/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/common/numeric-constants.ts b/common/numeric-constants.ts index 9d41d54f..3e5af0d3 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_RESET_HOUR = 9 +export const BETTING_STREAK_BONUS_MAX = 100 +export const BETTING_STREAK_RESET_HOUR = 0 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/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/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/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/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 + +
+ +
+
+ +
+
+ + +
+ + + + - + c.id), 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/components/outcome-label.tsx b/web/components/outcome-label.tsx index 85e171d8..3260018c 100644 --- a/web/components/outcome-label.tsx +++ b/web/components/outcome-label.tsx @@ -90,13 +90,11 @@ export function FreeResponseOutcomeLabel(props: { const chosen = contract.answers?.find((answer) => answer.id === resolution) if (!chosen) return return ( - - - + ) } @@ -165,11 +163,13 @@ export function AnswerLabel(props: { } return ( - - {truncated} - + + + {truncated} + + ) } diff --git a/web/components/profile/betting-streak-modal.tsx b/web/components/profile/betting-streak-modal.tsx index 345d38b1..eb90f6d9 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 @@ -11,12 +16,13 @@ export function BettingStreakModal(props: { 🔥 - Betting streaks are here! + Daily betting streaks• 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? diff --git a/web/components/tooltip.tsx b/web/components/tooltip.tsx index b053c6e7..ef8f5bb8 100644 --- a/web/components/tooltip.tsx +++ b/web/components/tooltip.tsx @@ -79,7 +79,7 @@ export function Tooltip(props: { role="tooltip" ref={floating} style={{ position: strategy, top: y ?? 0, left: x ?? 0 }} - className="z-10 max-w-xs rounded bg-slate-700 px-2 py-1 text-center text-sm text-white" + className="z-10 max-w-xs whitespace-normal rounded bg-slate-700 px-2 py-1 text-center text-sm text-white" {...getFloatingProps()} > {text} diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index 80cc496e..4db09b45 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -72,13 +72,28 @@ 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) + setShowConfetti(claimedMana || showBettingStreak) const showLoansModel = router.query['show'] === 'loans' setShowLoansModal(showLoansModel) - }, [router]) + + 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 @@ -89,10 +104,9 @@ export function UserPage(props: { user: User }) { description={user.bio ?? ''} url={`/${user.username}`} /> - {showConfetti || - (showBettingStreakModal && ( - - ))} + {showConfetti && ( + + )} ('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) 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}`, + }, ]} /> diff --git a/web/pages/create.tsx b/web/pages/create.tsx index ab566c9e..d7422ff1 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,33 @@ 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..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' @@ -20,6 +19,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') @@ -169,7 +169,8 @@ export const sendWelcomeEmail = async ( export const sendPersonalFollowupEmail = async ( user: User, - privateUser: PrivateUser + privateUser: PrivateUser, + sendTime: string ) => { if (!privateUser || !privateUser.email) return @@ -191,7 +192,6 @@ Cofounder of Manifold Markets https://manifold.markets ` - const sendTime = dayjs().add(4, 'hours').toString() await sendTextEmail( privateUser.email, @@ -460,3 +460,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 fb0e63bf..b0ad50fa 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' @@ -26,6 +27,7 @@ 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 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/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 + ) + }) 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/scripts/unlist-contracts.ts b/functions/src/scripts/unlist-contracts.ts new file mode 100644 index 00000000..63307653 --- /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: 'unlisted' }) + } +} + +if (require.main === module) unlistContracts().then(() => process.exit()) 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..1e43b7dc --- /dev/null +++ b/functions/src/weekly-markets-emails.ts @@ -0,0 +1,81 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' + +import { Contract } from '../../common/contract' +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'] }) + // every Monday at 12pm PT (UTC -07:00) + .pubsub.schedule('0 19 * * 1') + .timeZone('utc') + .onRun(async () => { + await sendTrendingMarketsEmailsToAllUsers() + }) + +const firestore = admin.firestore() + +export async function getTrendingContracts() { + return await getValues( + firestore + .collection('contracts') + .where('isResolved', '==', false) + .where('visibility', '==', 'public') + // can't use multiple inequality (/orderBy) operators on different fields, + // so have to filter for closed contracts separately + .orderBy('popularityScore', 'desc') + // might as well go big and do a quick filter for closed ones later + .limit(500) + ) +} + +async function sendTrendingMarketsEmailsToAllUsers() { + 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 + }) + 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}`) + continue + } + const contractsAvailableToSend = trendingContracts.filter((contract) => { + return !contract.uniqueBettorIds?.includes(privateUser.id) + }) + 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, + numContractsToSend + ) + + 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/bets-list.tsx b/web/components/bets-list.tsx index e8d85dba..c3058a45 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -534,9 +534,8 @@ export function ContractBetsTable(props: { contract: Contract bets: Bet[] isYourBets: boolean - className?: string }) { - const { contract, className, isYourBets } = props + const { contract, isYourBets } = props const bets = sortBy( props.bets.filter((b) => !b.isAnte && b.amount !== 0), @@ -568,7 +567,7 @@ export function ContractBetsTable(props: { const unfilledBets = useUnfilledBets(contract.id) ?? [] return ( -
+
{amountRedeemed > 0 && ( <>
@@ -771,7 +770,7 @@ function SellButton(props: { setIsSubmitting(false) }} > -
+
Sell {formatWithCommas(shares)} shares of{' '} {' '} for {formatMoney(saleAmount)}? diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index ebcba985..56bc965d 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} /> @@ -258,9 +255,12 @@ function ContractSearchControls(props: { ? additionalFilters : [ ...additionalFilters, + additionalFilter ? '' : 'visibility:public', + filter === 'open' ? 'isResolved:false' : '', filter === 'closed' ? 'isResolved:false' : '', filter === 'resolved' ? 'isResolved:true' : '', + pillFilter && pillFilter !== 'personal' && pillFilter !== 'your-bets' ? `groupLinks.slug:${pillFilter}` : '', 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-card.tsx b/web/components/contract/contract-card.tsx index c3bf1a31..090020e0 100644 --- a/web/components/contract/contract-card.tsx +++ b/web/components/contract/contract-card.tsx @@ -120,7 +120,7 @@ export function ContractCard(props: { truncate={'long'} /> ) : ( - + ))} {showQuickBet ? ( @@ -230,10 +230,9 @@ export function BinaryResolutionOrChance(props: { function FreeResponseTopAnswer(props: { contract: FreeResponseContract | MultipleChoiceContract - truncate: 'short' | 'long' | 'none' className?: string }) { - const { contract, truncate } = props + const { contract } = props const topAnswer = getTopAnswer(contract) @@ -241,7 +240,7 @@ function FreeResponseTopAnswer(props: { ) : null } 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/contracts-grid.tsx b/web/components/contract/contracts-grid.tsx index 05c66d56..f7b7eeac 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 (