diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml new file mode 100644 index 00000000..2aa95e44 --- /dev/null +++ b/.github/workflows/format.yml @@ -0,0 +1,43 @@ +name: Reformat main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + push: + branches: [main] + +env: + FORCE_COLOR: 3 + NEXT_TELEMETRY_DISABLED: 1 + +# mqp - i generated a personal token to use for these writes -- it's unclear +# why, but the default token didn't work, even when i gave it max permissions + +jobs: + prettify: + name: Auto-prettify + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + token: ${{ secrets.FORMATTER_ACCESS_TOKEN }} + - name: Restore cached node_modules + uses: actions/cache@v2 + with: + path: '**/node_modules' + key: ${{ runner.os }}-${{ matrix.node-version }}-nodemodules-${{ hashFiles('**/yarn.lock') }} + - name: Install missing dependencies + run: yarn install --prefer-offline --frozen-lockfile + - name: Run Prettier on web client + working-directory: web + run: yarn format + - name: Commit any Prettier changes + uses: stefanzweifel/git-auto-commit-action@v4 + with: + commit_message: Auto-prettification + branch: ${{ github.head_ref }} diff --git a/common/bet.ts b/common/bet.ts index 56e050a7..3d9d6a5a 100644 --- a/common/bet.ts +++ b/common/bet.ts @@ -14,19 +14,21 @@ export type Bet = { probBefore: number probAfter: number - sale?: { - amount: number // amount user makes from sale - betId: string // id of bet being sold - // TODO: add sale time? - } - fees: Fees - isSold?: boolean // true if this BUY bet has been sold isAnte?: boolean isLiquidityProvision?: boolean isRedemption?: boolean challengeSlug?: string + + // Props for bets in DPM contract below. + // A bet is either a BUY or a SELL that sells all of a previous buy. + isSold?: boolean // true if this BUY bet has been sold + // This field marks a SELL bet. + sale?: { + amount: number // amount user makes from sale + betId: string // id of BUY bet being sold + } } & Partial export type NumericBet = Bet & { diff --git a/common/calculate.ts b/common/calculate.ts index d25fd313..758fc3cd 100644 --- a/common/calculate.ts +++ b/common/calculate.ts @@ -1,4 +1,4 @@ -import { maxBy } from 'lodash' +import { maxBy, sortBy, sum, sumBy } from 'lodash' import { Bet, LimitBet } from './bet' import { calculateCpmmSale, @@ -133,10 +133,46 @@ export function resolvedPayout(contract: Contract, bet: Bet) { : calculateDpmPayout(contract, bet, outcome) } +function getCpmmInvested(yourBets: Bet[]) { + const totalShares: { [outcome: string]: number } = {} + const totalSpent: { [outcome: string]: number } = {} + + const sortedBets = sortBy(yourBets, 'createdTime') + for (const bet of sortedBets) { + const { outcome, shares, amount } = bet + if (amount > 0) { + totalShares[outcome] = (totalShares[outcome] ?? 0) + shares + totalSpent[outcome] = (totalSpent[outcome] ?? 0) + amount + } else if (amount < 0) { + const averagePrice = totalSpent[outcome] / totalShares[outcome] + totalShares[outcome] = totalShares[outcome] + shares + totalSpent[outcome] = totalSpent[outcome] + averagePrice * shares + } + } + + return sum(Object.values(totalSpent)) +} + +function getDpmInvested(yourBets: Bet[]) { + const sortedBets = sortBy(yourBets, 'createdTime') + + return sumBy(sortedBets, (bet) => { + const { amount, sale } = bet + + if (sale) { + const originalBet = sortedBets.find((b) => b.id === sale.betId) + if (originalBet) return -originalBet.amount + return 0 + } + + return amount + }) +} + export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) { const { resolution } = contract + const isCpmm = contract.mechanism === 'cpmm-1' - let currentInvested = 0 let totalInvested = 0 let payout = 0 let loan = 0 @@ -162,7 +198,6 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) { saleValue -= amount } - currentInvested += amount loan += loanAmount ?? 0 payout += resolution ? calculatePayout(contract, bet, resolution) @@ -174,12 +209,13 @@ export function getContractBetMetrics(contract: Contract, yourBets: Bet[]) { const profit = payout + saleValue + redeemed - totalInvested const profitPercent = (profit / totalInvested) * 100 + const invested = isCpmm ? getCpmmInvested(yourBets) : getDpmInvested(yourBets) const hasShares = Object.values(totalShares).some( (shares) => !floatingEqual(shares, 0) ) return { - invested: Math.max(0, currentInvested), + invested, payout, netPayout, profit, diff --git a/common/challenge.ts b/common/challenge.ts index 1a227f94..9bac8c08 100644 --- a/common/challenge.ts +++ b/common/challenge.ts @@ -1,3 +1,5 @@ +import { IS_PRIVATE_MANIFOLD } from './envs/constants' + export type Challenge = { // The link to send: https://manifold.markets/challenges/username/market-slug/{slug} // Also functions as the unique id for the link. @@ -60,4 +62,4 @@ export type Acceptance = { createdTime: number } -export const CHALLENGES_ENABLED = true +export const CHALLENGES_ENABLED = !IS_PRIVATE_MANIFOLD diff --git a/common/comment.ts b/common/comment.ts index 0d0c4daf..77b211d3 100644 --- a/common/comment.ts +++ b/common/comment.ts @@ -1,3 +1,5 @@ +import type { JSONContent } from '@tiptap/core' + // Currently, comments are created after the bet, not atomically with the bet. // They're uniquely identified by the pair contractId/betId. export type Comment = { @@ -9,11 +11,15 @@ export type Comment = { replyToCommentId?: string userId: string - text: string + /** @deprecated - content now stored as JSON in content*/ + text?: string + content: JSONContent createdTime: number // Denormalized, for rendering comments userName: string userUsername: string userAvatarUrl?: string + contractSlug?: string + contractQuestion?: string } diff --git a/common/contract.ts b/common/contract.ts index 8bdab6fe..c414a332 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -139,7 +139,7 @@ export const OUTCOME_TYPES = [ ] as const export const MAX_QUESTION_LENGTH = 480 -export const MAX_DESCRIPTION_LENGTH = 10000 +export const MAX_DESCRIPTION_LENGTH = 16000 export const MAX_TAG_LENGTH = 60 export const CPMM_MIN_POOL_QTY = 0.01 diff --git a/common/envs/constants.ts b/common/envs/constants.ts index 7092d711..48f9bf63 100644 --- a/common/envs/constants.ts +++ b/common/envs/constants.ts @@ -25,6 +25,10 @@ export function isAdmin(email: string) { return ENV_CONFIG.adminEmails.includes(email) } +export function isManifoldId(userId: string) { + return userId === 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2' +} + export const DOMAIN = ENV_CONFIG.domain export const FIREBASE_CONFIG = ENV_CONFIG.firebaseConfig export const PROJECT_ID = ENV_CONFIG.firebaseConfig.projectId diff --git a/common/envs/dev.ts b/common/envs/dev.ts index 3c062472..719de36e 100644 --- a/common/envs/dev.ts +++ b/common/envs/dev.ts @@ -2,6 +2,7 @@ import { EnvConfig, PROD_CONFIG } from './prod' export const DEV_CONFIG: EnvConfig = { ...PROD_CONFIG, + domain: 'dev.manifold.markets', firebaseConfig: { apiKey: 'AIzaSyBoq3rzUa8Ekyo3ZaTnlycQYPRCA26VpOw', authDomain: 'dev-mantic-markets.firebaseapp.com', diff --git a/common/package.json b/common/package.json index c324379f..955e9662 100644 --- a/common/package.json +++ b/common/package.json @@ -8,6 +8,7 @@ }, "sideEffects": false, "dependencies": { + "@tiptap/core": "2.0.0-beta.181", "@tiptap/extension-image": "2.0.0-beta.30", "@tiptap/extension-link": "2.0.0-beta.43", "@tiptap/extension-mention": "2.0.0-beta.102", diff --git a/common/util/array.ts b/common/util/array.ts index d81edba1..fd5efcc6 100644 --- a/common/util/array.ts +++ b/common/util/array.ts @@ -1,3 +1,40 @@ +import { isEqual } from 'lodash' + export function filterDefined(array: (T | null | undefined)[]) { return array.filter((item) => item !== null && item !== undefined) as T[] } + +export function buildArray( + ...params: (T | T[] | false | undefined | null)[] +) { + const array: T[] = [] + + for (const el of params) { + if (Array.isArray(el)) { + array.push(...el) + } else if (el) { + array.push(el) + } + } + + return array +} + +export function groupConsecutive(xs: T[], key: (x: T) => U) { + if (!xs.length) { + return [] + } + const result = [] + let curr = { key: key(xs[0]), items: [xs[0]] } + for (const x of xs.slice(1)) { + const k = key(x) + if (!isEqual(key, curr.key)) { + result.push(curr) + curr = { key: k, items: [x] } + } else { + curr.items.push(x) + } + } + result.push(curr) + return result +} diff --git a/common/util/parse.ts b/common/util/parse.ts index f07e4097..4fac3225 100644 --- a/common/util/parse.ts +++ b/common/util/parse.ts @@ -22,6 +22,7 @@ import { Image } from '@tiptap/extension-image' import { Link } from '@tiptap/extension-link' import { Mention } from '@tiptap/extension-mention' import Iframe from './tiptap-iframe' +import TiptapTweet from './tiptap-tweet-type' import { uniq } from 'lodash' export function parseTags(text: string) { @@ -94,6 +95,7 @@ export const exhibitExts = [ Link, Mention, Iframe, + TiptapTweet, ] export function richTextToString(text?: JSONContent) { diff --git a/common/util/tiptap-tweet-type.ts b/common/util/tiptap-tweet-type.ts new file mode 100644 index 00000000..0b9acffc --- /dev/null +++ b/common/util/tiptap-tweet-type.ts @@ -0,0 +1,37 @@ +import { Node, mergeAttributes } from '@tiptap/core' + +export interface TweetOptions { + tweetId: string +} + +// This is a version of the Tiptap Node config without addNodeView, +// since that would require bundling in tsx +export const TiptapTweetNode = { + name: 'tiptapTweet', + group: 'block', + atom: true, + + addAttributes() { + return { + tweetId: { + default: null, + }, + } + }, + + parseHTML() { + return [ + { + tag: 'tiptap-tweet', + }, + ] + }, + + renderHTML(props: { HTMLAttributes: Record }) { + return ['tiptap-tweet', mergeAttributes(props.HTMLAttributes)] + }, +} + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +export default Node.create(TiptapTweetNode) diff --git a/docs/docs/api.md b/docs/docs/api.md index 48564cb3..7b0058c2 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -135,7 +135,8 @@ Requires no authorization. // Market attributes. All times are in milliseconds since epoch closeTime?: number // Min of creator's chosen date, and resolutionTime question: string - description: string + description: JSONContent // Rich text content. See https://tiptap.dev/guide/output#option-1-json + textDescription: string // string description without formatting, images, or embeds // A list of tags on each market. Any user can add tags to any market. // This list also includes the predefined categories shown as filters on the home page. @@ -162,6 +163,8 @@ Requires no authorization. resolutionTime?: number resolution?: string resolutionProbability?: number // Used for BINARY markets resolved to MKT + + lastUpdatedTime?: number } ``` @@ -528,6 +531,10 @@ $ curl https://manifold.markets/api/v0/bet -X POST -H 'Content-Type: application "contractId":"{...}"}' ``` +### `POST /v0/bet/cancel/[id]` + +Cancel the limit order of a bet with the specified id. If the bet was unfilled, it will be cancelled so that no other bets will match with it. This is action irreversable. + ### `POST /v0/market` Creates a new market on behalf of the authorized user. @@ -537,6 +544,7 @@ Parameters: - `outcomeType`: Required. One of `BINARY`, `FREE_RESPONSE`, or `NUMERIC`. - `question`: Required. The headline question for the market. - `description`: Required. A long description describing the rules for the market. + - Note: string descriptions do **not** turn into links, mentions, formatted text. Instead, rich text descriptions must be in [TipTap json](https://tiptap.dev/guide/output#option-1-json). - `closeTime`: Required. The time at which the market will close, represented as milliseconds since the epoch. - `tags`: Optional. An array of string tags for the market. diff --git a/docs/package.json b/docs/package.json index 9e320306..38b69777 100644 --- a/docs/package.json +++ b/docs/package.json @@ -30,7 +30,8 @@ }, "devDependencies": { "@docusaurus/module-type-aliases": "2.0.0-beta.17", - "@tsconfig/docusaurus": "^1.0.4" + "@tsconfig/docusaurus": "^1.0.4", + "@types/react": "^17.0.2" }, "browserslist": { "production": [ diff --git a/firestore.indexes.json b/firestore.indexes.json index 12e88033..874344be 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -496,6 +496,28 @@ } ] }, + { + "collectionGroup": "comments", + "fieldPath": "contractId", + "indexes": [ + { + "order": "ASCENDING", + "queryScope": "COLLECTION" + }, + { + "order": "DESCENDING", + "queryScope": "COLLECTION" + }, + { + "arrayConfig": "CONTAINS", + "queryScope": "COLLECTION" + }, + { + "order": "ASCENDING", + "queryScope": "COLLECTION_GROUP" + } + ] + }, { "collectionGroup": "comments", "fieldPath": "createdTime", diff --git a/firestore.rules b/firestore.rules index b0befc85..81ab4eed 100644 --- a/firestore.rules +++ b/firestore.rules @@ -20,17 +20,17 @@ service cloud.firestore { match /users/{userId} { allow read; - allow update: if resource.data.id == request.auth.uid + allow update: if userId == request.auth.uid && request.resource.data.diff(resource.data).affectedKeys() .hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome']); // User referral rules - allow update: if resource.data.id == request.auth.uid + allow update: if userId == request.auth.uid && request.resource.data.diff(resource.data).affectedKeys() .hasOnly(['referredByUserId', 'referredByContractId', 'referredByGroupId']) // only one referral allowed per user && !("referredByUserId" in resource.data) // user can't refer themselves - && !(resource.data.id == request.resource.data.referredByUserId); + && !(userId == request.resource.data.referredByUserId); // quid pro quos enabled (only once though so nbd) - bc I can't make this work: // && (get(/databases/$(database)/documents/users/$(request.resource.data.referredByUserId)).referredByUserId == resource.data.id); } @@ -60,8 +60,8 @@ service cloud.firestore { } match /private-users/{userId} { - allow read: if resource.data.id == request.auth.uid || isAdmin(); - allow update: if (resource.data.id == request.auth.uid || isAdmin()) + 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' ]); } diff --git a/functions/package.json b/functions/package.json index b0d8e458..5839b5eb 100644 --- a/functions/package.json +++ b/functions/package.json @@ -31,8 +31,8 @@ "@tiptap/extension-link": "2.0.0-beta.43", "@tiptap/extension-mention": "2.0.0-beta.102", "@tiptap/starter-kit": "2.0.0-beta.190", - "dayjs": "1.11.4", "cors": "2.8.5", + "dayjs": "1.11.4", "express": "4.18.1", "firebase-admin": "10.0.0", "firebase-functions": "3.21.2", diff --git a/functions/src/analytics.ts b/functions/src/analytics.ts index d178ee0f..e0199616 100644 --- a/functions/src/analytics.ts +++ b/functions/src/analytics.ts @@ -3,7 +3,7 @@ import * as Amplitude from '@amplitude/node' import { DEV_CONFIG } from '../../common/envs/dev' import { PROD_CONFIG } from '../../common/envs/prod' -import { isProd } from './utils' +import { isProd, tryOrLogError } from './utils' const key = isProd() ? PROD_CONFIG.amplitudeApiKey : DEV_CONFIG.amplitudeApiKey @@ -15,10 +15,12 @@ export const track = async ( eventProperties?: any, amplitudeProperties?: Partial ) => { - await amp.logEvent({ - event_type: eventName, - user_id: userId, - event_properties: eventProperties, - ...amplitudeProperties, - }) + return await tryOrLogError( + amp.logEvent({ + event_type: eventName, + user_id: userId, + event_properties: eventProperties, + ...amplitudeProperties, + }) + ) } diff --git a/functions/src/api.ts b/functions/src/api.ts index fdda0ad5..e9a488c2 100644 --- a/functions/src/api.ts +++ b/functions/src/api.ts @@ -78,6 +78,19 @@ export const lookupUser = async (creds: Credentials): Promise => { } } +export const writeResponseError = (e: unknown, res: Response) => { + if (e instanceof APIError) { + const output: { [k: string]: unknown } = { message: e.message } + if (e.details != null) { + output.details = e.details + } + res.status(e.code).json(output) + } else { + error(e) + res.status(500).json({ message: 'An unknown error occurred.' }) + } +} + export const zTimestamp = () => { return z.preprocess((arg) => { return typeof arg == 'number' ? new Date(arg) : undefined @@ -131,16 +144,7 @@ export const newEndpoint = (endpointOpts: EndpointOptions, fn: Handler) => { const authedUser = await lookupUser(await parseCredentials(req)) res.status(200).json(await fn(req, authedUser)) } catch (e) { - if (e instanceof APIError) { - const output: { [k: string]: unknown } = { message: e.message } - if (e.details != null) { - output.details = e.details - } - res.status(e.code).json(output) - } else { - error(e) - res.status(500).json({ message: 'An unknown error occurred.' }) - } + writeResponseError(e, res) } }, } as EndpointDefinition diff --git a/functions/src/create-contract.ts b/functions/src/create-contract.ts index 44ced6a8..cef0dd48 100644 --- a/functions/src/create-contract.ts +++ b/functions/src/create-contract.ts @@ -59,7 +59,7 @@ const descScehma: z.ZodType = z.lazy(() => const bodySchema = z.object({ question: z.string().min(1).max(MAX_QUESTION_LENGTH), - description: descScehma.optional(), + description: descScehma.or(z.string()).optional(), tags: z.array(z.string().min(1).max(MAX_TAG_LENGTH)).optional(), closeTime: zTimestamp().refine( (date) => date.getTime() > new Date().getTime(), @@ -133,41 +133,7 @@ export const createmarket = newEndpoint({}, async (req, auth) => { if (ante > user.balance) throw new APIError(400, `Balance must be at least ${ante}.`) - const slug = await getSlug(question) - const contractRef = firestore.collection('contracts').doc() - - console.log( - 'creating contract for', - user.username, - 'on', - question, - 'ante:', - ante || 0 - ) - - const contract = getNewContract( - contractRef.id, - slug, - user, - question, - outcomeType, - description ?? {}, - initialProb ?? 0, - ante, - closeTime.getTime(), - tags ?? [], - NUMERIC_BUCKET_COUNT, - min ?? 0, - max ?? 0, - isLogScale ?? false, - answers ?? [] - ) - - if (ante) await chargeUser(user.id, ante, true) - - await contractRef.create(contract) - - let group = null + let group: Group | null = null if (groupId) { const groupDocRef = firestore.collection('groups').doc(groupId) const groupDoc = await groupDocRef.get() @@ -186,9 +152,60 @@ export const createmarket = newEndpoint({}, async (req, auth) => { 'User must be a member/creator of the group or group must be open to add markets to it.' ) } + } + const slug = await getSlug(question) + const contractRef = firestore.collection('contracts').doc() + + console.log( + 'creating contract for', + user.username, + 'on', + question, + 'ante:', + ante || 0 + ) + + // convert string descriptions into JSONContent + const newDescription = + typeof description === 'string' + ? { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: description }], + }, + ], + } + : description ?? {} + + const contract = getNewContract( + contractRef.id, + slug, + user, + question, + outcomeType, + newDescription, + initialProb ?? 0, + ante, + closeTime.getTime(), + tags ?? [], + NUMERIC_BUCKET_COUNT, + min ?? 0, + max ?? 0, + isLogScale ?? false, + answers ?? [] + ) + + if (ante) await chargeUser(user.id, ante, true) + + await contractRef.create(contract) + + if (group != null) { if (!group.contractIds.includes(contractRef.id)) { await createGroupLinks(group, [contractRef.id], auth.uid) - await groupDocRef.update({ + const groupDocRef = firestore.collection('groups').doc(group.id) + groupDocRef.update({ contractIds: uniq([...group.contractIds, contractRef.id]), }) } diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index e16920f7..51b884ad 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -7,7 +7,7 @@ import { } from '../../common/notification' import { User } from '../../common/user' import { Contract } from '../../common/contract' -import { getUserByUsername, getValues } from './utils' +import { getValues } from './utils' import { Comment } from '../../common/comment' import { uniq } from 'lodash' import { Bet, LimitBet } from '../../common/bet' @@ -17,6 +17,7 @@ import { removeUndefinedProps } from '../../common/util/object' import { TipTxn } from '../../common/txn' import { Group, GROUP_CHAT_SLUG } from '../../common/group' import { Challenge } from '../../common/challenge' +import { richTextToString } from '../../common/util/parse' const firestore = admin.firestore() type user_to_reason_texts = { @@ -155,17 +156,6 @@ export const createNotification = async ( } } - /** @deprecated parse from rich text instead */ - const parseMentions = async (source: string) => { - const mentions = source.match(/@\w+/g) - if (!mentions) return [] - return Promise.all( - mentions.map( - async (username) => (await getUserByUsername(username.slice(1)))?.id - ) - ) - } - const notifyTaggedUsers = ( userToReasonTexts: user_to_reason_texts, userIds: (string | undefined)[] @@ -301,8 +291,7 @@ export const createNotification = async ( if (sourceType === 'comment') { if (recipients?.[0] && relatedSourceType) notifyRepliedUser(userToReasonTexts, recipients[0], relatedSourceType) - if (sourceText) - notifyTaggedUsers(userToReasonTexts, await parseMentions(sourceText)) + if (sourceText) notifyTaggedUsers(userToReasonTexts, recipients ?? []) } await notifyContractCreator(userToReasonTexts, sourceContract) await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract) @@ -427,7 +416,7 @@ export const createGroupCommentNotification = async ( sourceUserName: fromUser.name, sourceUserUsername: fromUser.username, sourceUserAvatarUrl: fromUser.avatarUrl, - sourceText: comment.text, + sourceText: richTextToString(comment.content), sourceSlug, sourceTitle: `${group.name}`, isSeenOnHref: sourceSlug, diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts index c30e78c3..c0b03e23 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -16,7 +16,7 @@ import { cleanDisplayName, cleanUsername, } from '../../common/util/clean-username' -import { sendWelcomeEmail } from './emails' +import { sendPersonalFollowupEmail, sendWelcomeEmail } from './emails' import { isWhitelisted } from '../../common/envs/constants' import { CATEGORIES_GROUP_SLUG_POSTFIX, @@ -96,9 +96,10 @@ export const createuser = newEndpoint(opts, async (req, auth) => { await addUserToDefaultGroups(user) await sendWelcomeEmail(user, privateUser) + await sendPersonalFollowupEmail(user, privateUser) await track(auth.uid, 'create user', { username }, { ip: req.ip }) - return user + return { user, privateUser } }) const firestore = admin.firestore() diff --git a/functions/src/email-templates/500-mana.html b/functions/src/email-templates/500-mana.html index 1ef9dbb7..6c75f026 100644 --- a/functions/src/email-templates/500-mana.html +++ b/functions/src/email-templates/500-mana.html @@ -128,7 +128,20 @@

+ Hi {{name}},

+
+ + + + +
+

Thanks for using Manifold Markets. Running low @@ -161,6 +174,51 @@ + + +

+

Did + you know, besides making correct predictions, there are + plenty of other ways to earn mana?

+ +

 

+

Cheers, +

+

David + from Manifold

+

 

+
+ + diff --git a/functions/src/email-templates/creating-market.html b/functions/src/email-templates/creating-market.html index 64273e7c..674a30ed 100644 --- a/functions/src/email-templates/creating-market.html +++ b/functions/src/email-templates/creating-market.html @@ -1,46 +1,48 @@ - - - (no subject) - - - - - - - + + + + + + - - - - - - - - - + + - + + + - - - -
- -
+
+ +
- - - - + + +
+ + + + + + +
- -
+ +
- - - - - - -
+ + + + - - -
- +
- - - + + - - -
- +
+ -
-
- - -
-
- -
+
+
+
+ + + + + +
+ +
- - - -
+ + + + + + +
- -
+ +
- - - - - - -
+ + + + + + + - - -
+
+

+ Hi {{name}},

+
+
-
+
-

+

- + On Manifold Markets, several important factors - go into making a good question. These lead to - more people betting on them and allowing a more - accurate prediction to be formed! -

-

-   -

-

- Congrats on creating your first market on Manifold! +

+ +

+ Manifold also gives its creators 10 Mana for + ">The following is a short guide to creating markets. +

+

+   +

+

+ What makes a good market? +

+
    +
  • + Interesting + topic. Manifold gives + creators M$10 for each unique trader that bets on your - market! -

    -

    -   -

    -

    - + Clear resolution criteria. Any ambiguities or edge cases in your description + 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! +
  • +
  • + Share it on social media. You'll earn the M$500 + referral bonus if you get new users to sign up! +
  • +
+

+   +

+

+ What makes a good question? -

- +

+   +

+

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

-

- + Thanks for reading! -

-

Thanks for reading! +

+

- + David from Manifold -

-
-
- - -
-
- -
- - - - + + +
David from Manifold +

+ +
+
+ +
+ + +
+ + + +
- - -
- - - - + + +
+ + +
+ + + + - - -
- -
+ +
- - - - - - -
- + "> +
-
+ + + + - - - + + + + "> + + +
-
+
-

- This e-mail has been sent to {{name}}, - +

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

-
-
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 58527080..0ffafbd5 100644 --- a/functions/src/email-templates/welcome.html +++ b/functions/src/email-templates/welcome.html @@ -1,824 +1,337 @@ - - - Welcome to Manifold Markets - - - - - - - + + + + + + - - - + + - + + + - - - -
- -
- - - - - - -
- -
- - - - - - -
- - - - - - -
- -
-
-
- -
-
- -
- - - - - - -
- -
- - - - - - -
-
-

- Hi {{name}}, thanks for joining Manifold - Markets!

We can't wait to see what questions you - will ask! -

-

- As a gift M$1000 has been credited to your - account - the equivalent of 10 USD. - -

-

- Click the buttons to see what you can do with - it! -

-
-
-
- -
- - - - - - - - - - - - -
- - - - - - -
- - - -
-
- - - - - - -
- - - -
-
- - - - - - -
- - - -
-
-
- -
-
- -
- - - - - - -
- -
- - - - - - -
-
-

- If you have any questions or feedback we'd - love to hear from you in our Discord server! -

-

-

- Looking forward to seeing you, -

-

- David from Manifold -

-
-

-
-
-
- -
-
- -
- - - - - - -
-
- -
- - - - - - -
- -
- - - - - - -
- - - - - - -
-
-

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

-
-
-
-
- -
-
- + } + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+
+
+

+ Hi {{name}},

+
+
+
+

+ Welcome! Manifold Markets is a play-money prediction market platform where you can bet on + anything, from elections to Elon Musk to scientific papers to the NBA.

+
+
+
+

+ +

+
+
+

+
+ + + + +
+ + + + +
+ + Explore markets + +
+
+
+
+

Did + you know, besides betting and making predictions, you can also create + your + own + market on + any question you care about?

+ +

More resources:

+ + + +

 

+

Cheers, +

+

David + from Manifold

+

 

+
+
+
+

+ +

 

+

Cheers,

+

David from Manifold

+

 

+
+
+
+ +
- - + +
+ + + +
+ +
+ + + + @@ -139,6 +142,22 @@ export function AnswerBetPanel(props: { disabled={isSubmitting} inputRef={inputRef} /> + + {(betAmount ?? 0) > 10 && + bankrollFraction >= 0.5 && + bankrollFraction <= 1 ? ( + + ) : ( + '' + )} +
Probability
diff --git a/web/components/answers/multiple-choice-answers.tsx b/web/components/answers/multiple-choice-answers.tsx index 450c221a..c2857eb2 100644 --- a/web/components/answers/multiple-choice-answers.tsx +++ b/web/components/answers/multiple-choice-answers.tsx @@ -1,26 +1,23 @@ import { MAX_ANSWER_LENGTH } from 'common/answer' -import { useState } from 'react' import Textarea from 'react-expanding-textarea' import { XIcon } from '@heroicons/react/solid' - import { Col } from '../layout/col' import { Row } from '../layout/row' export function MultipleChoiceAnswers(props: { + answers: string[] setAnswers: (answers: string[]) => void }) { - const [answers, setInternalAnswers] = useState(['', '', '']) + const { answers, setAnswers } = props const setAnswer = (i: number, answer: string) => { const newAnswers = setElement(answers, i, answer) - setInternalAnswers(newAnswers) - props.setAnswers(newAnswers) + setAnswers(newAnswers) } const removeAnswer = (i: number) => { const newAnswers = answers.slice(0, i).concat(answers.slice(i + 1)) - setInternalAnswers(newAnswers) - props.setAnswers(newAnswers) + setAnswers(newAnswers) } const addAnswer = () => setAnswer(answers.length, '') @@ -28,7 +25,7 @@ export function MultipleChoiceAnswers(props: { return (
{answers.map((answer, i) => ( - + {i + 1}.{' '}
+ + + +
+
+ + + + + +
+ +
+ + + + + + +
+ + + + + + + + + +
+
+

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 b7469e9f..acab22d8 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -1,3 +1,5 @@ +import * as dayjs from 'dayjs' + import { DOMAIN } from '../../common/envs/constants' import { Answer } from '../../common/answer' import { Bet } from '../../common/bet' @@ -14,9 +16,10 @@ import { import { getValueFromBucket } from '../../common/calculate-dpm' import { formatNumericProbability } from '../../common/pseudo-numeric' -import { sendTemplateEmail } from './send-email' +import { sendTemplateEmail, sendTextEmail } from './send-email' import { getPrivateUser, getUser } from './utils' import { getFunctionUrl } from '../../common/api' +import { richTextToString } from '../../common/util/parse' const UNSUBSCRIBE_ENDPOINT = getFunctionUrl('unsubscribe') @@ -73,9 +76,8 @@ export const sendMarketResolutionEmail = async ( // Modify template here: // https://app.mailgun.com/app/sending/domains/mg.manifold.markets/templates/edit/market-resolved/initial - // Mailgun username: james@mantic.markets - await sendTemplateEmail( + return await sendTemplateEmail( privateUser.email, subject, 'market-resolved', @@ -151,7 +153,7 @@ export const sendWelcomeEmail = async ( const emailType = 'generic' const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` - await sendTemplateEmail( + return await sendTemplateEmail( privateUser.email, 'Welcome to Manifold Markets!', 'welcome', @@ -165,6 +167,43 @@ export const sendWelcomeEmail = async ( ) } +export const sendPersonalFollowupEmail = async ( + user: User, + privateUser: PrivateUser +) => { + if (!privateUser || !privateUser.email) return + + const { name } = user + const firstName = name.split(' ')[0] + + const emailBody = `Hi ${firstName}, + +Thanks for signing up! I'm one of the cofounders of Manifold Markets, and was wondering how you've found your exprience on the platform so far? + +If you haven't already, I encourage you to try creating your own prediction market (https://manifold.markets/create) and joining our Discord chat (https://discord.com/invite/eHQBNBqXuh). + +Feel free to reply to this email with any questions or concerns you have. + +Cheers, + +James +Cofounder of Manifold Markets +https://manifold.markets + ` + + const sendTime = dayjs().add(4, 'hours').toString() + + await sendTextEmail( + privateUser.email, + 'How are you finding Manifold?', + emailBody, + { + from: 'James from Manifold ', + 'o:deliverytime': sendTime, + } + ) +} + export const sendOneWeekBonusEmail = async ( user: User, privateUser: PrivateUser @@ -182,7 +221,7 @@ export const sendOneWeekBonusEmail = async ( const emailType = 'generic' const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` - await sendTemplateEmail( + return await sendTemplateEmail( privateUser.email, 'Manifold Markets one week anniversary gift', 'one-week', @@ -197,6 +236,37 @@ export const sendOneWeekBonusEmail = async ( ) } +export const sendCreatorGuideEmail = async ( + user: User, + privateUser: PrivateUser +) => { + if ( + !privateUser || + !privateUser.email || + privateUser.unsubscribedFromGenericEmails + ) + return + + const { name, id: userId } = user + const firstName = name.split(' ')[0] + + const emailType = 'generic' + const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` + + return await sendTemplateEmail( + privateUser.email, + 'Market creation guide', + 'creating-market', + { + name: firstName, + unsubscribeLink, + }, + { + from: 'David from Manifold ', + } + ) +} + export const sendThankYouEmail = async ( user: User, privateUser: PrivateUser @@ -214,7 +284,7 @@ export const sendThankYouEmail = async ( const emailType = 'generic' const unsubscribeLink = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` - await sendTemplateEmail( + return await sendTemplateEmail( privateUser.email, 'Thanks for your Manifold purchase', 'thank-you', @@ -249,7 +319,7 @@ export const sendMarketCloseEmail = async ( const emailType = 'market-resolve' const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` - await sendTemplateEmail( + return await sendTemplateEmail( privateUser.email, 'Your market has closed', 'market-close', @@ -291,7 +361,8 @@ export const sendNewCommentEmail = async ( const unsubscribeUrl = `${UNSUBSCRIBE_ENDPOINT}?id=${userId}&type=${emailType}` const { name: commentorName, avatarUrl: commentorAvatarUrl } = commentCreator - const { text } = comment + const { content } = comment + const text = richTextToString(content) let betDescription = '' if (bet) { @@ -307,7 +378,7 @@ export const sendNewCommentEmail = async ( if (contract.outcomeType === 'FREE_RESPONSE' && answerId && answerText) { const answerNumber = `#${answerId}` - await sendTemplateEmail( + return await sendTemplateEmail( privateUser.email, subject, 'market-answer-comment', @@ -330,7 +401,7 @@ export const sendNewCommentEmail = async ( bet.outcome )}` } - await sendTemplateEmail( + return await sendTemplateEmail( privateUser.email, subject, 'market-comment', @@ -375,7 +446,7 @@ export const sendNewAnswerEmail = async ( const subject = `New answer on ${question}` const from = `${name} ` - await sendTemplateEmail( + return await sendTemplateEmail( privateUser.email, subject, 'market-answer', diff --git a/functions/src/get-custom-token.ts b/functions/src/get-custom-token.ts new file mode 100644 index 00000000..4aaaac11 --- /dev/null +++ b/functions/src/get-custom-token.ts @@ -0,0 +1,33 @@ +import * as admin from 'firebase-admin' +import { + APIError, + EndpointDefinition, + lookupUser, + parseCredentials, + writeResponseError, +} from './api' + +const opts = { + method: 'GET', + minInstances: 1, + concurrency: 100, + memory: '2GiB', + cpu: 1, +} as const + +export const getcustomtoken: EndpointDefinition = { + opts, + handler: async (req, res) => { + try { + const credentials = await parseCredentials(req) + if (credentials.kind != 'jwt') { + throw new APIError(403, 'API keys cannot mint custom tokens.') + } + const user = await lookupUser(credentials) + const token = await admin.auth().createCustomToken(user.uid) + res.status(200).json({ token: token }) + } catch (e) { + writeResponseError(e, res) + } + }, +} diff --git a/functions/src/index.ts b/functions/src/index.ts index 125cdea4..07b37648 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -65,6 +65,7 @@ import { unsubscribe } from './unsubscribe' import { stripewebhook, createcheckoutsession } from './stripe' import { getcurrentuser } from './get-current-user' import { acceptchallenge } from './accept-challenge' +import { getcustomtoken } from './get-custom-token' const toCloudFunction = ({ opts, handler }: EndpointDefinition) => { return onRequest(opts, handler as any) @@ -89,6 +90,7 @@ const stripeWebhookFunction = toCloudFunction(stripewebhook) const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession) const getCurrentUserFunction = toCloudFunction(getcurrentuser) const acceptChallenge = toCloudFunction(acceptchallenge) +const getCustomTokenFunction = toCloudFunction(getcustomtoken) export { healthFunction as health, @@ -111,4 +113,5 @@ export { createCheckoutSessionFunction as createcheckoutsession, getCurrentUserFunction as getcurrentuser, acceptChallenge as acceptchallenge, + getCustomTokenFunction as getcustomtoken, } diff --git a/functions/src/on-create-comment-on-contract.ts b/functions/src/on-create-comment-on-contract.ts index 4719fd08..3fa0983d 100644 --- a/functions/src/on-create-comment-on-contract.ts +++ b/functions/src/on-create-comment-on-contract.ts @@ -1,13 +1,13 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' -import { uniq } from 'lodash' - +import { compact, uniq } from 'lodash' import { getContract, getUser, getValues } from './utils' import { Comment } from '../../common/comment' import { sendNewCommentEmail } from './emails' import { Bet } from '../../common/bet' import { Answer } from '../../common/answer' import { createNotification } from './create-notification' +import { parseMentions, richTextToString } from '../../common/util/parse' const firestore = admin.firestore() @@ -24,6 +24,11 @@ export const onCreateCommentOnContract = functions if (!contract) throw new Error('Could not find contract corresponding with comment') + await change.ref.update({ + contractSlug: contract.slug, + contractQuestion: contract.question, + }) + const comment = change.data() as Comment const lastCommentTime = comment.createdTime @@ -71,7 +76,10 @@ export const onCreateCommentOnContract = functions const repliedUserId = comment.replyToCommentId ? comments.find((c) => c.id === comment.replyToCommentId)?.userId : answer?.userId - const recipients = repliedUserId ? [repliedUserId] : [] + + const recipients = uniq( + compact([...parseMentions(comment.content), repliedUserId]) + ) await createNotification( comment.id, @@ -79,7 +87,7 @@ export const onCreateCommentOnContract = functions 'created', commentCreator, eventId, - comment.text, + richTextToString(comment.content), { contract, relatedSourceType, recipients } ) diff --git a/functions/src/on-create-contract.ts b/functions/src/on-create-contract.ts index 6b57a9a0..73076b7f 100644 --- a/functions/src/on-create-contract.ts +++ b/functions/src/on-create-contract.ts @@ -1,12 +1,17 @@ import * as functions from 'firebase-functions' -import { getUser } from './utils' +import * as admin from 'firebase-admin' + +import { getPrivateUser, 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.firestore - .document('contracts/{contractId}') +export const onCreateContract = functions + .runWith({ secrets: ['MAILGUN_KEY'] }) + .firestore.document('contracts/{contractId}') .onCreate(async (snapshot, context) => { const contract = snapshot.data() as Contract const { eventId } = context @@ -26,4 +31,23 @@ export const onCreateContract = functions.firestore 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/place-bet.ts b/functions/src/place-bet.ts index 7501309a..780b50d6 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -82,7 +82,22 @@ export const placebet = newEndpoint({}, async (req, auth) => { (outcomeType == 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') && mechanism == 'cpmm-1' ) { - const { outcome, limitProb } = validate(binarySchema, req.body) + // eslint-disable-next-line prefer-const + let { outcome, limitProb } = validate(binarySchema, req.body) + + if (limitProb !== undefined && outcomeType === 'BINARY') { + const isRounded = floatingEqual( + Math.round(limitProb * 100), + limitProb * 100 + ) + if (!isRounded) + throw new APIError( + 400, + 'limitProb must be in increments of 0.01 (i.e. whole percentage points)' + ) + + limitProb = Math.round(limitProb * 100) / 100 + } const unfilledBetsSnap = await trans.get( getUnfilledBetsQuery(contractDoc) diff --git a/functions/src/resolve-market.ts b/functions/src/resolve-market.ts index cc07d4be..6f8ea2e9 100644 --- a/functions/src/resolve-market.ts +++ b/functions/src/resolve-market.ts @@ -1,6 +1,6 @@ import * as admin from 'firebase-admin' import { z } from 'zod' -import { difference, uniq, mapValues, groupBy, sumBy } from 'lodash' +import { difference, mapValues, groupBy, sumBy } from 'lodash' import { Contract, @@ -18,10 +18,12 @@ import { groupPayoutsByUser, Payout, } from '../../common/payouts' -import { isAdmin } from '../../common/envs/constants' +import { isManifoldId } from '../../common/envs/constants' import { removeUndefinedProps } from '../../common/util/object' import { LiquidityProvision } from '../../common/liquidity-provision' import { APIError, newEndpoint, validate } from './api' +import { getContractBetMetrics } from '../../common/calculate' +import { floatingEqual } from '../../common/util/math' const bodySchema = z.object({ contractId: z.string(), @@ -82,7 +84,7 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => { req.body ) - if (creatorId !== auth.uid && !isAdmin(auth.uid)) + if (creatorId !== auth.uid && !isManifoldId(auth.uid)) throw new APIError(403, 'User is not creator of contract') if (contract.resolution) throw new APIError(400, 'Contract already resolved') @@ -162,7 +164,7 @@ export const resolvemarket = newEndpoint(opts, async (req, auth) => { const userPayoutsWithoutLoans = groupPayoutsByUser(payouts) await sendResolutionEmails( - openBets, + bets, userPayoutsWithoutLoans, creator, creatorPayout, @@ -188,7 +190,7 @@ const processPayouts = async (payouts: Payout[], isDeposit = false) => { } const sendResolutionEmails = async ( - openBets: Bet[], + bets: Bet[], userPayouts: { [userId: string]: number }, creator: User, creatorPayout: number, @@ -197,14 +199,15 @@ const sendResolutionEmails = async ( resolutionProbability?: number, resolutions?: { [outcome: string]: number } ) => { - const nonWinners = difference( - uniq(openBets.map(({ userId }) => userId)), - Object.keys(userPayouts) - ) const investedByUser = mapValues( - groupBy(openBets, (bet) => bet.userId), - (bets) => sumBy(bets, (bet) => bet.amount) + groupBy(bets, (bet) => bet.userId), + (bets) => getContractBetMetrics(contract, bets).invested ) + const investedUsers = Object.keys(investedByUser).filter( + (userId) => !floatingEqual(investedByUser[userId], 0) + ) + + const nonWinners = difference(investedUsers, Object.keys(userPayouts)) const emailPayouts = [ ...Object.entries(userPayouts), ...nonWinners.map((userId) => [userId, 0] as const), diff --git a/functions/src/scripts/denormalize-comment-contract-data.ts b/functions/src/scripts/denormalize-comment-contract-data.ts new file mode 100644 index 00000000..0358c5a1 --- /dev/null +++ b/functions/src/scripts/denormalize-comment-contract-data.ts @@ -0,0 +1,70 @@ +// Filling in the contract-based fields on comments. + +import * as admin from 'firebase-admin' +import { initAdmin } from './script-init' +import { + DocumentCorrespondence, + findDiffs, + describeDiff, + applyDiff, +} from './denormalize' +import { DocumentSnapshot, Transaction } from 'firebase-admin/firestore' + +initAdmin() +const firestore = admin.firestore() + +async function getContractsById(transaction: Transaction) { + const contracts = await transaction.get(firestore.collection('contracts')) + const results = Object.fromEntries(contracts.docs.map((doc) => [doc.id, doc])) + console.log(`Found ${contracts.size} contracts.`) + return results +} + +async function getCommentsByContractId(transaction: Transaction) { + const comments = await transaction.get( + firestore.collectionGroup('comments').where('contractId', '!=', null) + ) + const results = new Map() + comments.forEach((doc) => { + const contractId = doc.get('contractId') + const contractComments = results.get(contractId) || [] + contractComments.push(doc) + results.set(contractId, contractComments) + }) + console.log(`Found ${comments.size} comments on ${results.size} contracts.`) + return results +} + +async function denormalize() { + let hasMore = true + while (hasMore) { + hasMore = await admin.firestore().runTransaction(async (transaction) => { + const [contractsById, commentsByContractId] = await Promise.all([ + getContractsById(transaction), + getCommentsByContractId(transaction), + ]) + const mapping = Object.entries(contractsById).map( + ([id, doc]): DocumentCorrespondence => { + return [doc, commentsByContractId.get(id) || []] + } + ) + const slugDiffs = findDiffs(mapping, 'slug', 'contractSlug') + const qDiffs = findDiffs(mapping, 'question', 'contractQuestion') + console.log(`Found ${slugDiffs.length} comments with mismatched slugs.`) + console.log(`Found ${qDiffs.length} comments with mismatched questions.`) + const diffs = slugDiffs.concat(qDiffs) + diffs.slice(0, 500).forEach((d) => { + console.log(describeDiff(d)) + applyDiff(transaction, d) + }) + if (diffs.length > 500) { + console.log(`Applying first 500 because of Firestore limit...`) + } + return diffs.length > 500 + }) + } +} + +if (require.main === module) { + denormalize().catch((e) => console.error(e)) +} diff --git a/functions/src/scripts/set-avatar-cache-headers.ts b/functions/src/scripts/set-avatar-cache-headers.ts new file mode 100644 index 00000000..676ec62d --- /dev/null +++ b/functions/src/scripts/set-avatar-cache-headers.ts @@ -0,0 +1,27 @@ +import { initAdmin } from './script-init' +import { log } from '../utils' + +const app = initAdmin() +const ONE_YEAR_SECS = 60 * 60 * 24 * 365 +const AVATAR_EXTENSION_RE = /\.(gif|tiff|jpe?g|png|webp)$/i + +const processAvatars = async () => { + const storage = app.storage() + const bucket = storage.bucket(`${app.options.projectId}.appspot.com`) + const [files] = await bucket.getFiles({ prefix: 'user-images' }) + log(`${files.length} avatar images to process.`) + for (const file of files) { + if (AVATAR_EXTENSION_RE.test(file.name)) { + log(`Updating metadata for ${file.name}.`) + await file.setMetadata({ + cacheControl: `public, max-age=${ONE_YEAR_SECS}`, + }) + } else { + log(`Skipping ${file.name} because it probably isn't an avatar.`) + } + } +} + +if (require.main === module) { + processAvatars().catch((e) => console.error(e)) +} diff --git a/functions/src/send-email.ts b/functions/src/send-email.ts index d081997f..cb054aa7 100644 --- a/functions/src/send-email.ts +++ b/functions/src/send-email.ts @@ -1,27 +1,35 @@ import * as mailgun from 'mailgun-js' +import { tryOrLogError } from './utils' const initMailgun = () => { const apiKey = process.env.MAILGUN_KEY as string return mailgun({ apiKey, domain: 'mg.manifold.markets' }) } -export const sendTextEmail = (to: string, subject: string, text: string) => { +export const sendTextEmail = async ( + to: string, + subject: string, + text: string, + options?: Partial +) => { const data: mailgun.messages.SendData = { - from: 'Manifold Markets ', + ...options, + from: options?.from ?? 'Manifold Markets ', to, subject, text, // Don't rewrite urls in plaintext emails 'o:tracking-clicks': 'htmlonly', } - const mg = initMailgun() - return mg.messages().send(data, (error) => { - if (error) console.log('Error sending email', error) - else console.log('Sent text email', to, subject) - }) + const mg = initMailgun().messages() + const result = await tryOrLogError(mg.send(data)) + if (result != null) { + console.log('Sent text email', to, subject) + } + return result } -export const sendTemplateEmail = ( +export const sendTemplateEmail = async ( to: string, subject: string, templateId: string, @@ -35,11 +43,13 @@ export const sendTemplateEmail = ( subject, template: templateId, 'h:X-Mailgun-Variables': JSON.stringify(templateData), + 'o:tag': templateId, + 'o:tracking': true, } - const mg = initMailgun() - - return mg.messages().send(data, (error) => { - if (error) console.log('Error sending email', error) - else console.log('Sent template email', templateId, to, subject) - }) + const mg = initMailgun().messages() + const result = await tryOrLogError(mg.send(data)) + if (result != null) { + console.log('Sent template email', templateId, to, subject) + } + return result } diff --git a/functions/src/serve.ts b/functions/src/serve.ts index 0064b69f..bf96db20 100644 --- a/functions/src/serve.ts +++ b/functions/src/serve.ts @@ -26,6 +26,7 @@ import { resolvemarket } from './resolve-market' import { unsubscribe } from './unsubscribe' import { stripewebhook, createcheckoutsession } from './stripe' import { getcurrentuser } from './get-current-user' +import { getcustomtoken } from './get-custom-token' type Middleware = (req: Request, res: Response, next: NextFunction) => void const app = express() @@ -64,6 +65,7 @@ addJsonEndpointRoute('/resolvemarket', resolvemarket) addJsonEndpointRoute('/unsubscribe', unsubscribe) addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession) addJsonEndpointRoute('/getcurrentuser', getcurrentuser) +addEndpointRoute('/getcustomtoken', getcustomtoken) addEndpointRoute('/stripewebhook', stripewebhook, express.raw()) app.listen(PORT) diff --git a/functions/src/utils.ts b/functions/src/utils.ts index 0414b01e..721f33d0 100644 --- a/functions/src/utils.ts +++ b/functions/src/utils.ts @@ -42,6 +42,15 @@ export const writeAsync = async ( } } +export const tryOrLogError = async (task: Promise) => { + try { + return await task + } catch (e) { + console.error(e) + return null + } +} + export const isProd = () => { return admin.instanceId().app.options.projectId === 'mantic-markets' } diff --git a/package.json b/package.json index 77420607..05924ef0 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "devDependencies": { "@typescript-eslint/eslint-plugin": "5.25.0", "@typescript-eslint/parser": "5.25.0", + "@types/node": "16.11.11", "concurrently": "6.5.1", "eslint": "8.15.0", "eslint-plugin-lodash": "^7.4.0", diff --git a/web/.eslintrc.js b/web/.eslintrc.js index fec650f9..0f103080 100644 --- a/web/.eslintrc.js +++ b/web/.eslintrc.js @@ -5,6 +5,7 @@ module.exports = { 'plugin:@typescript-eslint/recommended', 'plugin:react-hooks/recommended', 'plugin:@next/next/recommended', + 'prettier', ], rules: { '@typescript-eslint/no-empty-function': 'off', diff --git a/web/components/answers/answer-bet-panel.tsx b/web/components/answers/answer-bet-panel.tsx index 6dcba79b..238c7783 100644 --- a/web/components/answers/answer-bet-panel.tsx +++ b/web/components/answers/answer-bet-panel.tsx @@ -26,6 +26,7 @@ import { Bet } from 'common/bet' import { track } from 'web/lib/service/analytics' import { SignUpPrompt } from '../sign-up-prompt' import { isIOS } from 'web/lib/util/device' +import { AlertBox } from '../alert-box' export function AnswerBetPanel(props: { answer: Answer @@ -113,6 +114,8 @@ export function AnswerBetPanel(props: { const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0 const currentReturnPercent = formatPercent(currentReturn) + const bankrollFraction = (betAmount ?? 0) / (user?.balance ?? 1e9) + return (