diff --git a/common/comment.ts b/common/comment.ts index cdb62fd3..71c04af4 100644 --- a/common/comment.ts +++ b/common/comment.ts @@ -18,6 +18,7 @@ export type Comment = { userName: string userUsername: string userAvatarUrl?: string + bountiesAwarded?: number } & T export type OnContract = { diff --git a/common/contract.ts b/common/contract.ts index 248c9745..1255874d 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -62,6 +62,9 @@ export type Contract = { featuredOnHomeRank?: number likedByUserIds?: string[] likedByUserCount?: number + flaggedByUsernames?: string[] + openCommentBounties?: number + unlistedById?: string } & T export type BinaryContract = Contract & Binary diff --git a/common/economy.ts b/common/economy.ts index 7ec52b30..d9a9433a 100644 --- a/common/economy.ts +++ b/common/economy.ts @@ -11,7 +11,8 @@ export const REFERRAL_AMOUNT = econ?.REFERRAL_AMOUNT ?? 250 export const UNIQUE_BETTOR_BONUS_AMOUNT = econ?.UNIQUE_BETTOR_BONUS_AMOUNT ?? 10 export const BETTING_STREAK_BONUS_AMOUNT = - econ?.BETTING_STREAK_BONUS_AMOUNT ?? 10 -export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 50 + econ?.BETTING_STREAK_BONUS_AMOUNT ?? 5 +export const BETTING_STREAK_BONUS_MAX = econ?.BETTING_STREAK_BONUS_MAX ?? 25 export const BETTING_STREAK_RESET_HOUR = econ?.BETTING_STREAK_RESET_HOUR ?? 7 export const FREE_MARKETS_PER_USER_MAX = econ?.FREE_MARKETS_PER_USER_MAX ?? 5 +export const COMMENT_BOUNTY_AMOUNT = econ?.COMMENT_BOUNTY_AMOUNT ?? 250 diff --git a/common/envs/prod.ts b/common/envs/prod.ts index d0469d84..38dd4feb 100644 --- a/common/envs/prod.ts +++ b/common/envs/prod.ts @@ -41,6 +41,7 @@ export type Economy = { BETTING_STREAK_BONUS_MAX?: number BETTING_STREAK_RESET_HOUR?: number FREE_MARKETS_PER_USER_MAX?: number + COMMENT_BOUNTY_AMOUNT?: number } type FirebaseConfig = { diff --git a/common/group.ts b/common/group.ts index 5220a1e8..8f5728d3 100644 --- a/common/group.ts +++ b/common/group.ts @@ -23,6 +23,7 @@ export type Group = { score: number }[] } + pinnedItems: { itemId: string; type: 'post' | 'contract' }[] } export const MAX_GROUP_NAME_LENGTH = 75 diff --git a/common/like.ts b/common/like.ts index 38b25dad..7ec14726 100644 --- a/common/like.ts +++ b/common/like.ts @@ -5,4 +5,4 @@ export type Like = { createdTime: number tipTxnId?: string // only holds most recent tip txn id } -export const LIKE_TIP_AMOUNT = 5 +export const LIKE_TIP_AMOUNT = 10 diff --git a/common/new-contract.ts b/common/new-contract.ts index 431f435e..3580b164 100644 --- a/common/new-contract.ts +++ b/common/new-contract.ts @@ -12,7 +12,6 @@ import { visibility, } from './contract' import { User } from './user' -import { parseTags, richTextToString } from './util/parse' import { removeUndefinedProps } from './util/object' import { JSONContent } from '@tiptap/core' @@ -38,15 +37,6 @@ export function getNewContract( answers: string[], visibility: visibility ) { - const tags = parseTags( - [ - question, - richTextToString(description), - ...extraTags.map((tag) => `#${tag}`), - ].join(' ') - ) - const lowercaseTags = tags.map((tag) => tag.toLowerCase()) - const propsByOutcomeType = outcomeType === 'BINARY' ? getBinaryCpmmProps(initialProb, ante) // getBinaryDpmProps(initialProb, ante) @@ -70,8 +60,8 @@ export function getNewContract( question: question.trim(), description, - tags, - lowercaseTags, + tags: [], + lowercaseTags: [], visibility, isResolved: false, createdTime: Date.now(), diff --git a/common/notification.ts b/common/notification.ts index b42df541..b75e3d4a 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -96,6 +96,7 @@ type notification_descriptions = { [key in notification_preference]: { simple: string detailed: string + necessary?: boolean } } export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = { @@ -116,8 +117,8 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = { detailed: "Only answers by market creator on markets you're watching", }, betting_streaks: { - simple: 'For predictions made over consecutive days', - detailed: 'Bonuses for predictions made over consecutive days', + simple: `For prediction streaks`, + detailed: `Bonuses for predictions made over consecutive days (Prediction streaks)})`, }, comments_by_followed_users_on_watched_markets: { simple: 'Only comments by users you follow', @@ -159,8 +160,8 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = { detailed: 'Large changes in probability on markets that you watch', }, profit_loss_updates: { - simple: 'Weekly profit and loss updates', - detailed: 'Weekly profit and loss updates', + simple: 'Weekly portfolio updates', + detailed: 'Weekly portfolio updates', }, referral_bonuses: { simple: 'For referring new users', @@ -208,8 +209,9 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = { detailed: 'Bonuses for unique predictors on your markets', }, your_contract_closed: { - simple: 'Your market has closed and you need to resolve it', - detailed: 'Your market has closed and you need to resolve it', + simple: 'Your market has closed and you need to resolve it (necessary)', + detailed: 'Your market has closed and you need to resolve it (necessary)', + necessary: true, }, all_comments_on_watched_markets: { simple: 'All new comments', @@ -235,6 +237,11 @@ export const NOTIFICATION_DESCRIPTIONS: notification_descriptions = { simple: `Only on markets you're invested in`, detailed: `Answers on markets that you're watching and that you're invested in`, }, + opt_out_all: { + simple: 'Opt out of all notifications (excludes when your markets close)', + detailed: + 'Opt out of all notifications excluding your own market closure notifications', + }, } export type BettingStreakData = { diff --git a/common/post.ts b/common/post.ts index 13a90821..45503b22 100644 --- a/common/post.ts +++ b/common/post.ts @@ -12,7 +12,6 @@ export type Post = { export type DateDoc = Post & { bounty: number birthday: number - photoUrl: string type: 'date-doc' contractSlug: string } diff --git a/common/txn.ts b/common/txn.ts index 2b7a32e8..c404059d 100644 --- a/common/txn.ts +++ b/common/txn.ts @@ -8,6 +8,7 @@ type AnyTxnType = | UniqueBettorBonus | BettingStreakBonus | CancelUniqueBettorBonus + | CommentBountyRefund type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK' export type Txn = { @@ -31,6 +32,8 @@ export type Txn = { | 'UNIQUE_BETTOR_BONUS' | 'BETTING_STREAK_BONUS' | 'CANCEL_UNIQUE_BETTOR_BONUS' + | 'COMMENT_BOUNTY' + | 'REFUND_COMMENT_BOUNTY' // Any extra data data?: { [key: string]: any } @@ -98,6 +101,34 @@ type CancelUniqueBettorBonus = { } } +type CommentBountyDeposit = { + fromType: 'USER' + toType: 'BANK' + category: 'COMMENT_BOUNTY' + data: { + contractId: string + } +} + +type CommentBountyWithdrawal = { + fromType: 'BANK' + toType: 'USER' + category: 'COMMENT_BOUNTY' + data: { + contractId: string + commentId: string + } +} + +type CommentBountyRefund = { + fromType: 'BANK' + toType: 'USER' + category: 'REFUND_COMMENT_BOUNTY' + data: { + contractId: string + } +} + export type DonationTxn = Txn & Donation export type TipTxn = Txn & Tip export type ManalinkTxn = Txn & Manalink @@ -105,3 +136,5 @@ export type ReferralTxn = Txn & Referral export type BettingStreakBonusTxn = Txn & BettingStreakBonus export type UniqueBettorBonusTxn = Txn & UniqueBettorBonus export type CancelUniqueBettorBonusTxn = Txn & CancelUniqueBettorBonus +export type CommentBountyDepositTxn = Txn & CommentBountyDeposit +export type CommentBountyWithdrawalTxn = Txn & CommentBountyWithdrawal diff --git a/common/user-notification-preferences.ts b/common/user-notification-preferences.ts index 3fc0fb2f..ae199e77 100644 --- a/common/user-notification-preferences.ts +++ b/common/user-notification-preferences.ts @@ -53,6 +53,9 @@ export type notification_preferences = { profit_loss_updates: notification_destination_types[] onboarding_flow: notification_destination_types[] thank_you_for_purchases: notification_destination_types[] + + opt_out_all: notification_destination_types[] + // When adding a new notification preference, use add-new-notification-preference.ts to existing users } export const getDefaultNotificationPreferences = ( @@ -65,7 +68,7 @@ export const getDefaultNotificationPreferences = ( const email = noEmails ? undefined : emailIf ? 'email' : undefined return filterDefined([browser, email]) as notification_destination_types[] } - return { + const defaults: notification_preferences = { // Watched Markets all_comments_on_watched_markets: constructPref(true, false), all_answers_on_watched_markets: constructPref(true, false), @@ -107,7 +110,7 @@ export const getDefaultNotificationPreferences = ( loan_income: constructPref(true, false), betting_streaks: constructPref(true, false), referral_bonuses: constructPref(true, true), - unique_bettors_on_your_contract: constructPref(true, false), + unique_bettors_on_your_contract: constructPref(true, true), tipped_comments_on_watched_markets: constructPref(true, true), tips_on_your_markets: constructPref(true, true), limit_order_fills: constructPref(true, false), @@ -121,7 +124,10 @@ export const getDefaultNotificationPreferences = ( probability_updates_on_watched_markets: constructPref(true, false), thank_you_for_purchases: constructPref(false, false), onboarding_flow: constructPref(false, false), - } as notification_preferences + + opt_out_all: [], + } + return defaults } // Adding a new key:value here is optional, you can just use a key of notification_subscription_types @@ -184,10 +190,18 @@ export const getNotificationDestinationsForUser = ( ? notificationSettings[subscriptionType] : [] } + const optOutOfAllSettings = notificationSettings['opt_out_all'] + // Your market closure notifications are high priority, opt-out doesn't affect their delivery + const optedOutOfEmail = + optOutOfAllSettings.includes('email') && + subscriptionType !== 'your_contract_closed' + const optedOutOfBrowser = + optOutOfAllSettings.includes('browser') && + subscriptionType !== 'your_contract_closed' const unsubscribeEndpoint = getFunctionUrl('unsubscribe') return { - sendToEmail: destinations.includes('email'), - sendToBrowser: destinations.includes('browser'), + sendToEmail: destinations.includes('email') && !optedOutOfEmail, + sendToBrowser: destinations.includes('browser') && !optedOutOfBrowser, unsubscribeUrl: `${unsubscribeEndpoint}?id=${privateUser.id}&type=${subscriptionType}`, urlToManageThisNotification: `${DOMAIN}/notifications?tab=settings§ion=${subscriptionType}`, } diff --git a/common/user.ts b/common/user.ts index b1365929..233fe4cc 100644 --- a/common/user.ts +++ b/common/user.ts @@ -33,6 +33,8 @@ export type User = { allTime: number } + fractionResolvedCorrectly: number + nextLoanCached: number followerCountCached: number diff --git a/common/util/format.ts b/common/util/format.ts index 4f123535..fd3e7551 100644 --- a/common/util/format.ts +++ b/common/util/format.ts @@ -8,7 +8,14 @@ const formatter = new Intl.NumberFormat('en-US', { }) export function formatMoney(amount: number) { - const newAmount = Math.round(amount) === 0 ? 0 : Math.floor(amount) // handle -0 case + const newAmount = + // handle -0 case + Math.round(amount) === 0 + ? 0 + : // Handle 499.9999999999999 case + (amount > 0 ? Math.floor : Math.ceil)( + amount + 0.00000000001 * Math.sign(amount) + ) return ENV_CONFIG.moneyMoniker + formatter.format(newAmount).replace('$', '') } diff --git a/common/util/parse.ts b/common/util/parse.ts index 0bbd5cd9..0c50e07d 100644 --- a/common/util/parse.ts +++ b/common/util/parse.ts @@ -1,4 +1,3 @@ -import { MAX_TAG_LENGTH } from '../contract' import { generateText, JSONContent } from '@tiptap/core' // Tiptap starter extensions import { Blockquote } from '@tiptap/extension-blockquote' @@ -24,7 +23,8 @@ import { Mention } from '@tiptap/extension-mention' import Iframe from './tiptap-iframe' import TiptapTweet from './tiptap-tweet-type' import { find } from 'linkifyjs' -import { uniq } from 'lodash' +import { cloneDeep, uniq } from 'lodash' +import { TiptapSpoiler } from './tiptap-spoiler' /** get first url in text. like "notion.so " -> "http://notion.so"; "notion" -> null */ export function getUrl(text: string) { @@ -32,34 +32,6 @@ export function getUrl(text: string) { return results.length ? results[0].href : null } -export function parseTags(text: string) { - const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi - const matches = (text.match(regex) || []).map((match) => - match.trim().substring(1).substring(0, MAX_TAG_LENGTH) - ) - const tagSet = new Set() - const uniqueTags: string[] = [] - // Keep casing of last tag. - matches.reverse() - for (const tag of matches) { - const lowercase = tag.toLowerCase() - if (!tagSet.has(lowercase)) { - tagSet.add(lowercase) - uniqueTags.push(tag) - } - } - uniqueTags.reverse() - return uniqueTags -} - -export function parseWordsAsTags(text: string) { - const taggedText = text - .split(/\s+/) - .map((tag) => (tag.startsWith('#') ? tag : `#${tag}`)) - .join(' ') - return parseTags(taggedText) -} - // TODO: fuzzy matching export const wordIn = (word: string, corpus: string) => corpus.toLocaleLowerCase().includes(word.toLocaleLowerCase()) @@ -103,8 +75,22 @@ export const exhibitExts = [ Mention, Iframe, TiptapTweet, + TiptapSpoiler, ] export function richTextToString(text?: JSONContent) { - return !text ? '' : generateText(text, exhibitExts) + if (!text) return '' + // remove spoiler tags. + const newText = cloneDeep(text) + dfs(newText, (current) => { + if (current.marks?.some((m) => m.type === TiptapSpoiler.name)) { + current.text = '[spoiler]' + } + }) + return generateText(newText, exhibitExts) +} + +const dfs = (data: JSONContent, f: (current: JSONContent) => any) => { + data.content?.forEach((d) => dfs(d, f)) + f(data) } diff --git a/common/util/tiptap-spoiler.ts b/common/util/tiptap-spoiler.ts new file mode 100644 index 00000000..c8944a46 --- /dev/null +++ b/common/util/tiptap-spoiler.ts @@ -0,0 +1,116 @@ +// adapted from @n8body/tiptap-spoiler + +import { + Mark, + markInputRule, + markPasteRule, + mergeAttributes, +} from '@tiptap/core' +import type { ElementType } from 'react' + +declare module '@tiptap/core' { + interface Commands { + spoilerEditor: { + setSpoiler: () => ReturnType + toggleSpoiler: () => ReturnType + unsetSpoiler: () => ReturnType + } + } +} + +export type SpoilerOptions = { + HTMLAttributes: Record + spoilerOpenClass: string + spoilerCloseClass?: string + inputRegex: RegExp + pasteRegex: RegExp + as: ElementType +} + +const spoilerInputRegex = /(?:^|\s)((?:\|\|)((?:[^||]+))(?:\|\|))$/ +const spoilerPasteRegex = /(?:^|\s)((?:\|\|)((?:[^||]+))(?:\|\|))/g + +export const TiptapSpoiler = Mark.create({ + name: 'spoiler', + + inline: true, + group: 'inline', + inclusive: false, + exitable: true, + content: 'inline*', + + priority: 1001, // higher priority than other formatting so they go inside + + addOptions() { + return { + HTMLAttributes: { 'aria-label': 'spoiler' }, + spoilerOpenClass: '', + spoilerCloseClass: undefined, + inputRegex: spoilerInputRegex, + pasteRegex: spoilerPasteRegex, + as: 'span', + editing: false, + } + }, + + addCommands() { + return { + setSpoiler: + () => + ({ commands }) => + commands.setMark(this.name), + toggleSpoiler: + () => + ({ commands }) => + commands.toggleMark(this.name), + unsetSpoiler: + () => + ({ commands }) => + commands.unsetMark(this.name), + } + }, + + addInputRules() { + return [ + markInputRule({ + find: this.options.inputRegex, + type: this.type, + }), + ] + }, + + addPasteRules() { + return [ + markPasteRule({ + find: this.options.pasteRegex, + type: this.type, + }), + ] + }, + + parseHTML() { + return [ + { + tag: 'span', + getAttrs: (node) => + (node as HTMLElement).ariaLabel?.toLowerCase() === 'spoiler' && null, + }, + ] + }, + + renderHTML({ HTMLAttributes }) { + const elem = document.createElement(this.options.as as string) + + Object.entries( + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { + class: this.options.spoilerCloseClass ?? this.options.spoilerOpenClass, + }) + ).forEach(([attr, val]) => elem.setAttribute(attr, val)) + + elem.addEventListener('click', () => { + elem.setAttribute('class', this.options.spoilerOpenClass) + }) + + return elem + }, +}) diff --git a/docs/docs/api.md b/docs/docs/api.md index 5fc95a4a..d25a18be 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -65,21 +65,21 @@ Requires no authorization. Gets a group by its slug. -Requires no authorization. +Requires no authorization. Note: group is singular in the URL. ### `GET /v0/group/by-id/[id]` Gets a group by its unique ID. -Requires no authorization. +Requires no authorization. Note: group is singular in the URL. ### `GET /v0/group/by-id/[id]/markets` Gets a group's markets by its unique ID. -Requires no authorization. +Requires no authorization. Note: group is singular in the URL. ### `GET /v0/markets` @@ -158,13 +158,16 @@ Requires no authorization. // i.e. https://manifold.markets/Austin/test-market is the same as https://manifold.markets/foo/test-market url: string - outcomeType: string // BINARY, FREE_RESPONSE, or NUMERIC + outcomeType: string // BINARY, FREE_RESPONSE, MULTIPLE_CHOICE, NUMERIC, or PSEUDO_NUMERIC mechanism: string // dpm-2 or cpmm-1 probability: number pool: { outcome: number } // For CPMM markets, the number of shares in the liquidity pool. For DPM markets, the amount of mana invested in each answer. p?: number // CPMM markets only, probability constant in y^p * n^(1-p) = k totalLiquidity?: number // CPMM markets only, the amount of mana deposited into the liquidity pool + min?: number // PSEUDO_NUMERIC markets only, the minimum resolvable value + max?: number // PSEUDO_NUMERIC markets only, the maximum resolvable value + isLogScale?: bool // PSEUDO_NUMERIC markets only, if true `number = (max - min + 1)^probability + minstart - 1`, otherwise `number = min + (max - min) * probability` volume: number volume7Days: number @@ -408,7 +411,7 @@ Requires no authorization. type FullMarket = LiteMarket & { bets: Bet[] comments: Comment[] - answers?: Answer[] + answers?: Answer[] // dpm-2 markets only description: JSONContent // Rich text content. See https://tiptap.dev/guide/output#option-1-json textDescription: string // string description without formatting, images, or embeds } @@ -554,7 +557,7 @@ Creates a new market on behalf of the authorized user. Parameters: -- `outcomeType`: Required. One of `BINARY`, `FREE_RESPONSE`, or `NUMERIC`. +- `outcomeType`: Required. One of `BINARY`, `FREE_RESPONSE`, `MULTIPLE_CHOICE`, or `PSEUDO_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). @@ -569,6 +572,12 @@ For numeric markets, you must also provide: - `min`: The minimum value that the market may resolve to. - `max`: The maximum value that the market may resolve to. +- `isLogScale`: If true, your numeric market will increase exponentially from min to max. +- `initialValue`: An initial value for the market, between min and max, exclusive. + +For multiple choice markets, you must also provide: + +- `answers`: An array of strings, each of which will be a valid answer for the market. Example request: @@ -605,15 +614,18 @@ For binary markets: - `outcome`: Required. One of `YES`, `NO`, `MKT`, or `CANCEL`. - `probabilityInt`: Optional. The probability to use for `MKT` resolution. -For free response markets: +For free response or multiple choice markets: - `outcome`: Required. One of `MKT`, `CANCEL`, or a `number` indicating the answer index. -- `resolutions`: An array of `{ answer, pct }` objects to use as the weights for resolving in favor of multiple free response options. Can only be set with `MKT` outcome. +- `resolutions`: An array of `{ answer, pct }` objects to use as the weights for resolving in favor of multiple free response options. Can only be set with `MKT` outcome. Note that the total weights must add to 100. For numeric markets: - `outcome`: Required. One of `CANCEL`, or a `number` indicating the selected numeric bucket ID. - `value`: The value that the market may resolves to. +- `probabilityInt`: Required if `value` is present. Should be equal to + - If log scale: `log10(value - min + 1) / log10(max - min + 1)` + - Otherwise: `(value - min) / (max - min)` Example request: @@ -757,6 +769,7 @@ Requires no authorization. ## Changelog +- 2022-09-24: Expand market POST docs to include new market types (`PSEUDO_NUMERIC`, `MULTIPLE_CHOICE`) - 2022-07-15: Add user by username and user by ID APIs - 2022-06-08: Add paging to markets endpoint - 2022-06-05: Add new authorized write endpoints diff --git a/firestore.rules b/firestore.rules index 26649fa6..50f93e1f 100644 --- a/firestore.rules +++ b/firestore.rules @@ -102,7 +102,7 @@ service cloud.firestore { allow update: if request.resource.data.diff(resource.data).affectedKeys() .hasOnly(['tags', 'lowercaseTags', 'groupSlugs', 'groupLinks']); allow update: if request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['description', 'closeTime', 'question']) + .hasOnly(['description', 'closeTime', 'question', 'visibility', 'unlistedById']) && resource.data.creatorId == request.auth.uid; allow update: if isAdmin(); match /comments/{commentId} { @@ -176,7 +176,7 @@ service cloud.firestore { allow update: if (request.auth.uid == resource.data.creatorId || isAdmin()) && request.resource.data.diff(resource.data) .affectedKeys() - .hasOnly(['name', 'about', 'anyoneCanJoin', 'aboutPostId' ]); + .hasOnly(['name', 'about', 'anyoneCanJoin', 'aboutPostId', 'pinnedItems' ]); allow delete: if request.auth.uid == resource.data.creatorId; match /groupContracts/{contractId} { diff --git a/functions/src/create-group.ts b/functions/src/create-group.ts index 76dc1298..4b3f7446 100644 --- a/functions/src/create-group.ts +++ b/functions/src/create-group.ts @@ -62,6 +62,7 @@ export const creategroup = newEndpoint({}, async (req, auth) => { totalContracts: 0, totalMembers: memberIds.length, postIds: [], + pinnedItems: [], } await groupRef.create(group) diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 038e0142..9bd73d05 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -1046,3 +1046,47 @@ export const createContractResolvedNotifications = async ( ) ) } + +export const createBountyNotification = async ( + fromUser: User, + toUserId: string, + amount: number, + idempotencyKey: string, + contract: Contract, + commentId?: string +) => { + const privateUser = await getPrivateUser(toUserId) + if (!privateUser) return + const { sendToBrowser } = getNotificationDestinationsForUser( + privateUser, + 'tip_received' + ) + if (!sendToBrowser) return + + const slug = commentId + const notificationRef = firestore + .collection(`/users/${toUserId}/notifications`) + .doc(idempotencyKey) + const notification: Notification = { + id: idempotencyKey, + userId: toUserId, + reason: 'tip_received', + createdTime: Date.now(), + isSeen: false, + sourceId: commentId ? commentId : contract.id, + sourceType: 'tip', + sourceUpdateType: 'created', + sourceUserName: fromUser.name, + sourceUserUsername: fromUser.username, + sourceUserAvatarUrl: fromUser.avatarUrl, + sourceText: amount.toString(), + sourceContractCreatorUsername: contract.creatorUsername, + sourceContractTitle: contract.question, + sourceContractSlug: contract.slug, + sourceSlug: slug, + sourceTitle: contract.question, + } + return await notificationRef.set(removeUndefinedProps(notification)) + + // maybe TODO: send email notification to comment creator +} diff --git a/functions/src/create-post.ts b/functions/src/create-post.ts index a342dc05..e9d6ae8f 100644 --- a/functions/src/create-post.ts +++ b/functions/src/create-post.ts @@ -42,7 +42,6 @@ const postSchema = z.object({ // Date doc fields: bounty: z.number().optional(), birthday: z.number().optional(), - photoUrl: z.string().optional(), type: z.string().optional(), question: z.string().optional(), }) @@ -76,6 +75,8 @@ export const createpost = newEndpoint({}, async (req, auth) => { outcomeType: 'BINARY', visibility: 'unlisted', initialProb: 50, + // Dating group! + groupId: 'j3ZE8fkeqiKmRGumy3O1', }, auth ) diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts index ab70b4e6..c3b7ba1d 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -69,6 +69,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => { followerCountCached: 0, followedCategories: DEFAULT_CATEGORIES, shouldShowWelcome: true, + fractionResolvedCorrectly: 1, } await firestore.collection('users').doc(auth.uid).create(user) diff --git a/functions/src/email-templates/market-close.html b/functions/src/email-templates/market-close.html index 4abd225e..b742c533 100644 --- a/functions/src/email-templates/market-close.html +++ b/functions/src/email-templates/market-close.html @@ -483,11 +483,7 @@ color: #999; text-decoration: underline; margin: 0; - ">our Discord! Or, - click here to unsubscribe from this type of notification. + ">our Discord! diff --git a/functions/src/email-templates/weekly-portfolio-update.html b/functions/src/email-templates/weekly-portfolio-update.html index fd99837f..921a58e5 100644 --- a/functions/src/email-templates/weekly-portfolio-update.html +++ b/functions/src/email-templates/weekly-portfolio-update.html @@ -320,7 +320,7 @@ style="line-height: 24px; margin: 10px 0; margin-top: 20px; margin-bottom: 20px;" data-testid="4XoHRGw1Y"> - And here's some of the biggest changes in your portfolio: + And here's some recent changes in your investments:

diff --git a/functions/src/emails.ts b/functions/src/emails.ts index dd91789a..993fac81 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -20,7 +20,7 @@ import { getNotificationDestinationsForUser } from '../../common/user-notificati import { PerContractInvestmentsData, OverallPerformanceData, -} from 'functions/src/weekly-portfolio-emails' +} from './weekly-portfolio-emails' export const sendMarketResolutionEmail = async ( reason: notification_reason_types, @@ -643,8 +643,8 @@ export const sendWeeklyPortfolioUpdateEmail = async ( templateData[`question${i + 1}Title`] = investment.questionTitle templateData[`question${i + 1}Url`] = investment.questionUrl templateData[`question${i + 1}Prob`] = investment.questionProb - templateData[`question${i + 1}Change`] = investment.questionChange - templateData[`question${i + 1}ChangeStyle`] = investment.questionChangeStyle + templateData[`question${i + 1}Change`] = formatMoney(investment.profit) + templateData[`question${i + 1}ChangeStyle`] = investment.profitStyle }) await sendTemplateEmail( diff --git a/functions/src/index.ts b/functions/src/index.ts index 9a8ec232..f5c45004 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -52,6 +52,7 @@ export * from './unsubscribe' export * from './stripe' export * from './mana-bonus-email' export * from './close-market' +export * from './update-comment-bounty' import { health } from './health' import { transact } from './transact' @@ -65,6 +66,7 @@ import { sellshares } from './sell-shares' import { claimmanalink } from './claim-manalink' import { createmarket } from './create-market' import { addliquidity } from './add-liquidity' +import { addcommentbounty, awardcommentbounty } from './update-comment-bounty' import { withdrawliquidity } from './withdraw-liquidity' import { creategroup } from './create-group' import { resolvemarket } from './resolve-market' @@ -91,6 +93,8 @@ const sellSharesFunction = toCloudFunction(sellshares) const claimManalinkFunction = toCloudFunction(claimmanalink) const createMarketFunction = toCloudFunction(createmarket) const addLiquidityFunction = toCloudFunction(addliquidity) +const addCommentBounty = toCloudFunction(addcommentbounty) +const awardCommentBounty = toCloudFunction(awardcommentbounty) const withdrawLiquidityFunction = toCloudFunction(withdrawliquidity) const createGroupFunction = toCloudFunction(creategroup) const resolveMarketFunction = toCloudFunction(resolvemarket) @@ -127,4 +131,6 @@ export { acceptChallenge as acceptchallenge, createPostFunction as createpost, saveTwitchCredentials as savetwitchcredentials, + addCommentBounty as addcommentbounty, + awardCommentBounty as awardcommentbounty, } diff --git a/functions/src/on-update-contract.ts b/functions/src/on-update-contract.ts index 5e2a94c0..301d6286 100644 --- a/functions/src/on-update-contract.ts +++ b/functions/src/on-update-contract.ts @@ -7,38 +7,47 @@ export const onUpdateContract = functions.firestore .document('contracts/{contractId}') .onUpdate(async (change, context) => { const contract = change.after.data() as Contract + const previousContract = change.before.data() as Contract const { eventId } = context - - const contractUpdater = await getUser(contract.creatorId) - if (!contractUpdater) throw new Error('Could not find contract updater') - - const previousValue = change.before.data() as Contract - - // Resolution is handled in resolve-market.ts - if (!previousValue.isResolved && contract.isResolved) return + const { openCommentBounties, closeTime, question } = contract if ( - previousValue.closeTime !== contract.closeTime || - previousValue.question !== contract.question + !previousContract.isResolved && + contract.isResolved && + (openCommentBounties ?? 0) > 0 ) { - let sourceText = '' - if ( - previousValue.closeTime !== contract.closeTime && - contract.closeTime - ) { - sourceText = contract.closeTime.toString() - } else if (previousValue.question !== contract.question) { - sourceText = contract.question - } - - await createCommentOrAnswerOrUpdatedContractNotification( - contract.id, - 'contract', - 'updated', - contractUpdater, - eventId, - sourceText, - contract - ) + // No need to notify users of resolution, that's handled in resolve-market + return + } + if ( + previousContract.closeTime !== closeTime || + previousContract.question !== question + ) { + await handleUpdatedCloseTime(previousContract, contract, eventId) } }) + +async function handleUpdatedCloseTime( + previousContract: Contract, + contract: Contract, + eventId: string +) { + const contractUpdater = await getUser(contract.creatorId) + if (!contractUpdater) throw new Error('Could not find contract updater') + let sourceText = '' + if (previousContract.closeTime !== contract.closeTime && contract.closeTime) { + sourceText = contract.closeTime.toString() + } else if (previousContract.question !== contract.question) { + sourceText = contract.question + } + + await createCommentOrAnswerOrUpdatedContractNotification( + contract.id, + 'contract', + 'updated', + contractUpdater, + eventId, + sourceText, + contract + ) +} diff --git a/functions/src/scripts/add-new-notification-preference.ts b/functions/src/scripts/add-new-notification-preference.ts new file mode 100644 index 00000000..d7e7072b --- /dev/null +++ b/functions/src/scripts/add-new-notification-preference.ts @@ -0,0 +1,27 @@ +import * as admin from 'firebase-admin' + +import { initAdmin } from './script-init' +import { getAllPrivateUsers } from 'functions/src/utils' +initAdmin() + +const firestore = admin.firestore() + +async function main() { + const privateUsers = await getAllPrivateUsers() + await Promise.all( + privateUsers.map((privateUser) => { + if (!privateUser.id) return Promise.resolve() + return firestore + .collection('private-users') + .doc(privateUser.id) + .update({ + notificationPreferences: { + ...privateUser.notificationPreferences, + opt_out_all: [], + }, + }) + }) + ) +} + +if (require.main === module) main().then(() => process.exit()) diff --git a/functions/src/scripts/contest/bulk-add-liquidity.ts b/functions/src/scripts/contest/bulk-add-liquidity.ts index 99d5f12b..e29fb0a9 100644 --- a/functions/src/scripts/contest/bulk-add-liquidity.ts +++ b/functions/src/scripts/contest/bulk-add-liquidity.ts @@ -50,3 +50,5 @@ async function main() { } } main() + +export {} diff --git a/functions/src/scripts/contest/bulk-resolve-markets.ts b/functions/src/scripts/contest/bulk-resolve-markets.ts new file mode 100644 index 00000000..8008db8b --- /dev/null +++ b/functions/src/scripts/contest/bulk-resolve-markets.ts @@ -0,0 +1,65 @@ +// Run with `npx ts-node src/scripts/contest/resolve-markets.ts` + +const DOMAIN = 'dev.manifold.markets' +// Dev API key for Cause Exploration Prizes (@CEP) +const API_KEY = '188f014c-0ba2-4c35-9e6d-88252e281dbf' +const GROUP_SLUG = 'cart-contest' + +// Can just curl /v0/group/{slug} to get a group +async function getGroupBySlug(slug: string) { + const resp = await fetch(`https://${DOMAIN}/api/v0/group/${slug}`) + return await resp.json() +} + +async function getMarketsByGroupId(id: string) { + // API structure: /v0/group/by-id/[id]/markets + const resp = await fetch(`https://${DOMAIN}/api/v0/group/by-id/${id}/markets`) + return await resp.json() +} + +/* Example curl request: +# Resolve a binary market +$ curl https://manifold.markets/api/v0/market/{marketId}/resolve -X POST \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Key {...}' \ + --data-raw '{"outcome": "YES"}' +*/ +async function resolveMarketById( + id: string, + outcome: 'YES' | 'NO' | 'MKT' | 'CANCEL' +) { + const resp = await fetch(`https://${DOMAIN}/api/v0/market/${id}/resolve`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Key ${API_KEY}`, + }, + body: JSON.stringify({ + outcome, + }), + }) + return await resp.json() +} + +async function main() { + const group = await getGroupBySlug(GROUP_SLUG) + const markets = await getMarketsByGroupId(group.id) + + // Count up some metrics + console.log('Number of markets', markets.length) + console.log( + 'Number of resolved markets', + markets.filter((m: any) => m.isResolved).length + ) + + // Resolve each market to NO + for (const market of markets) { + if (!market.isResolved) { + console.log(`Resolving market ${market.url} to NO`) + await resolveMarketById(market.id, 'NO') + } + } +} +main() + +export {} diff --git a/functions/src/scripts/convert-tag-to-group.ts b/functions/src/scripts/convert-tag-to-group.ts index b2e4c4d8..e1330fe1 100644 --- a/functions/src/scripts/convert-tag-to-group.ts +++ b/functions/src/scripts/convert-tag-to-group.ts @@ -42,6 +42,7 @@ const createGroup = async ( totalContracts: contracts.length, totalMembers: 1, postIds: [], + pinnedItems: [], } await groupRef.create(group) // create a GroupMemberDoc for the creator diff --git a/functions/src/scripts/update-contract-tags.ts b/functions/src/scripts/update-contract-tags.ts deleted file mode 100644 index 37a2b60a..00000000 --- a/functions/src/scripts/update-contract-tags.ts +++ /dev/null @@ -1,46 +0,0 @@ -import * as admin from 'firebase-admin' -import { uniq } from 'lodash' - -import { initAdmin } from './script-init' -initAdmin() - -import { Contract } from '../../../common/contract' -import { parseTags } from '../../../common/util/parse' -import { getValues } from '../utils' - -async function updateContractTags() { - const firestore = admin.firestore() - console.log('Updating contracts tags') - - const contracts = await getValues(firestore.collection('contracts')) - - console.log('Loaded', contracts.length, 'contracts') - - for (const contract of contracts) { - const contractRef = firestore.doc(`contracts/${contract.id}`) - - const tags = uniq([ - ...parseTags(contract.question + contract.description), - ...(contract.tags ?? []), - ]) - const lowercaseTags = tags.map((tag) => tag.toLowerCase()) - - console.log( - 'Updating tags', - contract.slug, - 'from', - contract.tags, - 'to', - tags - ) - - await contractRef.update({ - tags, - lowercaseTags, - } as Partial) - } -} - -if (require.main === module) { - updateContractTags().then(() => process.exit()) -} diff --git a/functions/src/serve.ts b/functions/src/serve.ts index 99ac6281..d861dcbc 100644 --- a/functions/src/serve.ts +++ b/functions/src/serve.ts @@ -29,6 +29,7 @@ import { getcurrentuser } from './get-current-user' import { createpost } from './create-post' import { savetwitchcredentials } from './save-twitch-credentials' import { testscheduledfunction } from './test-scheduled-function' +import { addcommentbounty, awardcommentbounty } from './update-comment-bounty' type Middleware = (req: Request, res: Response, next: NextFunction) => void const app = express() @@ -61,6 +62,8 @@ addJsonEndpointRoute('/sellshares', sellshares) addJsonEndpointRoute('/claimmanalink', claimmanalink) addJsonEndpointRoute('/createmarket', createmarket) addJsonEndpointRoute('/addliquidity', addliquidity) +addJsonEndpointRoute('/addCommentBounty', addcommentbounty) +addJsonEndpointRoute('/awardCommentBounty', awardcommentbounty) addJsonEndpointRoute('/withdrawliquidity', withdrawliquidity) addJsonEndpointRoute('/creategroup', creategroup) addJsonEndpointRoute('/resolvemarket', resolvemarket) diff --git a/functions/src/unsubscribe.ts b/functions/src/unsubscribe.ts index 418282c7..57a6d183 100644 --- a/functions/src/unsubscribe.ts +++ b/functions/src/unsubscribe.ts @@ -4,6 +4,7 @@ import { getPrivateUser } from './utils' import { PrivateUser } from '../../common/user' import { NOTIFICATION_DESCRIPTIONS } from '../../common/notification' import { notification_preference } from '../../common/user-notification-preferences' +import { getFunctionUrl } from '../../common/api' export const unsubscribe: EndpointDefinition = { opts: { method: 'GET', minInstances: 1 }, @@ -20,6 +21,8 @@ export const unsubscribe: EndpointDefinition = { res.status(400).send('Invalid subscription type parameter.') return } + const optOutAllType: notification_preference = 'opt_out_all' + const wantsToOptOutAll = notificationSubscriptionType === optOutAllType const user = await getPrivateUser(id) @@ -37,17 +40,22 @@ export const unsubscribe: EndpointDefinition = { const update: Partial = { notificationPreferences: { ...user.notificationPreferences, - [notificationSubscriptionType]: previousDestinations.filter( - (destination) => destination !== 'email' - ), + [notificationSubscriptionType]: wantsToOptOutAll + ? previousDestinations.push('email') + : previousDestinations.filter( + (destination) => destination !== 'email' + ), }, } await firestore.collection('private-users').doc(id).update(update) + const unsubscribeEndpoint = getFunctionUrl('unsubscribe') - res.send( - ` - + const optOutAllUrl = `${unsubscribeEndpoint}?id=${id}&type=${optOutAllType}` + if (wantsToOptOutAll) { + res.send( + ` + @@ -163,19 +171,6 @@ export const unsubscribe: EndpointDefinition = { - - -
-

- Hello!

-
- - @@ -186,20 +181,9 @@ export const unsubscribe: EndpointDefinition = { data-testid="4XoHRGw1Y"> - ${email} has been unsubscribed from email notifications related to: + ${email} has opted out of receiving unnecessary email notifications -
-
- ${NOTIFICATION_DESCRIPTIONS[notificationSubscriptionType].detailed}. -

-
-
-
- Click - here - to manage the rest of your notification settings. - @@ -219,9 +203,193 @@ export const unsubscribe: EndpointDefinition = { +` + ) + } else { + res.send( + ` + + + + + Manifold Markets 7th Day Anniversary Gift! + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + + + + +
+ + banner logo + +
+
+

+ Hello!

+
+
+
+

+ + ${email} has been unsubscribed from email notifications related to: + +
+
+ + ${NOTIFICATION_DESCRIPTIONS[notificationSubscriptionType].detailed}. +

+
+
+
+ Click + here + to unsubscribe from all unnecessary emails. + +
+
+ Click + here + to manage the rest of your notification settings. + +
+ +
+

+
+
+
+
+
+ ` - ) + ) + } }, } diff --git a/functions/src/update-comment-bounty.ts b/functions/src/update-comment-bounty.ts new file mode 100644 index 00000000..af1d6c0a --- /dev/null +++ b/functions/src/update-comment-bounty.ts @@ -0,0 +1,162 @@ +import * as admin from 'firebase-admin' +import { z } from 'zod' + +import { Contract } from '../../common/contract' +import { User } from '../../common/user' +import { removeUndefinedProps } from '../../common/util/object' +import { APIError, newEndpoint, validate } from './api' +import { + DEV_HOUSE_LIQUIDITY_PROVIDER_ID, + HOUSE_LIQUIDITY_PROVIDER_ID, +} from '../../common/antes' +import { isProd } from './utils' +import { + CommentBountyDepositTxn, + CommentBountyWithdrawalTxn, +} from '../../common/txn' +import { runTxn } from './transact' +import { Comment } from '../../common/comment' +import { createBountyNotification } from './create-notification' + +const bodySchema = z.object({ + contractId: z.string(), + amount: z.number().gt(0), +}) +const awardBodySchema = z.object({ + contractId: z.string(), + commentId: z.string(), + amount: z.number().gt(0), +}) + +export const addcommentbounty = newEndpoint({}, async (req, auth) => { + const { amount, contractId } = validate(bodySchema, req.body) + + if (!isFinite(amount)) throw new APIError(400, 'Invalid amount') + + // run as transaction to prevent race conditions + return await firestore.runTransaction(async (transaction) => { + const userDoc = firestore.doc(`users/${auth.uid}`) + const userSnap = await transaction.get(userDoc) + if (!userSnap.exists) throw new APIError(400, 'User not found') + const user = userSnap.data() as User + + const contractDoc = firestore.doc(`contracts/${contractId}`) + const contractSnap = await transaction.get(contractDoc) + if (!contractSnap.exists) throw new APIError(400, 'Invalid contract') + const contract = contractSnap.data() as Contract + + if (user.balance < amount) + throw new APIError(400, 'Insufficient user balance') + + const newCommentBountyTxn = { + fromId: user.id, + fromType: 'USER', + toId: isProd() + ? HOUSE_LIQUIDITY_PROVIDER_ID + : DEV_HOUSE_LIQUIDITY_PROVIDER_ID, + toType: 'BANK', + amount, + token: 'M$', + category: 'COMMENT_BOUNTY', + data: { + contractId, + }, + description: `Deposit M$${amount} from ${user.id} for comment bounty for contract ${contractId}`, + } as CommentBountyDepositTxn + + const result = await runTxn(transaction, newCommentBountyTxn) + + transaction.update( + contractDoc, + removeUndefinedProps({ + openCommentBounties: (contract.openCommentBounties ?? 0) + amount, + }) + ) + + return result + }) +}) +export const awardcommentbounty = newEndpoint({}, async (req, auth) => { + const { amount, commentId, contractId } = validate(awardBodySchema, req.body) + + if (!isFinite(amount)) throw new APIError(400, 'Invalid amount') + + // run as transaction to prevent race conditions + const res = await firestore.runTransaction(async (transaction) => { + const userDoc = firestore.doc(`users/${auth.uid}`) + const userSnap = await transaction.get(userDoc) + if (!userSnap.exists) throw new APIError(400, 'User not found') + const user = userSnap.data() as User + + const contractDoc = firestore.doc(`contracts/${contractId}`) + const contractSnap = await transaction.get(contractDoc) + if (!contractSnap.exists) throw new APIError(400, 'Invalid contract') + const contract = contractSnap.data() as Contract + + if (user.id !== contract.creatorId) + throw new APIError( + 400, + 'Only contract creator can award comment bounties' + ) + + const commentDoc = firestore.doc( + `contracts/${contractId}/comments/${commentId}` + ) + const commentSnap = await transaction.get(commentDoc) + if (!commentSnap.exists) throw new APIError(400, 'Invalid comment') + + const comment = commentSnap.data() as Comment + const amountAvailable = contract.openCommentBounties ?? 0 + if (amountAvailable < amount) + throw new APIError(400, 'Insufficient open bounty balance') + + const newCommentBountyTxn = { + fromId: isProd() + ? HOUSE_LIQUIDITY_PROVIDER_ID + : DEV_HOUSE_LIQUIDITY_PROVIDER_ID, + fromType: 'BANK', + toId: comment.userId, + toType: 'USER', + amount, + token: 'M$', + category: 'COMMENT_BOUNTY', + data: { + contractId, + commentId, + }, + description: `Withdrawal M$${amount} from BANK for comment ${comment.id} bounty for contract ${contractId}`, + } as CommentBountyWithdrawalTxn + + const result = await runTxn(transaction, newCommentBountyTxn) + + await transaction.update( + contractDoc, + removeUndefinedProps({ + openCommentBounties: amountAvailable - amount, + }) + ) + await transaction.update( + commentDoc, + removeUndefinedProps({ + bountiesAwarded: (comment.bountiesAwarded ?? 0) + amount, + }) + ) + + return { ...result, comment, contract, user } + }) + if (res.txn?.id) { + const { comment, contract, user } = res + await createBountyNotification( + user, + comment.userId, + amount, + res.txn.id, + contract, + comment.id + ) + } + + return res +}) + +const firestore = admin.firestore() diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts index d2b5f9b2..70c7c742 100644 --- a/functions/src/update-metrics.ts +++ b/functions/src/update-metrics.ts @@ -17,7 +17,8 @@ import { computeVolume, } from '../../common/calculate-metrics' import { getProbability } from '../../common/calculate' -import { Group } from 'common/group' +import { Group } from '../../common/group' +import { batchedWaitAll } from '../../common/util/promise' const firestore = admin.firestore() @@ -27,28 +28,46 @@ export const updateMetrics = functions .onRun(updateMetricsCore) export async function updateMetricsCore() { - const [users, contracts, bets, allPortfolioHistories, groups] = - await Promise.all([ - getValues(firestore.collection('users')), - getValues(firestore.collection('contracts')), - getValues(firestore.collectionGroup('bets')), - getValues( - firestore - .collectionGroup('portfolioHistory') - .where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago - ), - getValues(firestore.collection('groups')), - ]) + console.log('Loading users') + const users = await getValues(firestore.collection('users')) + console.log('Loading contracts') + const contracts = await getValues(firestore.collection('contracts')) + + console.log('Loading portfolio history') + const allPortfolioHistories = await getValues( + firestore + .collectionGroup('portfolioHistory') + .where('timestamp', '>', Date.now() - 31 * DAY_MS) // so it includes just over a month ago + ) + + console.log('Loading groups') + const groups = await getValues(firestore.collection('groups')) + + console.log('Loading bets') + const contractBets = await batchedWaitAll( + contracts + .filter((c) => c.id) + .map( + (c) => () => + getValues( + firestore.collection('contracts').doc(c.id).collection('bets') + ) + ), + 100 + ) + const bets = contractBets.flat() + + console.log('Loading group contracts') const contractsByGroup = await Promise.all( - groups.map((group) => { - return getValues( + groups.map((group) => + getValues( firestore .collection('groups') .doc(group.id) .collection('groupContracts') ) - }) + ) ) log( `Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.length} bets.` @@ -116,6 +135,28 @@ export async function updateMetricsCore() { lastPortfolio.investmentValue !== newPortfolio.investmentValue const newProfit = calculateNewProfit(portfolioHistory, newPortfolio) + const contractRatios = userContracts + .map((contract) => { + if ( + !contract.flaggedByUsernames || + contract.flaggedByUsernames?.length === 0 + ) { + return 0 + } + const contractRatio = + contract.flaggedByUsernames.length / (contract.uniqueBettorCount ?? 1) + + return contractRatio + }) + .filter((ratio) => ratio > 0) + const badResolutions = contractRatios.filter( + (ratio) => ratio > BAD_RESOLUTION_THRESHOLD + ) + let newFractionResolvedCorrectly = 0 + if (userContracts.length > 0) { + newFractionResolvedCorrectly = + (userContracts.length - badResolutions.length) / userContracts.length + } return { user, @@ -123,6 +164,7 @@ export async function updateMetricsCore() { newPortfolio, newProfit, didPortfolioChange, + newFractionResolvedCorrectly, } }) @@ -144,6 +186,7 @@ export async function updateMetricsCore() { newPortfolio, newProfit, didPortfolioChange, + newFractionResolvedCorrectly, }) => { const nextLoanCached = nextLoanByUser[user.id]?.payout ?? 0 return { @@ -153,6 +196,7 @@ export async function updateMetricsCore() { creatorVolumeCached: newCreatorVolume, profitCached: newProfit, nextLoanCached, + fractionResolvedCorrectly: newFractionResolvedCorrectly, }, }, @@ -224,3 +268,5 @@ const topUserScores = (scores: { [userId: string]: number }) => { } type GroupContractDoc = { contractId: string; createdTime: number } + +const BAD_RESOLUTION_THRESHOLD = 0.1 diff --git a/functions/src/update-stats.ts b/functions/src/update-stats.ts index 6f410886..a01bc87e 100644 --- a/functions/src/update-stats.ts +++ b/functions/src/update-stats.ts @@ -18,7 +18,7 @@ import { average } from '../../common/util/math' const firestore = admin.firestore() -const numberOfDays = 90 +const numberOfDays = 180 const getBetsQuery = (startTime: number, endTime: number) => firestore diff --git a/functions/src/weekly-portfolio-emails.ts b/functions/src/weekly-portfolio-emails.ts index dcbb68dd..bcf6da17 100644 --- a/functions/src/weekly-portfolio-emails.ts +++ b/functions/src/weekly-portfolio-emails.ts @@ -20,8 +20,8 @@ import { sendWeeklyPortfolioUpdateEmail } from './emails' import { contractUrl } from './utils' import { Txn } from '../../common/txn' import { formatMoney } from '../../common/util/format' +import { getContractBetMetrics } from '../../common/calculate' -// TODO: reset weeklyPortfolioUpdateEmailSent to false for all users at the start of each week export const weeklyPortfolioUpdateEmails = functions .runWith({ secrets: ['MAILGUN_KEY'], memory: '4GB' }) // every minute on Friday for an hour at 12pm PT (UTC -07:00) @@ -36,9 +36,9 @@ const firestore = admin.firestore() export async function sendPortfolioUpdateEmailsToAllUsers() { const privateUsers = isProd() ? // ian & stephen's ids - // ? filterDefined([ - // await getPrivateUser('AJwLWoo3xue32XIiAVrL5SyR1WB2'), - // await getPrivateUser('tlmGNz9kjXc2EteizMORes4qvWl2'), + // filterDefined([ + // await getPrivateUser('AJwLWoo3xue32XIiAVrL5SyR1WB2'), + // await getPrivateUser('tlmGNz9kjXc2EteizMORes4qvWl2'), // ]) await getAllPrivateUsers() : filterDefined([await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')]) @@ -48,7 +48,7 @@ export async function sendPortfolioUpdateEmailsToAllUsers() { return isProd() ? user.notificationPreferences.profit_loss_updates.includes('email') && !user.weeklyPortfolioUpdateEmailSent - : true + : user.notificationPreferences.profit_loss_updates.includes('email') }) // Send emails in batches .slice(0, 200) @@ -117,7 +117,8 @@ export async function sendPortfolioUpdateEmailsToAllUsers() { await Promise.all( privateUsersToSendEmailsTo.map(async (privateUser) => { const user = await getUser(privateUser.id) - if (!user) return + // Don't send to a user unless they're over 5 days old + if (!user || user.createdTime > Date.now() - 5 * DAY_MS) return const userBets = usersBets[privateUser.id] as Bet[] const contractsUserBetOn = contractsUsersBetOn.filter((contract) => userBets.some((bet) => bet.contractId === contract.id) @@ -165,28 +166,43 @@ export async function sendPortfolioUpdateEmailsToAllUsers() { const bets = userBets.filter( (bet) => bet.contractId === contract.id ) + const previousBets = bets.filter( + (b) => b.createdTime < Date.now() - 7 * DAY_MS + ) + + const betsInLastWeek = bets.filter( + (b) => b.createdTime >= Date.now() - 7 * DAY_MS + ) const marketProbabilityAWeekAgo = cpmmContract.prob - cpmmContract.probChanges.week const currentMarketProbability = cpmmContract.resolutionProbability ? cpmmContract.resolutionProbability : cpmmContract.prob - const betsValueAWeekAgo = computeInvestmentValueCustomProb( - bets.filter((b) => b.createdTime < Date.now() - 7 * DAY_MS), + + // TODO: returns 0 for resolved markets - doesn't include them + const betsMadeAWeekAgoValue = computeInvestmentValueCustomProb( + previousBets, contract, marketProbabilityAWeekAgo ) - const currentBetsValue = computeInvestmentValueCustomProb( - bets, + const currentBetsMadeAWeekAgoValue = + computeInvestmentValueCustomProb( + previousBets, + contract, + currentMarketProbability + ) + const betsMadeInLastWeekProfit = getContractBetMetrics( contract, - currentMarketProbability - ) - const marketChange = - currentMarketProbability - marketProbabilityAWeekAgo + betsInLastWeek + ).profit + const profit = + betsMadeInLastWeekProfit + + (currentBetsMadeAWeekAgoValue - betsMadeAWeekAgoValue) return { - currentValue: currentBetsValue, - pastValue: betsValueAWeekAgo, - difference: currentBetsValue - betsValueAWeekAgo, + currentValue: currentBetsMadeAWeekAgoValue, + pastValue: betsMadeAWeekAgoValue, + profit, contractSlug: contract.slug, marketProbAWeekAgo: marketProbabilityAWeekAgo, questionTitle: contract.question, @@ -194,19 +210,13 @@ export async function sendPortfolioUpdateEmailsToAllUsers() { questionProb: cpmmContract.resolution ? cpmmContract.resolution : Math.round(cpmmContract.prob * 100) + '%', - questionChange: - (marketChange > 0 ? '+' : '') + - Math.round(marketChange * 100) + - '%', - questionChangeStyle: `color: ${ - currentMarketProbability > marketProbabilityAWeekAgo - ? 'rgba(0,160,0,1)' - : '#a80000' + profitStyle: `color: ${ + profit > 0 ? 'rgba(0,160,0,1)' : '#a80000' };`, } as PerContractInvestmentsData }) ), - (differences) => Math.abs(differences.difference) + (differences) => Math.abs(differences.profit) ).reverse() log( @@ -218,12 +228,10 @@ export async function sendPortfolioUpdateEmailsToAllUsers() { const [winningInvestments, losingInvestments] = partition( investmentValueDifferences.filter( - (diff) => - diff.pastValue > 0.01 && - Math.abs(diff.difference / diff.pastValue) > 0.01 // difference is greater than 1% + (diff) => diff.pastValue > 0.01 && Math.abs(diff.profit) > 1 ), (investmentsData: PerContractInvestmentsData) => { - return investmentsData.difference > 0 + return investmentsData.profit > 0 } ) // pick 3 winning investments and 3 losing investments @@ -236,7 +244,9 @@ export async function sendPortfolioUpdateEmailsToAllUsers() { worstInvestments.length === 0 && usersToContractsCreated[privateUser.id].length === 0 ) { - log('No bets in last week, no market movers, no markets created') + log( + 'No bets in last week, no market movers, no markets created. Not sending an email.' + ) await firestore.collection('private-users').doc(privateUser.id).update({ weeklyPortfolioUpdateEmailSent: true, }) @@ -253,7 +263,7 @@ export async function sendPortfolioUpdateEmailsToAllUsers() { }) log('Sent weekly portfolio update email to', privateUser.email) count++ - log('sent out emails to user count:', count) + log('sent out emails to users:', count) }) ) } @@ -262,11 +272,10 @@ export type PerContractInvestmentsData = { questionTitle: string questionUrl: string questionProb: string - questionChange: string - questionChangeStyle: string + profitStyle: string currentValue: number pastValue: number - difference: number + profit: number } export type OverallPerformanceData = { diff --git a/web/components/add-funds-button.tsx b/web/components/add-funds-button.tsx deleted file mode 100644 index b610bfee..00000000 --- a/web/components/add-funds-button.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import clsx from 'clsx' -import { useEffect, useState } from 'react' - -import { useUser } from 'web/hooks/use-user' -import { checkoutURL } from 'web/lib/service/stripe' -import { FundsSelector } from './yes-no-selector' - -export function AddFundsButton(props: { className?: string }) { - const { className } = props - const user = useUser() - - const [amountSelected, setAmountSelected] = useState<1000 | 2500 | 10000>( - 2500 - ) - - const location = useLocation() - - return ( - <> - - - -
-
-
Get Mana
- -
- Buy mana (M$) to trade in your favorite markets.
(Not - redeemable for cash.) -
- -
Amount
- - -
-
Price USD
-
- ${Math.round(amountSelected / 100)}.00 -
-
- -
- - -
- -
-
-
-
- - ) -} - -// needed in next js -// window not loaded at runtime -const useLocation = () => { - const [href, setHref] = useState('') - useEffect(() => { - setHref(window.location.href) - }, []) - return href -} diff --git a/web/components/add-funds-modal.tsx b/web/components/add-funds-modal.tsx new file mode 100644 index 00000000..cac21f96 --- /dev/null +++ b/web/components/add-funds-modal.tsx @@ -0,0 +1,58 @@ +import { manaToUSD } from 'common/util/format' +import { useState } from 'react' +import { useUser } from 'web/hooks/use-user' +import { checkoutURL } from 'web/lib/service/stripe' +import { Button } from './button' +import { Modal } from './layout/modal' +import { FundsSelector } from './yes-no-selector' + +export function AddFundsModal(props: { + open: boolean + setOpen(open: boolean): void +}) { + const { open, setOpen } = props + + const user = useUser() + + const [amountSelected, setAmountSelected] = useState<1000 | 2500 | 10000>( + 2500 + ) + + return ( + +
Get Mana
+ +
+ Buy mana (M$) to trade in your favorite markets.
(Not redeemable + for cash.) +
+ +
Amount
+ + +
+
Price USD
+
{manaToUSD(amountSelected)}
+
+ +
+ + +
+ +
+
+
+ ) +} diff --git a/web/components/amount-input.tsx b/web/components/amount-input.tsx index fbb49677..65a79c20 100644 --- a/web/components/amount-input.tsx +++ b/web/components/amount-input.tsx @@ -1,12 +1,11 @@ import clsx from 'clsx' -import React from 'react' +import React, { useState } from 'react' import { useUser } from 'web/hooks/use-user' import { formatMoney } from 'common/util/format' import { Col } from './layout/col' -import { SiteLink } from './site-link' import { ENV_CONFIG } from 'common/envs/constants' -import { useWindowSize } from 'web/hooks/use-window-size' import { Row } from './layout/row' +import { AddFundsModal } from './add-funds-modal' export function AmountInput(props: { amount: number | undefined @@ -36,21 +35,20 @@ export function AmountInput(props: { onChange(isInvalid ? undefined : amount) } - const { width } = useWindowSize() - const isMobile = (width ?? 0) < 768 + const [addFundsModalOpen, setAddFundsModalOpen] = useState(false) return ( <> -