diff --git a/common/charity.ts b/common/charity.ts index c18c6ba1..fd5abc36 100644 --- a/common/charity.ts +++ b/common/charity.ts @@ -565,6 +565,30 @@ Improve the lives of the world's most vulnerable people. Reduce the number of easily preventable deaths worldwide. Work towards sustainable, systemic change.`, }, + { + name: 'YIMBY Law', + website: 'https://www.yimbylaw.org/', + photo: 'https://i.imgur.com/zlzp21Z.png', + preview: + 'YIMBY Law works to make housing in California more accessible and affordable, by enforcing state housing laws.', + description: ` + YIMBY Law works to make housing in California more accessible and affordable. Our method is to enforce state housing laws, and some examples are outlined below. We send letters to cities considering zoning or general plan compliant housing developments informing them of their duties under state law, and sue them when they do not comply. + +If you would like to support our work, you can do so by getting involved or by donating.`, + }, + { + name: 'CaRLA', + website: 'https://carlaef.org/', + photo: 'https://i.imgur.com/IsNVTOY.png', + preview: + 'The California Renters Legal Advocacy and Education Fund’s core mission is to make lasting impacts to improve the affordability and accessibility of housing to current and future Californians, especially low- and moderate-income people and communities of color.', + description: ` + The California Renters Legal Advocacy and Education Fund’s core mission is to make lasting impacts to improve the affordability and accessibility of housing to current and future Californians, especially low- and moderate-income people and communities of color. + +CaRLA uses legal advocacy and education to ensure all cities comply with their own zoning and state housing laws and do their part to help solve the state’s housing shortage. + +In addition to housing impact litigation, we provide free legal aid, education and workshops, counseling and advocacy to advocates, homeowners, small developers, and city and state government officials.`, + }, ].map((charity) => { const slug = charity.name.toLowerCase().replace(/\s/g, '-') return { diff --git a/common/comment.ts b/common/comment.ts index 77b211d3..c7f9b855 100644 --- a/common/comment.ts +++ b/common/comment.ts @@ -1,13 +1,11 @@ import type { JSONContent } from '@tiptap/core' +export type AnyCommentType = OnContract | OnGroup + // Currently, comments are created after the bet, not atomically with the bet. // They're uniquely identified by the pair contractId/betId. -export type Comment = { +export type Comment = { id: string - contractId?: string - groupId?: string - betId?: string - answerOutcome?: string replyToCommentId?: string userId: string @@ -20,6 +18,21 @@ export type Comment = { userName: string userUsername: string userAvatarUrl?: string - contractSlug?: string - contractQuestion?: string +} & T + +type OnContract = { + commentType: 'contract' + contractId: string + contractSlug: string + contractQuestion: string + answerOutcome?: string + betId?: string } + +type OnGroup = { + commentType: 'group' + groupId: string +} + +export type ContractComment = Comment +export type GroupComment = Comment 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/notification.ts b/common/notification.ts index fa4cd90a..99f9d852 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -38,6 +38,7 @@ export type notification_source_types = | 'user' | 'bonus' | 'challenge' + | 'betting_streak_bonus' export type notification_source_update_types = | 'created' @@ -66,3 +67,4 @@ export type notification_reason_types = | 'bet_fill' | 'user_joined_from_your_group_invite' | 'challenge_accepted' + | 'betting_streak_incremented' diff --git a/common/numeric-constants.ts b/common/numeric-constants.ts index f399aa5a..3e5af0d3 100644 --- a/common/numeric-constants.ts +++ b/common/numeric-constants.ts @@ -4,3 +4,6 @@ export const NUMERIC_FIXED_VAR = 0.005 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 = 0 diff --git a/common/recommended-contracts.ts b/common/recommended-contracts.ts deleted file mode 100644 index 3a6eca38..00000000 --- a/common/recommended-contracts.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { union, sum, sumBy, sortBy, groupBy, mapValues } from 'lodash' -import { Bet } from './bet' -import { Contract } from './contract' -import { ClickEvent } from './tracking' -import { filterDefined } from './util/array' -import { addObjects } from './util/object' - -export const MAX_FEED_CONTRACTS = 75 - -export const getRecommendedContracts = ( - contractsById: { [contractId: string]: Contract }, - yourBetOnContractIds: string[] -) => { - const contracts = Object.values(contractsById) - const yourContracts = filterDefined( - yourBetOnContractIds.map((contractId) => contractsById[contractId]) - ) - - const yourContractIds = new Set(yourContracts.map((c) => c.id)) - const notYourContracts = contracts.filter((c) => !yourContractIds.has(c.id)) - - const yourWordFrequency = contractsToWordFrequency(yourContracts) - const otherWordFrequency = contractsToWordFrequency(notYourContracts) - const words = union( - Object.keys(yourWordFrequency), - Object.keys(otherWordFrequency) - ) - - const yourWeightedFrequency = Object.fromEntries( - words.map((word) => { - const [yourFreq, otherFreq] = [ - yourWordFrequency[word] ?? 0, - otherWordFrequency[word] ?? 0, - ] - - const score = yourFreq / (yourFreq + otherFreq + 0.0001) - - return [word, score] - }) - ) - - // console.log( - // 'your weighted frequency', - // _.sortBy(_.toPairs(yourWeightedFrequency), ([, freq]) => -freq) - // ) - - const scoredContracts = contracts.map((contract) => { - const wordFrequency = contractToWordFrequency(contract) - - const score = sumBy(Object.keys(wordFrequency), (word) => { - const wordFreq = wordFrequency[word] ?? 0 - const weight = yourWeightedFrequency[word] ?? 0 - return wordFreq * weight - }) - - return { - contract, - score, - } - }) - - return sortBy(scoredContracts, (scored) => -scored.score).map( - (scored) => scored.contract - ) -} - -const contractToText = (contract: Contract) => { - const { description, question, tags, creatorUsername } = contract - return `${creatorUsername} ${question} ${tags.join(' ')} ${description}` -} - -const MAX_CHARS_IN_WORD = 100 - -const getWordsCount = (text: string) => { - const normalizedText = text.replace(/[^a-zA-Z]/g, ' ').toLowerCase() - const words = normalizedText - .split(' ') - .filter((word) => word) - .filter((word) => word.length <= MAX_CHARS_IN_WORD) - - const counts: { [word: string]: number } = {} - for (const word of words) { - if (counts[word]) counts[word]++ - else counts[word] = 1 - } - return counts -} - -const toFrequency = (counts: { [word: string]: number }) => { - const total = sum(Object.values(counts)) - return mapValues(counts, (count) => count / total) -} - -const contractToWordFrequency = (contract: Contract) => - toFrequency(getWordsCount(contractToText(contract))) - -const contractsToWordFrequency = (contracts: Contract[]) => { - const frequencySum = contracts - .map(contractToWordFrequency) - .reduce(addObjects, {}) - - return toFrequency(frequencySum) -} - -export const getWordScores = ( - contracts: Contract[], - contractViewCounts: { [contractId: string]: number }, - clicks: ClickEvent[], - bets: Bet[] -) => { - const contractClicks = groupBy(clicks, (click) => click.contractId) - const contractBets = groupBy(bets, (bet) => bet.contractId) - - const yourContracts = contracts.filter( - (c) => - contractViewCounts[c.id] || contractClicks[c.id] || contractBets[c.id] - ) - const yourTfIdf = calculateContractTfIdf(yourContracts) - - const contractWordScores = mapValues(yourTfIdf, (wordsTfIdf, contractId) => { - const viewCount = contractViewCounts[contractId] ?? 0 - const clickCount = contractClicks[contractId]?.length ?? 0 - const betCount = contractBets[contractId]?.length ?? 0 - - const factor = - -1 * Math.log(viewCount + 1) + - 10 * Math.log(betCount + clickCount / 4 + 1) - - return mapValues(wordsTfIdf, (tfIdf) => tfIdf * factor) - }) - - const wordScores = Object.values(contractWordScores).reduce(addObjects, {}) - const minScore = Math.min(...Object.values(wordScores)) - const maxScore = Math.max(...Object.values(wordScores)) - const normalizedWordScores = mapValues( - wordScores, - (score) => (score - minScore) / (maxScore - minScore) - ) - - // console.log( - // 'your word scores', - // _.sortBy(_.toPairs(normalizedWordScores), ([, score]) => -score).slice(0, 100), - // _.sortBy(_.toPairs(normalizedWordScores), ([, score]) => -score).slice(-100) - // ) - - return normalizedWordScores -} - -export function getContractScore( - contract: Contract, - wordScores: { [word: string]: number } -) { - if (Object.keys(wordScores).length === 0) return 1 - - const wordFrequency = contractToWordFrequency(contract) - const score = sumBy(Object.keys(wordFrequency), (word) => { - const wordFreq = wordFrequency[word] ?? 0 - const weight = wordScores[word] ?? 0 - return wordFreq * weight - }) - - return score -} - -// Caluculate Term Frequency-Inverse Document Frequency (TF-IDF): -// https://medium.datadriveninvestor.com/tf-idf-in-natural-language-processing-8db8ef4a7736 -function calculateContractTfIdf(contracts: Contract[]) { - const contractFreq = contracts.map((c) => contractToWordFrequency(c)) - const contractWords = contractFreq.map((freq) => Object.keys(freq)) - - const wordsCount: { [word: string]: number } = {} - for (const words of contractWords) { - for (const word of words) { - wordsCount[word] = (wordsCount[word] ?? 0) + 1 - } - } - - const wordIdf = mapValues(wordsCount, (count) => - Math.log(contracts.length / count) - ) - const contractWordsTfIdf = contractFreq.map((wordFreq) => - mapValues(wordFreq, (freq, word) => freq * wordIdf[word]) - ) - return Object.fromEntries( - contracts.map((c, i) => [c.id, contractWordsTfIdf[i]]) - ) -} diff --git a/common/txn.ts b/common/txn.ts index 701b67fe..00b19570 100644 --- a/common/txn.ts +++ b/common/txn.ts @@ -16,7 +16,13 @@ export type Txn = { amount: number token: 'M$' // | 'USD' | MarketOutcome - category: 'CHARITY' | 'MANALINK' | 'TIP' | 'REFERRAL' | 'UNIQUE_BETTOR_BONUS' + category: + | 'CHARITY' + | 'MANALINK' + | 'TIP' + | 'REFERRAL' + | 'UNIQUE_BETTOR_BONUS' + | 'BETTING_STREAK_BONUS' // Any extra data data?: { [key: string]: any } @@ -57,7 +63,7 @@ type Referral = { type Bonus = { fromType: 'BANK' toType: 'USER' - category: 'UNIQUE_BETTOR_BONUS' + category: 'UNIQUE_BETTOR_BONUS' | 'BETTING_STREAK_BONUS' } export type DonationTxn = Txn & Donation diff --git a/common/user.ts b/common/user.ts index 2aeb7122..2910c54e 100644 --- a/common/user.ts +++ b/common/user.ts @@ -41,6 +41,8 @@ export type User = { referredByGroupId?: string lastPingTime?: number shouldShowWelcome?: boolean + lastBetTime?: number + currentBettingStreak?: number } export const STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 1000 @@ -57,6 +59,7 @@ export type PrivateUser = { unsubscribedFromCommentEmails?: boolean unsubscribedFromAnswerEmails?: boolean unsubscribedFromGenericEmails?: boolean + unsubscribedFromWeeklyTrendingEmails?: boolean manaBonusEmailSent?: boolean initialDeviceToken?: string initialIpAddress?: string diff --git a/docs/docs/awesome-manifold.md b/docs/docs/awesome-manifold.md index 0871be52..458b81ee 100644 --- a/docs/docs/awesome-manifold.md +++ b/docs/docs/awesome-manifold.md @@ -18,6 +18,7 @@ A list of community-created projects built on, or related to, Manifold Markets. - [manifold-markets-python](https://github.com/vluzko/manifold-markets-python) - Python tools for working with Manifold Markets (including various accuracy metrics) - [ManifoldMarketManager](https://github.com/gappleto97/ManifoldMarketManager) - Python script and library to automatically manage markets - [manifeed](https://github.com/joy-void-joy/manifeed) - Tool that creates an RSS feed for new Manifold markets +- [manifold-sdk](https://github.com/keriwarr/manifold-sdk) - TypeScript/JavaScript client for the Manifold API ## Bots 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/accept-challenge.ts b/functions/src/accept-challenge.ts index eae6ab55..a96a4027 100644 --- a/functions/src/accept-challenge.ts +++ b/functions/src/accept-challenge.ts @@ -11,6 +11,7 @@ import { CandidateBet } from '../../common/new-bet' import { createChallengeAcceptedNotification } from './create-notification' import { noFees } from '../../common/fees' import { formatMoney, formatPercent } from '../../common/util/format' +import { redeemShares } from './redeem-shares' const bodySchema = z.object({ contractId: z.string(), @@ -163,5 +164,7 @@ export const acceptchallenge = newEndpoint({}, async (req, auth) => { return yourNewBetDoc }) + await redeemShares(auth.uid, contractId) + return { betId: result.id } }) diff --git a/functions/src/api.ts b/functions/src/api.ts index e9a488c2..7440f16a 100644 --- a/functions/src/api.ts +++ b/functions/src/api.ts @@ -23,13 +23,8 @@ type JwtCredentials = { kind: 'jwt'; data: admin.auth.DecodedIdToken } type KeyCredentials = { kind: 'key'; data: string } type Credentials = JwtCredentials | KeyCredentials -const auth = admin.auth() -const firestore = admin.firestore() -const privateUsers = firestore.collection( - 'private-users' -) as admin.firestore.CollectionReference - export const parseCredentials = async (req: Request): Promise => { + const auth = admin.auth() const authHeader = req.get('Authorization') if (!authHeader) { throw new APIError(403, 'Missing Authorization header.') @@ -57,6 +52,8 @@ export const parseCredentials = async (req: Request): Promise => { } export const lookupUser = async (creds: Credentials): Promise => { + const firestore = admin.firestore() + const privateUsers = firestore.collection('private-users') switch (creds.kind) { case 'jwt': { if (typeof creds.data.user_id !== 'string') { @@ -70,7 +67,7 @@ export const lookupUser = async (creds: Credentials): Promise => { if (privateUserQ.empty) { throw new APIError(403, `No private user exists with API key ${key}.`) } - const privateUser = privateUserQ.docs[0].data() + const privateUser = privateUserQ.docs[0].data() as PrivateUser return { uid: privateUser.id, creds: { privateUser, ...creds } } } default: diff --git a/functions/src/create-group.ts b/functions/src/create-group.ts index a9626916..71c6bd64 100644 --- a/functions/src/create-group.ts +++ b/functions/src/create-group.ts @@ -21,6 +21,7 @@ const bodySchema = z.object({ }) export const creategroup = newEndpoint({}, async (req, auth) => { + const firestore = admin.firestore() const { name, about, memberIds, anyoneCanJoin } = validate( bodySchema, req.body @@ -67,7 +68,7 @@ export const creategroup = newEndpoint({}, async (req, auth) => { return { status: 'success', group: group } }) -const getSlug = async (name: string) => { +export const getSlug = async (name: string) => { const proposedSlug = slugify(name) const preexistingGroup = await getGroupFromSlug(proposedSlug) @@ -75,9 +76,8 @@ const getSlug = async (name: string) => { return preexistingGroup ? proposedSlug + '-' + randomString() : proposedSlug } -const firestore = admin.firestore() - export async function getGroupFromSlug(slug: string) { + const firestore = admin.firestore() const snap = await firestore .collection('groups') .where('slug', '==', slug) diff --git a/functions/src/create-contract.ts b/functions/src/create-market.ts similarity index 97% rename from functions/src/create-contract.ts rename to functions/src/create-market.ts index cef0dd48..5b0d1daf 100644 --- a/functions/src/create-contract.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' @@ -67,6 +68,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({ @@ -88,8 +90,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 @@ -194,7 +203,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-notification.ts b/functions/src/create-notification.ts index 51b884ad..90250e73 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -504,3 +504,38 @@ export const createChallengeAcceptedNotification = async ( } return await notificationRef.set(removeUndefinedProps(notification)) } + +export const createBettingStreakBonusNotification = async ( + user: User, + txnId: string, + bet: Bet, + contract: Contract, + amount: number, + idempotencyKey: string +) => { + const notificationRef = firestore + .collection(`/users/${user.id}/notifications`) + .doc(idempotencyKey) + const notification: Notification = { + id: idempotencyKey, + userId: user.id, + reason: 'betting_streak_incremented', + createdTime: Date.now(), + isSeen: false, + sourceId: txnId, + sourceType: 'betting_streak_bonus', + sourceUpdateType: 'created', + sourceUserName: user.name, + sourceUserUsername: user.username, + sourceUserAvatarUrl: user.avatarUrl, + sourceText: amount.toString(), + sourceSlug: `/${contract.creatorUsername}/${contract.slug}/bets/${bet.id}`, + sourceTitle: 'Betting Streak Bonus', + // Perhaps not necessary, but just in case + sourceContractSlug: contract.slug, + sourceContractId: contract.id, + sourceContractTitle: contract.question, + sourceContractCreatorUsername: contract.creatorUsername, + } + return await notificationRef.set(removeUndefinedProps(notification)) +} 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/creating-market.html b/functions/src/email-templates/creating-market.html index 674a30ed..a61e8d65 100644 --- a/functions/src/email-templates/creating-market.html +++ b/functions/src/email-templates/creating-market.html @@ -103,94 +103,28 @@ -
- -
+
- - - - - - -
- -
- - - - - - -
- - - - - - -
- -
-
-
- -
-
- -
- - - - + + + + + + + +
+
+ + banner logo + +
- -
+
- - - - - - - + + + + + + + +
-
-

- Hi {{name}},

-
-
+
+
+

+ Hi {{name}},

+
+
-
-

- Congrats on creating your first market on Manifold! -

+ ">Did you know you create your own prediction market on Manifold for + any question you care about? +

-

- + The following is a short guide to creating markets. -

-

-   -

-

- Whether it's current events like Musk buying + Twitter or 2024 + elections or personal matters + like book + recommendations or losing + weight, + Manifold can help you find the answer. +

+

+ The following is a + short guide to creating markets. +

+ + + + + +
+ + Create a market + +
+ +

+   +

+

+ What makes a good market? -

-
    -
  • - Interesting - topic. Manifold gives - creators M$10 for - each unique trader that bets on your - market, so it pays to ask a question people are interested in! -
  • +

    +
      +
    • + Interesting + topic. Manifold gives + creators M$10 for + each unique trader that bets on your + market, so it pays to ask a question people are interested in! +
    • -
    • - + Clear resolution criteria. Any ambiguities or edge cases in your description - will drive traders away from your markets. -
    • + will drive traders away from your markets. + -
    • - + Detailed description. Include images/videos/tweets and any context or - background - information that could be useful to people who - are interested in learning more that are - uneducated on the subject. -
    • -
    • - + Add it to a group. Groups are the - primary way users filter for relevant markets. - Also, consider making your own groups and - inviting friends/interested communities to - them from other sites! -
    • -
    • - Part of a group. Groups are the + primary way users filter for relevant markets. + Also, consider making your own groups and + inviting friends/interested communities to + them from other sites! +
    • +
    • + Share it on social media. You'll earn the Sharing it on social media. You'll earn the M$500 - referral bonus if you get new users to sign up! -
    • -
    -

    -   -

    -

    - Examples of markets you should - emulate!  -

    - -

    -   -

    -

    - +   +

    + +

    + Why not - - - - create another marketcreate a market - while it is still fresh on your mind? -

    -

    - +   +

    +

    + Thanks for reading! -

    -

    - David from Manifold -

    -
+

+ +
+
+ +
+
+ +
+ +
+ + + +
+ +
+ + + + 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 + +
+ +
+
+ +
+
+ + +
+ + + + - @@ -175,9 +168,9 @@
+ + +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + + + + +
+
+

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

+
+
+
+
+ +
+
+ + + + + \ No newline at end of file diff --git a/functions/src/email-templates/welcome.html b/functions/src/email-templates/welcome.html index 0ffafbd5..366709e3 100644 --- a/functions/src/email-templates/welcome.html +++ b/functions/src/email-templates/welcome.html @@ -107,19 +107,12 @@ width="100%">
- - - - - - -
+
+ + banner logo +
- @@ -225,22 +218,12 @@ style="color:#55575d;font-family:Arial;font-size:18px;">Join our Discord chat - -

 

-

Cheers, -

-

David - from Manifold

-

 

- + +
Bet
+ + + {user && ( + + )} + + +
+ {error && ( +
+ {error} {tooFewFunds && `(${formatMoney(user?.balance ?? 0)})`} +
+ )} + + ) +} 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/button.tsx b/web/components/button.tsx index 57b2add9..843f74ca 100644 --- a/web/components/button.tsx +++ b/web/components/button.tsx @@ -1,20 +1,23 @@ import { ReactNode } from 'react' import clsx from 'clsx' +export type SizeType = '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' +export type ColorType = + | 'green' + | 'red' + | 'blue' + | 'indigo' + | 'yellow' + | 'gray' + | 'gradient' + | 'gray-white' + export function Button(props: { className?: string onClick?: () => void children?: ReactNode - size?: '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' - color?: - | 'green' - | 'red' - | 'blue' - | 'indigo' - | 'yellow' - | 'gray' - | 'gradient' - | 'gray-white' + size?: SizeType + color?: ColorType type?: 'button' | 'reset' | 'submit' disabled?: boolean }) { diff --git a/web/components/challenges/create-challenge-modal.tsx b/web/components/challenges/create-challenge-modal.tsx index e93ec314..b1ac7704 100644 --- a/web/components/challenges/create-challenge-modal.tsx +++ b/web/components/challenges/create-challenge-modal.tsx @@ -11,7 +11,7 @@ import { User } from 'common/user' import { Modal } from 'web/components/layout/modal' import { Button } from '../button' import { createChallenge, getChallengeUrl } from 'web/lib/firebase/challenges' -import { BinaryContract } from 'common/contract' +import { BinaryContract, MAX_QUESTION_LENGTH } from 'common/contract' import { SiteLink } from 'web/components/site-link' import { formatMoney } from 'common/util/format' import { NoLabel, YesLabel } from '../outcome-label' @@ -19,24 +19,32 @@ import { QRCode } from '../qr-code' import { copyToClipboard } from 'web/lib/util/copy' import { AmountInput } from '../amount-input' import { getProbability } from 'common/calculate' +import { createMarket } from 'web/lib/firebase/api' +import { removeUndefinedProps } from 'common/util/object' +import { FIXED_ANTE } from 'common/antes' +import Textarea from 'react-expanding-textarea' +import { useTextEditor } from 'web/components/editor' +import { LoadingIndicator } from 'web/components/loading-indicator' import { track } from 'web/lib/service/analytics' type challengeInfo = { amount: number expiresTime: number | null - message: string outcome: 'YES' | 'NO' | number acceptorAmount: number + question: string } export function CreateChallengeModal(props: { user: User | null | undefined - contract: BinaryContract isOpen: boolean setOpen: (open: boolean) => void + contract?: BinaryContract }) { const { user, contract, isOpen, setOpen } = props const [challengeSlug, setChallengeSlug] = useState('') + const [loading, setLoading] = useState(false) + const { editor } = useTextEditor({ placeholder: '' }) return ( @@ -46,24 +54,42 @@ export function CreateChallengeModal(props: { { - const challenge = await createChallenge({ - creator: user, - creatorAmount: newChallenge.amount, - expiresTime: newChallenge.expiresTime, - message: newChallenge.message, - acceptorAmount: newChallenge.acceptorAmount, - outcome: newChallenge.outcome, - contract: contract, - }) - if (challenge) { - setChallengeSlug(getChallengeUrl(challenge)) - track('challenge created', { - creator: user.username, - amount: newChallenge.amount, - contractId: contract.id, + setLoading(true) + try { + const challengeContract = contract + ? contract + : await createMarket( + removeUndefinedProps({ + question: newChallenge.question, + outcomeType: 'BINARY', + initialProb: 50, + description: editor?.getJSON(), + ante: FIXED_ANTE, + closeTime: dayjs().add(30, 'day').valueOf(), + }) + ) + const challenge = await createChallenge({ + creator: user, + creatorAmount: newChallenge.amount, + expiresTime: newChallenge.expiresTime, + acceptorAmount: newChallenge.acceptorAmount, + outcome: newChallenge.outcome, + contract: challengeContract as BinaryContract, }) + if (challenge) { + setChallengeSlug(getChallengeUrl(challenge)) + track('challenge created', { + creator: user.username, + amount: newChallenge.amount, + contractId: challengeContract.id, + }) + } + } catch (e) { + console.error("couldn't create market/challenge:", e) } + setLoading(false) }} challengeSlug={challengeSlug} /> @@ -75,25 +101,24 @@ export function CreateChallengeModal(props: { function CreateChallengeForm(props: { user: User - contract: BinaryContract onCreate: (m: challengeInfo) => Promise challengeSlug: string + loading: boolean + contract?: BinaryContract }) { - const { user, onCreate, contract, challengeSlug } = props + const { user, onCreate, contract, challengeSlug, loading } = props const [isCreating, setIsCreating] = useState(false) const [finishedCreating, setFinishedCreating] = useState(false) const [error, setError] = useState('') const [editingAcceptorAmount, setEditingAcceptorAmount] = useState(false) const defaultExpire = 'week' - const defaultMessage = `${user.name} is challenging you to a bet! Do you think ${contract.question}` - const [challengeInfo, setChallengeInfo] = useState({ expiresTime: dayjs().add(2, defaultExpire).valueOf(), outcome: 'YES', amount: 100, acceptorAmount: 100, - message: defaultMessage, + question: contract ? contract.question : '', }) useEffect(() => { setError('') @@ -106,7 +131,15 @@ function CreateChallengeForm(props: { onSubmit={(e) => { e.preventDefault() if (user.balance < challengeInfo.amount) { - setError('You do not have enough mana to create this challenge') + setError("You don't have enough mana to create this challenge") + return + } + if (!contract && user.balance < FIXED_ANTE + challengeInfo.amount) { + setError( + `You don't have enough mana to create this challenge and market. You need ${formatMoney( + FIXED_ANTE + challengeInfo.amount + )}` + ) return } setIsCreating(true) @@ -118,7 +151,23 @@ function CreateChallengeForm(props: {
Challenge a friend to bet on{' '} - {contract.question} + {contract ? ( + {contract.question} + ) : ( +
+ + style="padding: 12px 16px; border: 1px solid #4337c9;border-radius: 16px;font-family: Helvetica, Arial, sans-serif;font-size: 24px; color: #ffffff;text-decoration: none;font-weight:semibold;display: inline-block;"> Explore markets
+ style="font-size:0px;padding:15px 25px 0px 25px;padding-top:0px;padding-right:25px;padding-bottom:0px;padding-left:25px;word-break:break-word;">

{ if (!privateUser || !privateUser.email) return @@ -191,8 +191,6 @@ Cofounder of Manifold Markets https://manifold.markets ` - const sendTime = dayjs().add(4, 'hours').toString() - await sendTextEmail( privateUser.email, 'How are you finding Manifold?', @@ -238,7 +236,8 @@ export const sendOneWeekBonusEmail = async ( export const sendCreatorGuideEmail = async ( user: User, - privateUser: PrivateUser + privateUser: PrivateUser, + sendTime: string ) => { if ( !privateUser || @@ -255,7 +254,7 @@ export const sendCreatorGuideEmail = async ( return await sendTemplateEmail( privateUser.email, - 'Market creation guide', + 'Create your own prediction market', 'creating-market', { name: firstName, @@ -263,6 +262,7 @@ export const sendCreatorGuideEmail = async ( }, { from: 'David from Manifold ', + 'o:deliverytime': sendTime, } ) } @@ -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 07b37648..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' @@ -25,6 +26,8 @@ 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' @@ -37,7 +40,7 @@ export * from './cancel-bet' export * from './sell-bet' export * from './sell-shares' export * from './claim-manalink' -export * from './create-contract' +export * from './create-market' export * from './add-liquidity' export * from './withdraw-liquidity' export * from './create-group' @@ -56,7 +59,7 @@ import { cancelbet } from './cancel-bet' import { sellbet } from './sell-bet' import { sellshares } from './sell-shares' import { claimmanalink } from './claim-manalink' -import { createmarket } from './create-contract' +import { createmarket } from './create-market' import { addliquidity } from './add-liquidity' import { withdrawliquidity } from './withdraw-liquidity' import { creategroup } from './create-group' diff --git a/functions/src/on-create-bet.ts b/functions/src/on-create-bet.ts index d33e71dd..45adade5 100644 --- a/functions/src/on-create-bet.ts +++ b/functions/src/on-create-bet.ts @@ -3,15 +3,21 @@ import * as admin from 'firebase-admin' import { keyBy, uniq } from 'lodash' import { Bet, LimitBet } from '../../common/bet' -import { getContract, getUser, getValues, isProd, log } from './utils' +import { getUser, getValues, isProd, log } from './utils' import { createBetFillNotification, + createBettingStreakBonusNotification, createNotification, } from './create-notification' import { filterDefined } from '../../common/util/array' import { Contract } from '../../common/contract' import { runTxn, TxnData } from './transact' -import { UNIQUE_BETTOR_BONUS_AMOUNT } from '../../common/numeric-constants' +import { + BETTING_STREAK_BONUS_AMOUNT, + BETTING_STREAK_BONUS_MAX, + BETTING_STREAK_RESET_HOUR, + UNIQUE_BETTOR_BONUS_AMOUNT, +} from '../../common/numeric-constants' import { DEV_HOUSE_LIQUIDITY_PROVIDER_ID, HOUSE_LIQUIDITY_PROVIDER_ID, @@ -38,37 +44,99 @@ export const onCreateBet = functions.firestore .doc(contractId) .update({ lastBetTime, lastUpdatedTime: Date.now() }) - await notifyFills(bet, contractId, eventId) - await updateUniqueBettorsAndGiveCreatorBonus( - contractId, - eventId, - bet.userId - ) + const userContractSnap = await firestore + .collection(`contracts`) + .doc(contractId) + .get() + const contract = userContractSnap.data() as Contract + + if (!contract) { + log(`Could not find contract ${contractId}`) + return + } + await updateUniqueBettorsAndGiveCreatorBonus(contract, eventId, bet.userId) + + const bettor = await getUser(bet.userId) + if (!bettor) return + + await notifyFills(bet, contract, eventId, bettor) + await updateBettingStreak(bettor, bet, contract, eventId) + + await firestore.collection('users').doc(bettor.id).update({ lastBetTime }) }) +const updateBettingStreak = async ( + user: User, + bet: Bet, + contract: Contract, + eventId: string +) => { + const betStreakResetTime = getTodaysBettingStreakResetTime() + const lastBetTime = user?.lastBetTime ?? 0 + + // If they've already bet after the reset time, or if we haven't hit the reset time yet + if (lastBetTime > betStreakResetTime || bet.createdTime < betStreakResetTime) + return + + const newBettingStreak = (user?.currentBettingStreak ?? 0) + 1 + // Otherwise, add 1 to their betting streak + await firestore.collection('users').doc(user.id).update({ + currentBettingStreak: newBettingStreak, + }) + + // Send them the bonus times their streak + const bonusAmount = Math.min( + BETTING_STREAK_BONUS_AMOUNT * newBettingStreak, + BETTING_STREAK_BONUS_MAX + ) + const fromUserId = isProd() + ? HOUSE_LIQUIDITY_PROVIDER_ID + : DEV_HOUSE_LIQUIDITY_PROVIDER_ID + const bonusTxnDetails = { + currentBettingStreak: newBettingStreak, + } + const result = await firestore.runTransaction(async (trans) => { + const bonusTxn: TxnData = { + fromId: fromUserId, + fromType: 'BANK', + toId: user.id, + toType: 'USER', + amount: bonusAmount, + token: 'M$', + category: 'BETTING_STREAK_BONUS', + description: JSON.stringify(bonusTxnDetails), + } + return await runTxn(trans, bonusTxn) + }) + if (!result.txn) { + log("betting streak bonus txn couldn't be made") + return + } + + await createBettingStreakBonusNotification( + user, + result.txn.id, + bet, + contract, + bonusAmount, + eventId + ) +} + const updateUniqueBettorsAndGiveCreatorBonus = async ( - contractId: string, + contract: Contract, eventId: string, bettorId: string ) => { - const userContractSnap = await firestore - .collection(`contracts`) - .doc(contractId) - .get() - const contract = userContractSnap.data() as Contract - if (!contract) { - log(`Could not find contract ${contractId}`) - return - } let previousUniqueBettorIds = contract.uniqueBettorIds if (!previousUniqueBettorIds) { const contractBets = ( - await firestore.collection(`contracts/${contractId}/bets`).get() + await firestore.collection(`contracts/${contract.id}/bets`).get() ).docs.map((doc) => doc.data() as Bet) if (contractBets.length === 0) { - log(`No bets for contract ${contractId}`) + log(`No bets for contract ${contract.id}`) return } @@ -86,7 +154,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( if (!contract.uniqueBettorIds || isNewUniqueBettor) { log(`Got ${previousUniqueBettorIds} unique bettors`) isNewUniqueBettor && log(`And a new unique bettor ${bettorId}`) - await firestore.collection(`contracts`).doc(contractId).update({ + await firestore.collection(`contracts`).doc(contract.id).update({ uniqueBettorIds: newUniqueBettorIds, uniqueBettorCount: newUniqueBettorIds.length, }) @@ -97,7 +165,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( // Create combined txn for all new unique bettors const bonusTxnDetails = { - contractId: contractId, + contractId: contract.id, uniqueBettorIds: newUniqueBettorIds, } const fromUserId = isProd() @@ -140,14 +208,14 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( } } -const notifyFills = async (bet: Bet, contractId: string, eventId: string) => { +const notifyFills = async ( + bet: Bet, + contract: Contract, + eventId: string, + user: User +) => { if (!bet.fills) return - const user = await getUser(bet.userId) - if (!user) return - const contract = await getContract(contractId) - if (!contract) return - const matchedFills = bet.fills.filter((fill) => fill.matchedBetId !== null) const matchedBets = ( await Promise.all( @@ -180,3 +248,7 @@ const notifyFills = async (bet: Bet, contractId: string, eventId: string) => { }) ) } + +const getTodaysBettingStreakResetTime = () => { + return new Date().setUTCHours(BETTING_STREAK_RESET_HOUR, 0, 0, 0) +} diff --git a/functions/src/on-create-comment-on-contract.ts b/functions/src/on-create-comment-on-contract.ts index 3fa0983d..9f19dfcc 100644 --- a/functions/src/on-create-comment-on-contract.ts +++ b/functions/src/on-create-comment-on-contract.ts @@ -2,7 +2,7 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' import { compact, uniq } from 'lodash' import { getContract, getUser, getValues } from './utils' -import { Comment } from '../../common/comment' +import { ContractComment } from '../../common/comment' import { sendNewCommentEmail } from './emails' import { Bet } from '../../common/bet' import { Answer } from '../../common/answer' @@ -29,7 +29,7 @@ export const onCreateCommentOnContract = functions contractQuestion: contract.question, }) - const comment = change.data() as Comment + const comment = change.data() as ContractComment const lastCommentTime = comment.createdTime const commentCreator = await getUser(comment.userId) @@ -64,7 +64,7 @@ export const onCreateCommentOnContract = functions : undefined } - const comments = await getValues( + const comments = await getValues( firestore.collection('contracts').doc(contractId).collection('comments') ) const relatedSourceType = comment.replyToCommentId diff --git a/functions/src/on-create-comment-on-group.ts b/functions/src/on-create-comment-on-group.ts index 0064480f..15f2bbc1 100644 --- a/functions/src/on-create-comment-on-group.ts +++ b/functions/src/on-create-comment-on-group.ts @@ -1,5 +1,5 @@ import * as functions from 'firebase-functions' -import { Comment } from '../../common/comment' +import { GroupComment } from '../../common/comment' import * as admin from 'firebase-admin' import { Group } from '../../common/group' import { User } from '../../common/user' @@ -14,7 +14,7 @@ export const onCreateCommentOnGroup = functions.firestore groupId: string } - const comment = change.data() as Comment + const comment = change.data() as GroupComment const creatorSnapshot = await firestore .collection('users') .doc(comment.userId) diff --git a/functions/src/on-create-contract.ts b/functions/src/on-create-contract.ts index 73076b7f..3785ecc9 100644 --- a/functions/src/on-create-contract.ts +++ b/functions/src/on-create-contract.ts @@ -1,13 +1,10 @@ import * as functions from 'firebase-functions' -import * as admin from 'firebase-admin' -import { getPrivateUser, getUser } from './utils' +import { getUser } from './utils' import { createNotification } from './create-notification' import { Contract } from '../../common/contract' import { parseMentions, richTextToString } from '../../common/util/parse' import { JSONContent } from '@tiptap/core' -import { User } from 'common/user' -import { sendCreatorGuideEmail } from './emails' export const onCreateContract = functions .runWith({ secrets: ['MAILGUN_KEY'] }) @@ -31,23 +28,4 @@ export const onCreateContract = functions richTextToString(desc), { contract, recipients: mentioned } ) - - await sendGuideEmail(contractCreator) }) - -const firestore = admin.firestore() - -const sendGuideEmail = async (contractCreator: User) => { - const query = await firestore - .collection(`contracts`) - .where('creatorId', '==', contractCreator.id) - .limit(2) - .get() - - if (query.size >= 2) return - - const privateUser = await getPrivateUser(contractCreator.id) - if (!privateUser) return - - await sendCreatorGuideEmail(contractCreator, privateUser) -} diff --git a/functions/src/on-create-user.ts b/functions/src/on-create-user.ts new file mode 100644 index 00000000..844f75fc --- /dev/null +++ b/functions/src/on-create-user.ts @@ -0,0 +1,45 @@ +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 { + sendCreatorGuideEmail, + 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 guideSendTime = dayjs().add(28, 'hours').toString() + await sendCreatorGuideEmail(user, privateUser, guideSendTime) + + const followupSendTime = dayjs().add(48, '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 new file mode 100644 index 00000000..e1c3af8f --- /dev/null +++ b/functions/src/reset-betting-streaks.ts @@ -0,0 +1,39 @@ +// check every day if the user has created a bet since 4pm UTC, and if not, reset their streak + +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' +import { User } from '../../common/user' +import { DAY_MS } from '../../common/util/time' +import { BETTING_STREAK_RESET_HOUR } from '../../common/numeric-constants' +const firestore = admin.firestore() + +export const resetBettingStreaksForUsers = functions.pubsub + .schedule(`0 ${BETTING_STREAK_RESET_HOUR} * * *`) + .timeZone('utc') + .onRun(async () => { + await resetBettingStreaksInternal() + }) + +const resetBettingStreaksInternal = async () => { + const usersSnap = await firestore.collection('users').get() + + const users = usersSnap.docs.map((doc) => doc.data() as User) + + for (const user of users) { + await resetBettingStreakForUser(user) + } +} + +const resetBettingStreakForUser = async (user: User) => { + const betStreakResetTime = Date.now() - DAY_MS + // if they made a bet within the last day, don't reset their streak + if ( + (user.lastBetTime ?? 0 > betStreakResetTime) || + !user.currentBettingStreak || + user.currentBettingStreak === 0 + ) + return + await firestore.collection('users').doc(user.id).update({ + currentBettingStreak: 0, + }) +} diff --git a/functions/src/scripts/backfill-comment-types.ts b/functions/src/scripts/backfill-comment-types.ts new file mode 100644 index 00000000..6b61170e --- /dev/null +++ b/functions/src/scripts/backfill-comment-types.ts @@ -0,0 +1,31 @@ +// Comment types were introduced in August 2022. + +import { initAdmin } from './script-init' +import { log, writeAsync } from '../utils' + +if (require.main === module) { + const app = initAdmin() + const firestore = app.firestore() + const commentsRef = firestore.collectionGroup('comments') + commentsRef.get().then(async (commentsSnaps) => { + log(`Loaded ${commentsSnaps.size} comments.`) + const needsFilling = commentsSnaps.docs.filter((ct) => { + return !('commentType' in ct.data()) + }) + log(`Found ${needsFilling.length} comments to update.`) + const updates = needsFilling.map((d) => { + const comment = d.data() + const fields: { [k: string]: unknown } = {} + if (comment.contractId != null && comment.groupId == null) { + fields.commentType = 'contract' + } else if (comment.groupId != null && comment.contractId == null) { + fields.commentType = 'group' + } else { + log(`Invalid comment ${comment}; not touching it.`) + } + return { doc: d.ref, fields, info: comment } + }) + await writeAsync(firestore, updates) + log(`Updated all comments.`) + }) +} diff --git a/functions/src/scripts/convert-tag-to-group.ts b/functions/src/scripts/convert-tag-to-group.ts new file mode 100644 index 00000000..48f14e27 --- /dev/null +++ b/functions/src/scripts/convert-tag-to-group.ts @@ -0,0 +1,66 @@ +// Takes a tag and makes a new group with all the contracts in it. + +import * as admin from 'firebase-admin' +import { initAdmin } from './script-init' +import { isProd, log } from '../utils' +import { getSlug } from '../create-group' +import { Group } from '../../../common/group' + +const getTaggedContractIds = async (tag: string) => { + const firestore = admin.firestore() + const results = await firestore + .collection('contracts') + .where('lowercaseTags', 'array-contains', tag.toLowerCase()) + .get() + return results.docs.map((d) => d.id) +} + +const createGroup = async ( + name: string, + about: string, + contractIds: string[] +) => { + const firestore = admin.firestore() + const creatorId = isProd() + ? 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2' + : '94YYTk1AFWfbWMpfYcvnnwI1veP2' + + const slug = await getSlug(name) + const groupRef = firestore.collection('groups').doc() + const now = Date.now() + const group: Group = { + id: groupRef.id, + creatorId, + slug, + name, + about, + createdTime: now, + mostRecentActivityTime: now, + contractIds: contractIds, + anyoneCanJoin: true, + memberIds: [], + } + return await groupRef.create(group) +} + +const convertTagToGroup = async (tag: string, groupName: string) => { + log(`Looking up contract IDs with tag ${tag}...`) + const contractIds = await getTaggedContractIds(tag) + log(`${contractIds.length} contracts found.`) + if (contractIds.length > 0) { + log(`Creating group ${groupName}...`) + const about = `Contracts that used to be tagged ${tag}.` + const result = await createGroup(groupName, about, contractIds) + log(`Done. Group: `, result) + } +} + +if (require.main === module) { + initAdmin() + const args = process.argv.slice(2) + if (args.length != 2) { + console.log('Usage: convert-tag-to-group [tag] [group-name]') + } else { + convertTagToGroup(args[0], args[1]).catch((e) => console.error(e)) + } +} 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/serve.ts b/functions/src/serve.ts index bf96db20..8d848f7f 100644 --- a/functions/src/serve.ts +++ b/functions/src/serve.ts @@ -18,7 +18,7 @@ import { cancelbet } from './cancel-bet' import { sellbet } from './sell-bet' import { sellshares } from './sell-shares' import { claimmanalink } from './claim-manalink' -import { createmarket } from './create-contract' +import { createmarket } from './create-market' import { addliquidity } from './add-liquidity' import { withdrawliquidity } from './withdraw-liquidity' import { creategroup } from './create-group' 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/amount-input.tsx b/web/components/amount-input.tsx index 426a9371..cb071850 100644 --- a/web/components/amount-input.tsx +++ b/web/components/amount-input.tsx @@ -3,7 +3,6 @@ import React from 'react' import { useUser } from 'web/hooks/use-user' import { formatMoney } from 'common/util/format' import { Col } from './layout/col' -import { Spacer } from './layout/spacer' import { SiteLink } from './site-link' import { ENV_CONFIG } from 'common/envs/constants' @@ -37,7 +36,7 @@ export function AmountInput(props: { return (