diff --git a/common/calculate-cpmm.ts b/common/calculate-cpmm.ts index 493b5fa9..b5153355 100644 --- a/common/calculate-cpmm.ts +++ b/common/calculate-cpmm.ts @@ -123,6 +123,7 @@ export function calculateCpmmAmountToProb( prob: number, outcome: 'YES' | 'NO' ) { + if (prob <= 0 || prob >= 1 || isNaN(prob)) return Infinity if (outcome === 'NO') prob = 1 - prob // First, find an upper bound that leads to a more extreme probability than prob. diff --git a/common/categories.ts b/common/categories.ts index 232aa526..f302e3f2 100644 --- a/common/categories.ts +++ b/common/categories.ts @@ -1,6 +1,7 @@ import { difference } from 'lodash' export const CATEGORIES_GROUP_SLUG_POSTFIX = '-default' + export const CATEGORIES = { politics: 'Politics', technology: 'Technology', @@ -30,10 +31,13 @@ export const EXCLUDED_CATEGORIES: category[] = [ 'manifold', 'personal', 'covid', - 'culture', 'gaming', 'crypto', - 'world', ] export const DEFAULT_CATEGORIES = difference(CATEGORY_LIST, EXCLUDED_CATEGORIES) + +export const DEFAULT_CATEGORY_GROUPS = DEFAULT_CATEGORIES.map((c) => ({ + slug: c.toLowerCase() + CATEGORIES_GROUP_SLUG_POSTFIX, + name: CATEGORIES[c as category], +})) diff --git a/common/contract.ts b/common/contract.ts index 5ddcf0b8..177af862 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -1,6 +1,7 @@ import { Answer } from './answer' import { Fees } from './fees' import { JSONContent } from '@tiptap/core' +import { GroupLink } from 'common/group' export type AnyMechanism = DPM | CPMM export type AnyOutcomeType = Binary | PseudoNumeric | FreeResponse | Numeric @@ -46,8 +47,10 @@ export type Contract = { collectedFees: Fees groupSlugs?: string[] + groupLinks?: GroupLink[] uniqueBettorIds?: string[] uniqueBettorCount?: number + popularityScore?: number } & T export type BinaryContract = Contract & Binary diff --git a/common/envs/prod.ts b/common/envs/prod.ts index f8aaf4cc..5bd12095 100644 --- a/common/envs/prod.ts +++ b/common/envs/prod.ts @@ -22,6 +22,7 @@ export type EnvConfig = { // Currency controls fixedAnte?: number startingBalance?: number + referralBonus?: number } type FirebaseConfig = { diff --git a/common/group.ts b/common/group.ts index e367ded7..7d3215ae 100644 --- a/common/group.ts +++ b/common/group.ts @@ -19,3 +19,11 @@ export const MAX_ABOUT_LENGTH = 140 export const MAX_ID_LENGTH = 60 export const NEW_USER_GROUP_SLUGS = ['updates', 'bugs', 'welcome'] export const GROUP_CHAT_SLUG = 'chat' + +export type GroupLink = { + slug: string + name: string + groupId: string + createdTime: number + userId?: string +} diff --git a/common/new-bet.ts b/common/new-bet.ts index f484b9f7..1f5c0340 100644 --- a/common/new-bet.ts +++ b/common/new-bet.ts @@ -1,4 +1,4 @@ -import { sortBy, sumBy } from 'lodash' +import { sortBy, sum, sumBy } from 'lodash' import { Bet, fill, LimitBet, MAX_LOAN_PER_CONTRACT, NumericBet } from './bet' import { @@ -142,6 +142,13 @@ export const computeFills = ( limitProb: number | undefined, unfilledBets: LimitBet[] ) => { + if (isNaN(betAmount)) { + throw new Error('Invalid bet amount: ${betAmount}') + } + if (isNaN(limitProb ?? 0)) { + throw new Error('Invalid limitProb: ${limitProb}') + } + const sortedBets = sortBy( unfilledBets.filter((bet) => bet.outcome !== outcome), (bet) => (outcome === 'YES' ? bet.limitProb : -bet.limitProb), @@ -239,6 +246,32 @@ export const getBinaryCpmmBetInfo = ( } } +export const getBinaryBetStats = ( + outcome: 'YES' | 'NO', + betAmount: number, + contract: CPMMBinaryContract | PseudoNumericContract, + limitProb: number, + unfilledBets: LimitBet[] +) => { + const { newBet } = getBinaryCpmmBetInfo( + outcome, + betAmount ?? 0, + contract, + limitProb, + unfilledBets as LimitBet[] + ) + const remainingMatched = + ((newBet.orderAmount ?? 0) - newBet.amount) / + (outcome === 'YES' ? limitProb : 1 - limitProb) + const currentPayout = newBet.shares + remainingMatched + + const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0 + + const totalFees = sum(Object.values(newBet.fees)) + + return { currentPayout, currentReturn, totalFees, newBet } +} + export const getNewBinaryDpmBetInfo = ( outcome: 'YES' | 'NO', amount: number, diff --git a/common/numeric-constants.ts b/common/numeric-constants.ts index 46885668..f399aa5a 100644 --- a/common/numeric-constants.ts +++ b/common/numeric-constants.ts @@ -3,4 +3,4 @@ export const NUMERIC_FIXED_VAR = 0.005 export const NUMERIC_GRAPH_COLOR = '#5fa5f9' export const NUMERIC_TEXT_COLOR = 'text-blue-500' -export const UNIQUE_BETTOR_BONUS_AMOUNT = 5 +export const UNIQUE_BETTOR_BONUS_AMOUNT = 10 diff --git a/common/pseudo-numeric.ts b/common/pseudo-numeric.ts index c99e670f..ca62a80e 100644 --- a/common/pseudo-numeric.ts +++ b/common/pseudo-numeric.ts @@ -16,8 +16,8 @@ export const getMappedValue = const { min, max, isLogScale } = contract if (isLogScale) { - const logValue = p * Math.log10(max - min) - return 10 ** logValue + min + const logValue = p * Math.log10(max - min + 1) + return 10 ** logValue + min - 1 } return p * (max - min) + min @@ -37,8 +37,11 @@ export const getPseudoProbability = ( max: number, isLogScale = false ) => { + if (value < min) return 0 + if (value > max) return 1 + if (isLogScale) { - return Math.log10(value - min) / Math.log10(max - min) + return Math.log10(value - min + 1) / Math.log10(max - min + 1) } return (value - min) / (max - min) diff --git a/common/user.ts b/common/user.ts index 1995ce34..0dac5a19 100644 --- a/common/user.ts +++ b/common/user.ts @@ -45,7 +45,7 @@ export type User = { export const STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 1000 // for sus users, i.e. multiple sign ups for same person export const SUS_STARTING_BALANCE = ENV_CONFIG.startingBalance ?? 10 -export const REFERRAL_AMOUNT = 500 +export const REFERRAL_AMOUNT = ENV_CONFIG.referralBonus ?? 500 export type PrivateUser = { id: string // same as User.id username: string // denormalized from User diff --git a/common/util/format.ts b/common/util/format.ts index 7dc1a341..4f123535 100644 --- a/common/util/format.ts +++ b/common/util/format.ts @@ -33,20 +33,24 @@ export function formatPercent(zeroToOne: number) { return (zeroToOne * 100).toFixed(decimalPlaces) + '%' } +const showPrecision = (x: number, sigfigs: number) => + // convert back to number for weird formatting reason + `${Number(x.toPrecision(sigfigs))}` + // Eg 1234567.89 => 1.23M; 5678 => 5.68K export function formatLargeNumber(num: number, sigfigs = 2): string { const absNum = Math.abs(num) - if (absNum < 1) return num.toPrecision(sigfigs) + if (absNum < 1) return showPrecision(num, sigfigs) - if (absNum < 100) return num.toPrecision(2) - if (absNum < 1000) return num.toPrecision(3) - if (absNum < 10000) return num.toPrecision(4) + if (absNum < 100) return showPrecision(num, 2) + if (absNum < 1000) return showPrecision(num, 3) + if (absNum < 10000) return showPrecision(num, 4) const suffix = ['', 'K', 'M', 'B', 'T', 'Q'] const i = Math.floor(Math.log10(absNum) / 3) - const numStr = (num / Math.pow(10, 3 * i)).toPrecision(sigfigs) - return `${numStr}${suffix[i]}` + const numStr = showPrecision(num / Math.pow(10, 3 * i), sigfigs) + return `${numStr}${suffix[i] ?? ''}` } export function toCamelCase(words: string) { diff --git a/common/util/parse.ts b/common/util/parse.ts index b29a1c9a..cacd0862 100644 --- a/common/util/parse.ts +++ b/common/util/parse.ts @@ -21,6 +21,7 @@ import { Text } from '@tiptap/extension-text' import { Image } from '@tiptap/extension-image' import { Link } from '@tiptap/extension-link' import { Mention } from '@tiptap/extension-mention' +import Iframe from './tiptap-iframe' export function parseTags(text: string) { const regex = /(?:^|\s)(?:[#][a-z0-9_]+)/gi @@ -82,6 +83,7 @@ export const exhibitExts = [ Image, Link, Mention, + Iframe, ] export function richTextToString(text?: JSONContent) { diff --git a/common/util/tiptap-iframe.ts b/common/util/tiptap-iframe.ts new file mode 100644 index 00000000..5af63d2f --- /dev/null +++ b/common/util/tiptap-iframe.ts @@ -0,0 +1,92 @@ +// Adopted from https://github.com/ueberdosis/tiptap/blob/main/demos/src/Experiments/Embeds/Vue/iframe.ts + +import { Node } from '@tiptap/core' + +export interface IframeOptions { + allowFullscreen: boolean + HTMLAttributes: { + [key: string]: any + } +} + +declare module '@tiptap/core' { + interface Commands { + iframe: { + setIframe: (options: { src: string }) => ReturnType + } + } +} + +// These classes style the outer wrapper and the inner iframe; +// Adopted from css in https://github.com/ueberdosis/tiptap/blob/main/demos/src/Experiments/Embeds/Vue/index.vue +const wrapperClasses = 'relative h-auto w-full overflow-hidden' +const iframeClasses = 'absolute top-0 left-0 h-full w-full' + +export default Node.create({ + name: 'iframe', + + group: 'block', + + atom: true, + + addOptions() { + return { + allowFullscreen: true, + HTMLAttributes: { + class: 'iframe-wrapper' + ' ' + wrapperClasses, + // Tailwind JIT doesn't seem to pick up `pb-[20rem]`, so we hack this in: + style: 'padding-bottom: 20rem;', + }, + } + }, + + addAttributes() { + return { + src: { + default: null, + }, + frameborder: { + default: 0, + }, + allowfullscreen: { + default: this.options.allowFullscreen, + parseHTML: () => this.options.allowFullscreen, + }, + } + }, + + parseHTML() { + return [{ tag: 'iframe' }] + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'div', + this.options.HTMLAttributes, + [ + 'iframe', + { + ...HTMLAttributes, + class: HTMLAttributes.class + ' ' + iframeClasses, + }, + ], + ] + }, + + addCommands() { + return { + setIframe: + (options: { src: string }) => + ({ tr, dispatch }) => { + const { selection } = tr + const node = this.type.create(options) + + if (dispatch) { + tr.replaceRangeWith(selection.from, selection.to, node) + } + + return true + }, + } + }, +}) diff --git a/firestore.rules b/firestore.rules index 96378d8b..0f28ca80 100644 --- a/firestore.rules +++ b/firestore.rules @@ -74,7 +74,7 @@ service cloud.firestore { match /contracts/{contractId} { allow read; allow update: if request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['tags', 'lowercaseTags', 'groupSlugs']); + .hasOnly(['tags', 'lowercaseTags', 'groupSlugs', 'groupLinks']); allow update: if request.resource.data.diff(resource.data).affectedKeys() .hasOnly(['description', 'closeTime', 'question']) && resource.data.creatorId == request.auth.uid; diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index bf2dd28a..7cc05760 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -29,12 +29,22 @@ export const createNotification = async ( sourceUser: User, idempotencyKey: string, sourceText: string, - sourceContract?: Contract, - relatedSourceType?: notification_source_types, - relatedUserId?: string, - sourceSlug?: string, - sourceTitle?: string + miscData?: { + contract?: Contract + relatedSourceType?: notification_source_types + relatedUserId?: string + slug?: string + title?: string + } ) => { + const { + contract: sourceContract, + relatedSourceType, + relatedUserId, + slug, + title, + } = miscData ?? {} + const shouldGetNotification = ( userId: string, userToReasonTexts: user_to_reason_texts @@ -70,8 +80,8 @@ export const createNotification = async ( sourceContractCreatorUsername: sourceContract?.creatorUsername, sourceContractTitle: sourceContract?.question, sourceContractSlug: sourceContract?.slug, - sourceSlug: sourceSlug ? sourceSlug : sourceContract?.slug, - sourceTitle: sourceTitle ? sourceTitle : sourceContract?.question, + sourceSlug: slug ? slug : sourceContract?.slug, + sourceTitle: title ? title : sourceContract?.question, } await notificationRef.set(removeUndefinedProps(notification)) }) diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts index 1fd23894..1f413b6d 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -159,7 +159,7 @@ const addUserToDefaultGroups = async (user: User) => { id: welcomeCommentDoc.id, groupId: group.id, userId: manifoldAccount, - text: `Welcome, ${user.name} (@${user.username})!`, + text: `Welcome, @${user.username} aka ${user.name}!`, createdTime: Date.now(), userName: 'Manifold Markets', userUsername: MANIFOLD_USERNAME, diff --git a/functions/src/emails.ts b/functions/src/emails.ts index 60534679..a29f982c 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -302,7 +302,7 @@ export const sendNewCommentEmail = async ( )}` } - const subject = `Comment from ${commentorName} on ${question}` + const subject = `Comment on ${question}` const from = `${commentorName} on Manifold ` if (contract.outcomeType === 'FREE_RESPONSE' && answerId && answerText) { diff --git a/functions/src/index.ts b/functions/src/index.ts index 3055f8dc..df311886 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -22,6 +22,7 @@ export * from './on-update-user' export * from './on-create-comment-on-group' export * from './on-create-txn' export * from './on-delete-group' +export * from './score-contracts' // v2 export * from './health' diff --git a/functions/src/market-close-notifications.ts b/functions/src/market-close-notifications.ts index ee9952bf..f31674a1 100644 --- a/functions/src/market-close-notifications.ts +++ b/functions/src/market-close-notifications.ts @@ -64,7 +64,7 @@ async function sendMarketCloseEmails() { user, 'closed' + contract.id.slice(6, contract.id.length), contract.closeTime?.toString() ?? new Date().toString(), - contract + { contract } ) } } diff --git a/functions/src/on-create-answer.ts b/functions/src/on-create-answer.ts index 78fd1399..af4690b0 100644 --- a/functions/src/on-create-answer.ts +++ b/functions/src/on-create-answer.ts @@ -28,6 +28,6 @@ export const onCreateAnswer = functions.firestore answerCreator, eventId, answer.text, - contract + { contract } ) }) diff --git a/functions/src/on-create-bet.ts b/functions/src/on-create-bet.ts index fc2e0053..d33e71dd 100644 --- a/functions/src/on-create-bet.ts +++ b/functions/src/on-create-bet.ts @@ -64,10 +64,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( if (!previousUniqueBettorIds) { const contractBets = ( - await firestore - .collection(`contracts/${contractId}/bets`) - .where('userId', '!=', contract.creatorId) - .get() + await firestore.collection(`contracts/${contractId}/bets`).get() ).docs.map((doc) => doc.data() as Bet) if (contractBets.length === 0) { @@ -82,9 +79,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( ) } - const isNewUniqueBettor = - !previousUniqueBettorIds.includes(bettorId) && - bettorId !== contract.creatorId + const isNewUniqueBettor = !previousUniqueBettorIds.includes(bettorId) const newUniqueBettorIds = uniq([...previousUniqueBettorIds, bettorId]) // Update contract unique bettors @@ -96,7 +91,9 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( uniqueBettorCount: newUniqueBettorIds.length, }) } - if (!isNewUniqueBettor) return + + // No need to give a bonus for the creator's bet + if (!isNewUniqueBettor || bettorId == contract.creatorId) return // Create combined txn for all new unique bettors const bonusTxnDetails = { @@ -134,12 +131,11 @@ const updateUniqueBettorsAndGiveCreatorBonus = async ( fromUser, eventId + '-bonus', result.txn.amount + '', - contract, - undefined, - // No need to set the user id, we'll use the contract creator id - undefined, - contract.slug, - contract.question + { + contract, + slug: contract.slug, + title: contract.question, + } ) } } diff --git a/functions/src/on-create-comment-on-contract.ts b/functions/src/on-create-comment-on-contract.ts index f7839b44..8d841ac0 100644 --- a/functions/src/on-create-comment-on-contract.ts +++ b/functions/src/on-create-comment-on-contract.ts @@ -68,7 +68,7 @@ export const onCreateCommentOnContract = functions ? 'answer' : undefined - const relatedUser = comment.replyToCommentId + const relatedUserId = comment.replyToCommentId ? comments.find((c) => c.id === comment.replyToCommentId)?.userId : answer?.userId @@ -79,9 +79,7 @@ export const onCreateCommentOnContract = functions commentCreator, eventId, comment.text, - contract, - relatedSourceType, - relatedUser + { contract, relatedSourceType, relatedUserId } ) const recipientUserIds = uniq([ diff --git a/functions/src/on-create-contract.ts b/functions/src/on-create-contract.ts index 28682793..a43beda7 100644 --- a/functions/src/on-create-contract.ts +++ b/functions/src/on-create-contract.ts @@ -21,6 +21,6 @@ export const onCreateContract = functions.firestore contractCreator, eventId, richTextToString(contract.description as JSONContent), - contract + { contract } ) }) diff --git a/functions/src/on-create-group.ts b/functions/src/on-create-group.ts index 1d041c04..47618d7a 100644 --- a/functions/src/on-create-group.ts +++ b/functions/src/on-create-group.ts @@ -20,11 +20,11 @@ export const onCreateGroup = functions.firestore groupCreator, eventId, group.about, - undefined, - undefined, - memberId, - group.slug, - group.name + { + relatedUserId: memberId, + slug: group.slug, + title: group.name, + } ) } }) diff --git a/functions/src/on-create-liquidity-provision.ts b/functions/src/on-create-liquidity-provision.ts index d55b2be4..ba17f3e7 100644 --- a/functions/src/on-create-liquidity-provision.ts +++ b/functions/src/on-create-liquidity-provision.ts @@ -26,6 +26,6 @@ export const onCreateLiquidityProvision = functions.firestore liquidityProvider, eventId, liquidity.amount.toString(), - contract + { contract } ) }) diff --git a/functions/src/on-delete-group.ts b/functions/src/on-delete-group.ts index ca833254..e5531d7b 100644 --- a/functions/src/on-delete-group.ts +++ b/functions/src/on-delete-group.ts @@ -3,6 +3,7 @@ import * as admin from 'firebase-admin' import { Group } from 'common/group' import { Contract } from 'common/contract' + const firestore = admin.firestore() export const onDeleteGroup = functions.firestore @@ -15,17 +16,21 @@ export const onDeleteGroup = functions.firestore .collection('contracts') .where('groupSlugs', 'array-contains', group.slug) .get() + console.log("contracts with group's slug:", contracts) for (const doc of contracts.docs) { const contract = doc.data() as Contract + const newGroupLinks = contract.groupLinks?.filter( + (link) => link.slug !== group.slug + ) + // remove the group from the contract await firestore .collection('contracts') .doc(contract.id) .update({ - groupSlugs: (contract.groupSlugs ?? []).filter( - (groupSlug) => groupSlug !== group.slug - ), + groupSlugs: contract.groupSlugs?.filter((s) => s !== group.slug), + groupLinks: newGroupLinks ?? [], }) } }) diff --git a/functions/src/on-follow-user.ts b/functions/src/on-follow-user.ts index ad85f4d3..9a6e6dce 100644 --- a/functions/src/on-follow-user.ts +++ b/functions/src/on-follow-user.ts @@ -30,9 +30,7 @@ export const onFollowUser = functions.firestore followingUser, eventId, '', - undefined, - undefined, - follow.userId + { relatedUserId: follow.userId } ) }) diff --git a/functions/src/on-update-contract.ts b/functions/src/on-update-contract.ts index 4674bd82..2042f726 100644 --- a/functions/src/on-update-contract.ts +++ b/functions/src/on-update-contract.ts @@ -36,7 +36,7 @@ export const onUpdateContract = functions.firestore contractUpdater, eventId, resolutionText, - contract + { contract } ) } else if ( previousValue.closeTime !== contract.closeTime || @@ -62,7 +62,7 @@ export const onUpdateContract = functions.firestore contractUpdater, eventId, sourceText, - contract + { contract } ) } }) diff --git a/functions/src/score-contracts.ts b/functions/src/score-contracts.ts new file mode 100644 index 00000000..57976ff2 --- /dev/null +++ b/functions/src/score-contracts.ts @@ -0,0 +1,54 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' +import { Bet } from 'common/bet' +import { uniq } from 'lodash' +import { Contract } from 'common/contract' +import { log } from './utils' + +export const scoreContracts = functions.pubsub + .schedule('every 1 hours') + .onRun(async () => { + await scoreContractsInternal() + }) +const firestore = admin.firestore() + +async function scoreContractsInternal() { + const now = Date.now() + const lastHour = now - 60 * 60 * 1000 + const last3Days = now - 1000 * 60 * 60 * 24 * 3 + const activeContractsSnap = await firestore + .collection('contracts') + .where('lastUpdatedTime', '>', lastHour) + .get() + const activeContracts = activeContractsSnap.docs.map( + (doc) => doc.data() as Contract + ) + // We have to downgrade previously active contracts to allow the new ones to bubble up + const previouslyActiveContractsSnap = await firestore + .collection('contracts') + .where('popularityScore', '>', 0) + .get() + const activeContractIds = activeContracts.map((c) => c.id) + const previouslyActiveContracts = previouslyActiveContractsSnap.docs + .map((doc) => doc.data() as Contract) + .filter((c) => !activeContractIds.includes(c.id)) + + const contracts = activeContracts.concat(previouslyActiveContracts) + log(`Found ${contracts.length} contracts to score`) + + for (const contract of contracts) { + const bets = await firestore + .collection(`contracts/${contract.id}/bets`) + .where('createdTime', '>', last3Days) + .get() + const bettors = bets.docs + .map((doc) => doc.data() as Bet) + .map((bet) => bet.userId) + const score = uniq(bettors).length + if (contract.popularityScore !== score) + await firestore + .collection('contracts') + .doc(contract.id) + .update({ popularityScore: score }) + } +} diff --git a/functions/src/scripts/backfill-comment-ids.ts b/functions/src/scripts/backfill-comment-ids.ts new file mode 100644 index 00000000..e6bb6902 --- /dev/null +++ b/functions/src/scripts/backfill-comment-ids.ts @@ -0,0 +1,55 @@ +// We have some old comments without IDs and user IDs. Let's fill them in. +// Luckily, this was back when all comments had associated bets, so it's possible +// to retrieve the user IDs through the bets. + +import * as admin from 'firebase-admin' +import { QueryDocumentSnapshot } from 'firebase-admin/firestore' +import { initAdmin } from './script-init' +import { log, writeAsync } from '../utils' +import { Bet } from '../../../common/bet' + +initAdmin() +const firestore = admin.firestore() + +const getUserIdsByCommentId = async (comments: QueryDocumentSnapshot[]) => { + const bets = await firestore.collectionGroup('bets').get() + log(`Loaded ${bets.size} bets.`) + const betsById = Object.fromEntries( + bets.docs.map((b) => [b.id, b.data() as Bet]) + ) + return Object.fromEntries( + comments.map((c) => [c.id, betsById[c.data().betId].userId]) + ) +} + +if (require.main === module) { + const commentsQuery = firestore.collectionGroup('comments') + commentsQuery.get().then(async (commentSnaps) => { + log(`Loaded ${commentSnaps.size} comments.`) + const needsFilling = commentSnaps.docs.filter((ct) => { + return !('id' in ct.data()) || !('userId' in ct.data()) + }) + log(`${needsFilling.length} comments need IDs.`) + const userIdNeedsFilling = needsFilling.filter((ct) => { + return !('userId' in ct.data()) + }) + log(`${userIdNeedsFilling.length} comments need user IDs.`) + const userIdsByCommentId = + userIdNeedsFilling.length > 0 + ? await getUserIdsByCommentId(userIdNeedsFilling) + : {} + const updates = needsFilling.map((ct) => { + const fields: { [k: string]: unknown } = {} + if (!ct.data().id) { + fields.id = ct.id + } + if (!ct.data().userId && userIdsByCommentId[ct.id]) { + fields.userId = userIdsByCommentId[ct.id] + } + return { doc: ct.ref, fields } + }) + log(`Updating ${updates.length} comments.`) + await writeAsync(firestore, updates) + log(`Updated all comments.`) + }) +} diff --git a/functions/src/scripts/convert-categories.ts b/functions/src/scripts/convert-categories.ts index 8fe90807..3436bcbc 100644 --- a/functions/src/scripts/convert-categories.ts +++ b/functions/src/scripts/convert-categories.ts @@ -1,14 +1,9 @@ import * as admin from 'firebase-admin' import { initAdmin } from './script-init' -initAdmin() - import { getValues, isProd } from '../utils' -import { - CATEGORIES_GROUP_SLUG_POSTFIX, - DEFAULT_CATEGORIES, -} from 'common/categories' -import { Group } from 'common/group' +import { CATEGORIES_GROUP_SLUG_POSTFIX } from 'common/categories' +import { Group, GroupLink } from 'common/group' import { uniq } from 'lodash' import { Contract } from 'common/contract' import { User } from 'common/user' @@ -18,28 +13,12 @@ import { HOUSE_LIQUIDITY_PROVIDER_ID, } from 'common/antes' +initAdmin() + const adminFirestore = admin.firestore() -async function convertCategoriesToGroups() { - const groups = await getValues(adminFirestore.collection('groups')) - const contracts = await getValues( - adminFirestore.collection('contracts') - ) - for (const group of groups) { - const groupContracts = contracts.filter((contract) => - group.contractIds.includes(contract.id) - ) - for (const contract of groupContracts) { - await adminFirestore - .collection('contracts') - .doc(contract.id) - .update({ - groupSlugs: uniq([...(contract.groupSlugs ?? []), group.slug]), - }) - } - } - - for (const category of Object.values(DEFAULT_CATEGORIES)) { +const convertCategoriesToGroupsInternal = async (categories: string[]) => { + for (const category of categories) { const markets = await getValues( adminFirestore .collection('contracts') @@ -77,7 +56,7 @@ async function convertCategoriesToGroups() { createdTime: Date.now(), anyoneCanJoin: true, memberIds: [manifoldAccount], - about: 'Official group for all things related to ' + category, + about: 'Default group for all things related to ' + category, mostRecentActivityTime: Date.now(), contractIds: markets.map((market) => market.id), chatDisabled: true, @@ -93,16 +72,35 @@ async function convertCategoriesToGroups() { }) for (const market of markets) { + if (market.groupLinks?.map((l) => l.groupId).includes(newGroup.id)) + continue // already in that group + + const newGroupLinks = [ + ...(market.groupLinks ?? []), + { + groupId: newGroup.id, + createdTime: Date.now(), + slug: newGroup.slug, + name: newGroup.name, + } as GroupLink, + ] await adminFirestore .collection('contracts') .doc(market.id) .update({ - groupSlugs: uniq([...(market?.groupSlugs ?? []), newGroup.slug]), + groupSlugs: uniq([...(market.groupSlugs ?? []), newGroup.slug]), + groupLinks: newGroupLinks, }) } } } +async function convertCategoriesToGroups() { + // const defaultCategories = Object.values(DEFAULT_CATEGORIES) + const moreCategories = ['world', 'culture'] + await convertCategoriesToGroupsInternal(moreCategories) +} + if (require.main === module) { convertCategoriesToGroups() .then(() => process.exit()) diff --git a/functions/src/scripts/link-contracts-to-groups.ts b/functions/src/scripts/link-contracts-to-groups.ts new file mode 100644 index 00000000..e3296160 --- /dev/null +++ b/functions/src/scripts/link-contracts-to-groups.ts @@ -0,0 +1,53 @@ +import { getValues } from 'functions/src/utils' +import { Group } from 'common/group' +import { Contract } from 'common/contract' +import { initAdmin } from 'functions/src/scripts/script-init' +import * as admin from 'firebase-admin' +import { filterDefined } from 'common/util/array' +import { uniq } from 'lodash' + +initAdmin() + +const adminFirestore = admin.firestore() + +const addGroupIdToContracts = async () => { + const groups = await getValues(adminFirestore.collection('groups')) + + for (const group of groups) { + const groupContracts = await getValues( + adminFirestore + .collection('contracts') + .where('groupSlugs', 'array-contains', group.slug) + ) + + for (const contract of groupContracts) { + const oldGroupLinks = contract.groupLinks?.filter( + (l) => l.slug != group.slug + ) + const newGroupLinks = filterDefined([ + ...(oldGroupLinks ?? []), + group.id + ? { + slug: group.slug, + name: group.name, + groupId: group.id, + createdTime: Date.now(), + } + : undefined, + ]) + await adminFirestore + .collection('contracts') + .doc(contract.id) + .update({ + groupSlugs: uniq([...(contract.groupSlugs ?? []), group.slug]), + groupLinks: newGroupLinks, + }) + } + } +} + +if (require.main === module) { + addGroupIdToContracts() + .then(() => process.exit()) + .catch(console.log) +} diff --git a/web/.prettierignore b/web/.prettierignore index b79c5513..6cc1e5c7 100644 --- a/web/.prettierignore +++ b/web/.prettierignore @@ -1,3 +1,4 @@ # Ignore Next artifacts .next/ -out/ \ No newline at end of file +out/ +public/**/*.json \ No newline at end of file diff --git a/web/components/NotificationSettings.tsx b/web/components/NotificationSettings.tsx new file mode 100644 index 00000000..7a839a7a --- /dev/null +++ b/web/components/NotificationSettings.tsx @@ -0,0 +1,210 @@ +import { useUser } from 'web/hooks/use-user' +import React, { useEffect, useState } from 'react' +import { notification_subscribe_types, PrivateUser } from 'common/user' +import { listenForPrivateUser, updatePrivateUser } from 'web/lib/firebase/users' +import toast from 'react-hot-toast' +import { track } from '@amplitude/analytics-browser' +import { LoadingIndicator } from 'web/components/loading-indicator' +import { Row } from 'web/components/layout/row' +import clsx from 'clsx' +import { CheckIcon, XIcon } from '@heroicons/react/outline' +import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' + +export function NotificationSettings() { + const user = useUser() + const [notificationSettings, setNotificationSettings] = + useState('all') + const [emailNotificationSettings, setEmailNotificationSettings] = + useState('all') + const [privateUser, setPrivateUser] = useState(null) + + useEffect(() => { + if (user) listenForPrivateUser(user.id, setPrivateUser) + }, [user]) + + useEffect(() => { + if (!privateUser) return + if (privateUser.notificationPreferences) { + setNotificationSettings(privateUser.notificationPreferences) + } + if ( + privateUser.unsubscribedFromResolutionEmails && + privateUser.unsubscribedFromCommentEmails && + privateUser.unsubscribedFromAnswerEmails + ) { + setEmailNotificationSettings('none') + } else if ( + !privateUser.unsubscribedFromResolutionEmails && + !privateUser.unsubscribedFromCommentEmails && + !privateUser.unsubscribedFromAnswerEmails + ) { + setEmailNotificationSettings('all') + } else { + setEmailNotificationSettings('less') + } + }, [privateUser]) + + const loading = 'Changing Notifications Settings' + const success = 'Notification Settings Changed!' + function changeEmailNotifications(newValue: notification_subscribe_types) { + if (!privateUser) return + if (newValue === 'all') { + toast.promise( + updatePrivateUser(privateUser.id, { + unsubscribedFromResolutionEmails: false, + unsubscribedFromCommentEmails: false, + unsubscribedFromAnswerEmails: false, + }), + { + loading, + success, + error: (err) => `${err.message}`, + } + ) + } else if (newValue === 'less') { + toast.promise( + updatePrivateUser(privateUser.id, { + unsubscribedFromResolutionEmails: false, + unsubscribedFromCommentEmails: true, + unsubscribedFromAnswerEmails: true, + }), + { + loading, + success, + error: (err) => `${err.message}`, + } + ) + } else if (newValue === 'none') { + toast.promise( + updatePrivateUser(privateUser.id, { + unsubscribedFromResolutionEmails: true, + unsubscribedFromCommentEmails: true, + unsubscribedFromAnswerEmails: true, + }), + { + loading, + success, + error: (err) => `${err.message}`, + } + ) + } + } + + function changeInAppNotificationSettings( + newValue: notification_subscribe_types + ) { + if (!privateUser) return + track('In-App Notification Preferences Changed', { + newPreference: newValue, + oldPreference: privateUser.notificationPreferences, + }) + toast.promise( + updatePrivateUser(privateUser.id, { + notificationPreferences: newValue, + }), + { + loading, + success, + error: (err) => `${err.message}`, + } + ) + } + + useEffect(() => { + if (privateUser && privateUser.notificationPreferences) + setNotificationSettings(privateUser.notificationPreferences) + else setNotificationSettings('all') + }, [privateUser]) + + if (!privateUser) { + return + } + + function NotificationSettingLine(props: { + label: string + highlight: boolean + }) { + const { label, highlight } = props + return ( + + {highlight ? : } + {label} + + ) + } + + return ( +
+
In App Notifications
+ + changeInAppNotificationSettings( + choice as notification_subscribe_types + ) + } + className={'col-span-4 p-2'} + toggleClassName={'w-24'} + /> +
+
+
+ You will receive notifications for: + + + + + +
+
+
+
Email Notifications
+ + changeEmailNotifications(choice as notification_subscribe_types) + } + className={'col-span-4 p-2'} + toggleClassName={'w-24'} + /> +
+
+ You will receive emails for: + + + + +
+
+
+ ) +} diff --git a/web/components/amount-input.tsx b/web/components/amount-input.tsx index a31957cb..426a9371 100644 --- a/web/components/amount-input.tsx +++ b/web/components/amount-input.tsx @@ -41,7 +41,7 @@ export function AmountInput(props: { {label} { + let deviceToken = localStorage.getItem('device-token') + if (!deviceToken) { + deviceToken = randomString() + localStorage.setItem('device-token', deviceToken) + } + return deviceToken +} + +export const AuthContext = createContext(null) + +export function AuthProvider({ children }: any) { + const [authUser, setAuthUser] = useStateCheckEquality(undefined) + + useEffect(() => { + const cachedUser = localStorage.getItem(CACHED_USER_KEY) + setAuthUser(cachedUser && JSON.parse(cachedUser)) + }, [setAuthUser]) + + useEffect(() => { + return onIdTokenChanged(auth, async (fbUser) => { + if (fbUser) { + setAuthCookies(await fbUser.getIdToken(), fbUser.refreshToken) + let user = await getUser(fbUser.uid) + if (!user) { + const deviceToken = ensureDeviceToken() + user = (await createUser({ deviceToken })) as User + } + setAuthUser(user) + // Persist to local storage, to reduce login blink next time. + // Note: Cap on localStorage size is ~5mb + localStorage.setItem(CACHED_USER_KEY, JSON.stringify(user)) + setCachedReferralInfoForUser(user) + } else { + // User logged out; reset to null + deleteAuthCookies() + setAuthUser(null) + localStorage.removeItem(CACHED_USER_KEY) + } + }) + }, [setAuthUser]) + + const authUserId = authUser?.id + const authUsername = authUser?.username + useEffect(() => { + if (authUserId && authUsername) { + identifyUser(authUserId) + setUserProperty('username', authUsername) + return listenForUser(authUserId, setAuthUser) + } + }, [authUserId, authUsername, setAuthUser]) + + return ( + {children} + ) +} diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 351b012e..c638fcde 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -1,6 +1,6 @@ import clsx from 'clsx' import React, { useEffect, useState } from 'react' -import { partition, sum, sumBy } from 'lodash' +import { clamp, partition, sum, sumBy } from 'lodash' import { useUser } from 'web/hooks/use-user' import { CPMMBinaryContract, PseudoNumericContract } from 'common/contract' @@ -13,32 +13,33 @@ import { formatPercent, formatWithCommas, } from 'common/util/format' -import { getBinaryCpmmBetInfo } from 'common/new-bet' +import { getBinaryBetStats, getBinaryCpmmBetInfo } from 'common/new-bet' import { User } from 'web/lib/firebase/users' import { Bet, LimitBet } from 'common/bet' import { APIError, placeBet } from 'web/lib/firebase/api' import { sellShares } from 'web/lib/firebase/api' import { AmountInput, BuyAmountInput } from './amount-input' import { InfoTooltip } from './info-tooltip' -import { BinaryOutcomeLabel } from './outcome-label' +import { + BinaryOutcomeLabel, + HigherLabel, + LowerLabel, + NoLabel, + YesLabel, +} from './outcome-label' import { getProbability } from 'common/calculate' import { useFocus } from 'web/hooks/use-focus' import { useUserContractBets } from 'web/hooks/use-user-bets' import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm' -import { - getFormattedMappedValue, - getPseudoProbability, -} from 'common/pseudo-numeric' +import { getFormattedMappedValue } from 'common/pseudo-numeric' import { SellRow } from './sell-row' import { useSaveBinaryShares } from './use-save-binary-shares' import { SignUpPrompt } from './sign-up-prompt' import { isIOS } from 'web/lib/util/device' -import { ProbabilityInput } from './probability-input' +import { ProbabilityOrNumericInput } from './probability-input' import { track } from 'web/lib/service/analytics' -import { removeUndefinedProps } from 'common/util/object' import { useUnfilledBets } from 'web/hooks/use-bets' import { LimitBets } from './limit-bets' -import { BucketInput } from './bucket-input' import { PillButton } from './buttons/pill-button' import { YesNoSelector } from './yes-no-selector' @@ -50,14 +51,10 @@ export function BetPanel(props: { const user = useUser() const userBets = useUserContractBets(user?.id, contract.id) const unfilledBets = useUnfilledBets(contract.id) ?? [] - const yourUnfilledBets = unfilledBets.filter((bet) => bet.userId === user?.id) const { sharesOutcome } = useSaveBinaryShares(contract, userBets) const [isLimitOrder, setIsLimitOrder] = useState(false) - const showLimitOrders = - (isLimitOrder && unfilledBets.length > 0) || yourUnfilledBets.length > 0 - return (