+
+
+
+
+
+
+
+
+
\ 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)
|