diff --git a/common/calculate-metrics.ts b/common/calculate-metrics.ts new file mode 100644 index 00000000..b27ac977 --- /dev/null +++ b/common/calculate-metrics.ts @@ -0,0 +1,158 @@ +import { last, sortBy, sum, sumBy } from 'lodash' +import { calculatePayout } from './calculate' +import { Bet } from './bet' +import { Contract } from './contract' +import { PortfolioMetrics, User } from './user' +import { DAY_MS } from './util/time' + +const computeInvestmentValue = ( + bets: Bet[], + contractsDict: { [k: string]: Contract } +) => { + return sumBy(bets, (bet) => { + const contract = contractsDict[bet.contractId] + if (!contract || contract.isResolved) return 0 + if (bet.sale || bet.isSold) return 0 + + const payout = calculatePayout(contract, bet, 'MKT') + const value = payout - (bet.loanAmount ?? 0) + if (isNaN(value)) return 0 + return value + }) +} + +const computeTotalPool = (userContracts: Contract[], startTime = 0) => { + const periodFilteredContracts = userContracts.filter( + (contract) => contract.createdTime >= startTime + ) + return sum( + periodFilteredContracts.map((contract) => sum(Object.values(contract.pool))) + ) +} + +export const computeVolume = (contractBets: Bet[], since: number) => { + return sumBy(contractBets, (b) => + b.createdTime > since && !b.isRedemption ? Math.abs(b.amount) : 0 + ) +} + +const calculateProbChangeSince = (descendingBets: Bet[], since: number) => { + const newestBet = descendingBets[0] + if (!newestBet) return 0 + + const betBeforeSince = descendingBets.find((b) => b.createdTime < since) + + if (!betBeforeSince) { + const oldestBet = last(descendingBets) ?? newestBet + return newestBet.probAfter - oldestBet.probBefore + } + + return newestBet.probAfter - betBeforeSince.probAfter +} + +export const calculateProbChanges = (descendingBets: Bet[]) => { + const now = Date.now() + const yesterday = now - DAY_MS + const weekAgo = now - 7 * DAY_MS + const monthAgo = now - 30 * DAY_MS + + return { + day: calculateProbChangeSince(descendingBets, yesterday), + week: calculateProbChangeSince(descendingBets, weekAgo), + month: calculateProbChangeSince(descendingBets, monthAgo), + } +} + +export const calculateCreatorVolume = (userContracts: Contract[]) => { + const allTimeCreatorVolume = computeTotalPool(userContracts, 0) + const monthlyCreatorVolume = computeTotalPool( + userContracts, + Date.now() - 30 * DAY_MS + ) + const weeklyCreatorVolume = computeTotalPool( + userContracts, + Date.now() - 7 * DAY_MS + ) + + const dailyCreatorVolume = computeTotalPool( + userContracts, + Date.now() - 1 * DAY_MS + ) + + return { + daily: dailyCreatorVolume, + weekly: weeklyCreatorVolume, + monthly: monthlyCreatorVolume, + allTime: allTimeCreatorVolume, + } +} + +export const calculateNewPortfolioMetrics = ( + user: User, + contractsById: { [k: string]: Contract }, + currentBets: Bet[] +) => { + const investmentValue = computeInvestmentValue(currentBets, contractsById) + const newPortfolio = { + investmentValue: investmentValue, + balance: user.balance, + totalDeposits: user.totalDeposits, + timestamp: Date.now(), + userId: user.id, + } + return newPortfolio +} + +const calculateProfitForPeriod = ( + startTime: number, + descendingPortfolio: PortfolioMetrics[], + currentProfit: number +) => { + const startingPortfolio = descendingPortfolio.find( + (p) => p.timestamp < startTime + ) + + if (startingPortfolio === undefined) { + return currentProfit + } + + const startingProfit = calculatePortfolioProfit(startingPortfolio) + + return currentProfit - startingProfit +} + +export const calculatePortfolioProfit = (portfolio: PortfolioMetrics) => { + return portfolio.investmentValue + portfolio.balance - portfolio.totalDeposits +} + +export const calculateNewProfit = ( + portfolioHistory: PortfolioMetrics[], + newPortfolio: PortfolioMetrics +) => { + const allTimeProfit = calculatePortfolioProfit(newPortfolio) + const descendingPortfolio = sortBy( + portfolioHistory, + (p) => p.timestamp + ).reverse() + + const newProfit = { + daily: calculateProfitForPeriod( + Date.now() - 1 * DAY_MS, + descendingPortfolio, + allTimeProfit + ), + weekly: calculateProfitForPeriod( + Date.now() - 7 * DAY_MS, + descendingPortfolio, + allTimeProfit + ), + monthly: calculateProfitForPeriod( + Date.now() - 30 * DAY_MS, + descendingPortfolio, + allTimeProfit + ), + allTime: allTimeProfit, + } + + return newProfit +} diff --git a/common/comment.ts b/common/comment.ts index c7f9b855..7ecbb6d4 100644 --- a/common/comment.ts +++ b/common/comment.ts @@ -1,6 +1,6 @@ import type { JSONContent } from '@tiptap/core' -export type AnyCommentType = OnContract | OnGroup +export type AnyCommentType = OnContract | OnGroup | OnPost // Currently, comments are created after the bet, not atomically with the bet. // They're uniquely identified by the pair contractId/betId. @@ -20,19 +20,31 @@ export type Comment = { userAvatarUrl?: string } & T -type OnContract = { +export type OnContract = { commentType: 'contract' contractId: string - contractSlug: string - contractQuestion: string answerOutcome?: string betId?: string + + // denormalized from contract + contractSlug: string + contractQuestion: string + + // denormalized from bet + betAmount?: number + betOutcome?: string } -type OnGroup = { +export type OnGroup = { commentType: 'group' groupId: string } +export type OnPost = { + commentType: 'post' + postId: string +} + export type ContractComment = Comment export type GroupComment = Comment +export type PostComment = Comment diff --git a/common/contract-details.ts b/common/contract-details.ts index 02af6359..c231b1e4 100644 --- a/common/contract-details.ts +++ b/common/contract-details.ts @@ -27,10 +27,10 @@ export function contractMetrics(contract: Contract) { export function contractTextDetails(contract: Contract) { // eslint-disable-next-line @typescript-eslint/no-var-requires const dayjs = require('dayjs') - const { closeTime, tags } = contract + const { closeTime, groupLinks } = contract const { createdDate, resolvedDate, volumeLabel } = contractMetrics(contract) - const hashtags = tags.map((tag) => `#${tag}`) + const groupHashtags = groupLinks?.slice(0, 5).map((g) => `#${g.name}`) return ( `${resolvedDate ? `${createdDate} - ${resolvedDate}` : createdDate}` + @@ -40,7 +40,7 @@ export function contractTextDetails(contract: Contract) { ).format('MMM D, h:mma')}` : '') + ` • ${volumeLabel}` + - (hashtags.length > 0 ? ` • ${hashtags.join(' ')}` : '') + (groupHashtags ? ` • ${groupHashtags.join(' ')}` : '') ) } @@ -92,6 +92,7 @@ export const getOpenGraphProps = (contract: Contract) => { creatorAvatarUrl, description, numericValue, + resolution, } } @@ -103,6 +104,7 @@ export type OgCardProps = { creatorUsername: string creatorAvatarUrl?: string numericValue?: string + resolution?: string } export function buildCardUrl(props: OgCardProps, challenge?: Challenge) { @@ -113,22 +115,32 @@ export function buildCardUrl(props: OgCardProps, challenge?: Challenge) { creatorOutcome, acceptorOutcome, } = challenge || {} + const { + probability, + numericValue, + resolution, + creatorAvatarUrl, + question, + metadata, + creatorUsername, + creatorName, + } = props const { userName, userAvatarUrl } = acceptances?.[0] ?? {} const probabilityParam = - props.probability === undefined + probability === undefined ? '' - : `&probability=${encodeURIComponent(props.probability ?? '')}` + : `&probability=${encodeURIComponent(probability ?? '')}` const numericValueParam = - props.numericValue === undefined + numericValue === undefined ? '' - : `&numericValue=${encodeURIComponent(props.numericValue ?? '')}` + : `&numericValue=${encodeURIComponent(numericValue ?? '')}` const creatorAvatarUrlParam = - props.creatorAvatarUrl === undefined + creatorAvatarUrl === undefined ? '' - : `&creatorAvatarUrl=${encodeURIComponent(props.creatorAvatarUrl ?? '')}` + : `&creatorAvatarUrl=${encodeURIComponent(creatorAvatarUrl ?? '')}` const challengeUrlParams = challenge ? `&creatorAmount=${creatorAmount}&creatorOutcome=${creatorOutcome}` + @@ -136,16 +148,21 @@ export function buildCardUrl(props: OgCardProps, challenge?: Challenge) { `&acceptedName=${userName ?? ''}&acceptedAvatarUrl=${userAvatarUrl ?? ''}` : '' + const resolutionUrlParam = resolution + ? `&resolution=${encodeURIComponent(resolution)}` + : '' + // URL encode each of the props, then add them as query params return ( `https://manifold-og-image.vercel.app/m.png` + - `?question=${encodeURIComponent(props.question)}` + + `?question=${encodeURIComponent(question)}` + probabilityParam + numericValueParam + - `&metadata=${encodeURIComponent(props.metadata)}` + - `&creatorName=${encodeURIComponent(props.creatorName)}` + + `&metadata=${encodeURIComponent(metadata)}` + + `&creatorName=${encodeURIComponent(creatorName)}` + creatorAvatarUrlParam + - `&creatorUsername=${encodeURIComponent(props.creatorUsername)}` + - challengeUrlParams + `&creatorUsername=${encodeURIComponent(creatorUsername)}` + + challengeUrlParams + + resolutionUrlParam ) } diff --git a/common/contract.ts b/common/contract.ts index 5dc4b696..0d2a38ca 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -87,6 +87,12 @@ export type CPMM = { pool: { [outcome: string]: number } p: number // probability constant in y^p * n^(1-p) = k totalLiquidity: number // in M$ + prob: number + probChanges: { + day: number + week: number + month: number + } } export type Binary = { diff --git a/common/envs/constants.ts b/common/envs/constants.ts index 89d040e8..ba460d58 100644 --- a/common/envs/constants.ts +++ b/common/envs/constants.ts @@ -34,6 +34,11 @@ export const FIREBASE_CONFIG = ENV_CONFIG.firebaseConfig export const PROJECT_ID = ENV_CONFIG.firebaseConfig.projectId export const IS_PRIVATE_MANIFOLD = ENV_CONFIG.visibility === 'PRIVATE' +export const AUTH_COOKIE_NAME = `FBUSER_${PROJECT_ID.toUpperCase().replace( + /-/g, + '_' +)}` + // Manifold's domain or any subdomains thereof export const CORS_ORIGIN_MANIFOLD = new RegExp( '^https?://(?:[a-zA-Z0-9\\-]+\\.)*' + escapeRegExp(ENV_CONFIG.domain) + '$' diff --git a/common/envs/prod.ts b/common/envs/prod.ts index 2b1ee70e..b3b552eb 100644 --- a/common/envs/prod.ts +++ b/common/envs/prod.ts @@ -73,6 +73,7 @@ export const PROD_CONFIG: EnvConfig = { 'manticmarkets@gmail.com', // Manifold 'iansphilips@gmail.com', // Ian 'd4vidchee@gmail.com', // D4vid + 'federicoruizcassarino@gmail.com', // Fede ], visibility: 'PUBLIC', diff --git a/common/group.ts b/common/group.ts index 181ad153..19f3b7b8 100644 --- a/common/group.ts +++ b/common/group.ts @@ -6,13 +6,11 @@ export type Group = { creatorId: string // User id createdTime: number mostRecentActivityTime: number - memberIds: string[] // User ids anyoneCanJoin: boolean - contractIds: string[] - + totalContracts: number + totalMembers: number aboutPostId?: string chatDisabled?: boolean - mostRecentChatActivityTime?: number mostRecentContractAddedTime?: number } export const MAX_GROUP_NAME_LENGTH = 75 diff --git a/common/loans.ts b/common/loans.ts index 05b64474..e05f1c2a 100644 --- a/common/loans.ts +++ b/common/loans.ts @@ -118,7 +118,7 @@ const getFreeResponseContractLoanUpdate = ( contract: FreeResponseContract | MultipleChoiceContract, bets: Bet[] ) => { - const openBets = bets.filter((bet) => bet.isSold || bet.sale) + const openBets = bets.filter((bet) => !bet.isSold && !bet.sale) return openBets.map((bet) => { const loanAmount = bet.loanAmount ?? 0 diff --git a/common/new-contract.ts b/common/new-contract.ts index 17b872ab..431f435e 100644 --- a/common/new-contract.ts +++ b/common/new-contract.ts @@ -123,6 +123,8 @@ const getBinaryCpmmProps = (initialProb: number, ante: number) => { initialProbability: p, p, pool: pool, + prob: initialProb, + probChanges: { day: 0, week: 0, month: 0 }, } return system diff --git a/common/notification.ts b/common/notification.ts index 657ea2c1..9ec320fa 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -15,6 +15,7 @@ export type Notification = { sourceUserUsername?: string sourceUserAvatarUrl?: string sourceText?: string + data?: string sourceContractTitle?: string sourceContractCreatorUsername?: string diff --git a/common/redeem.ts b/common/redeem.ts index e0839ff8..f786a1c2 100644 --- a/common/redeem.ts +++ b/common/redeem.ts @@ -13,7 +13,10 @@ export const getRedeemableAmount = (bets: RedeemableBet[]) => { const yesShares = sumBy(yesBets, (b) => b.shares) const noShares = sumBy(noBets, (b) => b.shares) const shares = Math.max(Math.min(yesShares, noShares), 0) - const soldFrac = shares > 0 ? Math.min(yesShares, noShares) / shares : 0 + const soldFrac = + shares > 0 + ? Math.min(yesShares, noShares) / Math.max(yesShares, noShares) + : 0 const loanAmount = sumBy(bets, (bet) => bet.loanAmount ?? 0) const loanPayment = loanAmount * soldFrac const netAmount = shares - loanPayment diff --git a/common/util/tiptap-iframe.ts b/common/util/tiptap-iframe.ts index 5af63d2f..9e260821 100644 --- a/common/util/tiptap-iframe.ts +++ b/common/util/tiptap-iframe.ts @@ -35,7 +35,7 @@ export default Node.create({ HTMLAttributes: { class: 'iframe-wrapper' + ' ' + wrapperClasses, // Tailwind JIT doesn't seem to pick up `pb-[20rem]`, so we hack this in: - style: 'padding-bottom: 20rem;', + style: 'padding-bottom: 20rem; ', }, } }, @@ -48,6 +48,9 @@ export default Node.create({ frameborder: { default: 0, }, + height: { + default: 0, + }, allowfullscreen: { default: this.options.allowFullscreen, parseHTML: () => this.options.allowFullscreen, @@ -60,6 +63,11 @@ export default Node.create({ }, renderHTML({ HTMLAttributes }) { + this.options.HTMLAttributes.style = + this.options.HTMLAttributes.style + + ' height: ' + + HTMLAttributes.height + + ';' return [ 'div', this.options.HTMLAttributes, diff --git a/docs/docs/api.md b/docs/docs/api.md index c02a5141..64e26de8 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -54,19 +54,33 @@ Returns the authenticated user. Gets all groups, in no particular order. +Parameters: +- `availableToUserId`: Optional. if specified, only groups that the user can + join and groups they've already joined will be returned. + Requires no authorization. -### `GET /v0/groups/[slug]` +### `GET /v0/group/[slug]` Gets a group by its slug. -Requires no authorization. +Requires no authorization. +Note: group is singular in the URL. -### `GET /v0/groups/by-id/[id]` +### `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. +Note: group is singular in the URL. + ### `GET /v0/markets` diff --git a/firestore.rules b/firestore.rules index e42e3ed7..9a72e454 100644 --- a/firestore.rules +++ b/firestore.rules @@ -12,7 +12,9 @@ service cloud.firestore { 'taowell@gmail.com', 'abc.sinclair@gmail.com', 'manticmarkets@gmail.com', - 'iansphilips@gmail.com' + 'iansphilips@gmail.com', + 'd4vidchee@gmail.com', + 'federicoruizcassarino@gmail.com' ] } @@ -160,25 +162,40 @@ service cloud.firestore { .hasOnly(['isSeen', 'viewTime']); } - match /groups/{groupId} { + match /{somePath=**}/groupMembers/{memberId} { + allow read; + } + + match /{somePath=**}/groupContracts/{contractId} { + allow read; + } + + match /groups/{groupId} { allow read; allow update: if (request.auth.uid == resource.data.creatorId || isAdmin()) && request.resource.data.diff(resource.data) .affectedKeys() - .hasOnly(['name', 'about', 'contractIds', 'memberIds', 'anyoneCanJoin', 'aboutPostId' ]); - allow update: if (request.auth.uid in resource.data.memberIds || resource.data.anyoneCanJoin) - && request.resource.data.diff(resource.data) - .affectedKeys() - .hasOnly([ 'contractIds', 'memberIds' ]); + .hasOnly(['name', 'about', 'anyoneCanJoin', 'aboutPostId' ]); allow delete: if request.auth.uid == resource.data.creatorId; - function isMember() { - return request.auth.uid in get(/databases/$(database)/documents/groups/$(groupId)).data.memberIds; + match /groupContracts/{contractId} { + allow write: if isGroupMember() || request.auth.uid == get(/databases/$(database)/documents/groups/$(groupId)).data.creatorId + } + + match /groupMembers/{memberId}{ + allow create: if request.auth.uid == get(/databases/$(database)/documents/groups/$(groupId)).data.creatorId || (request.auth.uid == request.resource.data.userId && get(/databases/$(database)/documents/groups/$(groupId)).data.anyoneCanJoin); + allow delete: if request.auth.uid == resource.data.userId; + } + + function isGroupMember() { + return exists(/databases/$(database)/documents/groups/$(groupId)/groupMembers/$(request.auth.uid)); } + match /comments/{commentId} { allow read; - allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) && isMember(); + allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) && isGroupMember(); } + } match /posts/{postId} { @@ -188,6 +205,10 @@ service cloud.firestore { .affectedKeys() .hasOnly(['name', 'content']); allow delete: if isAdmin() || request.auth.uid == resource.data.creatorId; + match /comments/{commentId} { + allow read; + allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) ; + } } } } diff --git a/functions/src/change-user-info.ts b/functions/src/change-user-info.ts index aa041856..ca66f1ba 100644 --- a/functions/src/change-user-info.ts +++ b/functions/src/change-user-info.ts @@ -37,6 +37,45 @@ export const changeUser = async ( avatarUrl?: string } ) => { + // Update contracts, comments, and answers outside of a transaction to avoid contention. + // Using bulkWriter to supports >500 writes at a time + const contractsRef = firestore + .collection('contracts') + .where('creatorId', '==', user.id) + + const contracts = await contractsRef.get() + + const contractUpdate: Partial = removeUndefinedProps({ + creatorName: update.name, + creatorUsername: update.username, + creatorAvatarUrl: update.avatarUrl, + }) + + const commentSnap = await firestore + .collectionGroup('comments') + .where('userUsername', '==', user.username) + .get() + + const commentUpdate: Partial = removeUndefinedProps({ + userName: update.name, + userUsername: update.username, + userAvatarUrl: update.avatarUrl, + }) + + const answerSnap = await firestore + .collectionGroup('answers') + .where('username', '==', user.username) + .get() + const answerUpdate: Partial = removeUndefinedProps(update) + + const bulkWriter = firestore.bulkWriter() + commentSnap.docs.forEach((d) => bulkWriter.update(d.ref, commentUpdate)) + answerSnap.docs.forEach((d) => bulkWriter.update(d.ref, answerUpdate)) + contracts.docs.forEach((d) => bulkWriter.update(d.ref, contractUpdate)) + await bulkWriter.flush() + console.log('Done writing!') + + // Update the username inside a transaction return await firestore.runTransaction(async (transaction) => { if (update.username) { update.username = cleanUsername(update.username) @@ -58,42 +97,7 @@ export const changeUser = async ( const userRef = firestore.collection('users').doc(user.id) const userUpdate: Partial = removeUndefinedProps(update) - - const contractsRef = firestore - .collection('contracts') - .where('creatorId', '==', user.id) - - const contracts = await transaction.get(contractsRef) - - const contractUpdate: Partial = removeUndefinedProps({ - creatorName: update.name, - creatorUsername: update.username, - creatorAvatarUrl: update.avatarUrl, - }) - - const commentSnap = await transaction.get( - firestore - .collectionGroup('comments') - .where('userUsername', '==', user.username) - ) - - const commentUpdate: Partial = removeUndefinedProps({ - userName: update.name, - userUsername: update.username, - userAvatarUrl: update.avatarUrl, - }) - - const answerSnap = await transaction.get( - firestore - .collectionGroup('answers') - .where('username', '==', user.username) - ) - const answerUpdate: Partial = removeUndefinedProps(update) - transaction.update(userRef, userUpdate) - commentSnap.docs.forEach((d) => transaction.update(d.ref, commentUpdate)) - answerSnap.docs.forEach((d) => transaction.update(d.ref, answerUpdate)) - contracts.docs.forEach((d) => transaction.update(d.ref, contractUpdate)) }) } diff --git a/functions/src/create-group.ts b/functions/src/create-group.ts index 71c6bd64..fc64aeff 100644 --- a/functions/src/create-group.ts +++ b/functions/src/create-group.ts @@ -58,13 +58,23 @@ export const creategroup = newEndpoint({}, async (req, auth) => { createdTime: Date.now(), mostRecentActivityTime: Date.now(), // TODO: allow users to add contract ids on group creation - contractIds: [], anyoneCanJoin, - memberIds, + totalContracts: 0, + totalMembers: memberIds.length, } await groupRef.create(group) + // create a GroupMemberDoc for each member + await Promise.all( + memberIds.map((memberId) => + groupRef.collection('groupMembers').doc(memberId).create({ + userId: memberId, + createdTime: Date.now(), + }) + ) + ) + return { status: 'success', group: group } }) diff --git a/functions/src/create-market.ts b/functions/src/create-market.ts index e9804f90..300d91f2 100644 --- a/functions/src/create-market.ts +++ b/functions/src/create-market.ts @@ -155,8 +155,14 @@ export const createmarket = newEndpoint({}, async (req, auth) => { } group = groupDoc.data() as Group + const groupMembersSnap = await firestore + .collection(`groups/${groupId}/groupMembers`) + .get() + const groupMemberDocs = groupMembersSnap.docs.map( + (doc) => doc.data() as { userId: string; createdTime: number } + ) if ( - !group.memberIds.includes(user.id) && + !groupMemberDocs.map((m) => m.userId).includes(user.id) && !group.anyoneCanJoin && group.creatorId !== user.id ) { @@ -227,11 +233,20 @@ export const createmarket = newEndpoint({}, async (req, auth) => { await contractRef.create(contract) if (group != null) { - if (!group.contractIds.includes(contractRef.id)) { + const groupContractsSnap = await firestore + .collection(`groups/${groupId}/groupContracts`) + .get() + const groupContracts = groupContractsSnap.docs.map( + (doc) => doc.data() as { contractId: string; createdTime: number } + ) + if (!groupContracts.map((c) => c.contractId).includes(contractRef.id)) { await createGroupLinks(group, [contractRef.id], auth.uid) - const groupDocRef = firestore.collection('groups').doc(group.id) - groupDocRef.update({ - contractIds: uniq([...group.contractIds, contractRef.id]), + const groupContractRef = firestore + .collection(`groups/${groupId}/groupContracts`) + .doc(contract.id) + await groupContractRef.set({ + contractId: contract.id, + createdTime: Date.now(), }) } } diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 8ed14704..131d6e85 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -151,15 +151,6 @@ export const createNotification = async ( } } - const notifyContractCreatorOfUniqueBettorsBonus = async ( - userToReasonTexts: user_to_reason_texts, - userId: string - ) => { - userToReasonTexts[userId] = { - reason: 'unique_bettors_on_your_contract', - } - } - const userToReasonTexts: user_to_reason_texts = {} // The following functions modify the userToReasonTexts object in place. @@ -192,16 +183,6 @@ export const createNotification = async ( sourceContract ) { await notifyContractCreator(userToReasonTexts, sourceContract) - } else if ( - sourceType === 'bonus' && - sourceUpdateType === 'created' && - sourceContract - ) { - // Note: the daily bonus won't have a contract attached to it - await notifyContractCreatorOfUniqueBettorsBonus( - userToReasonTexts, - sourceContract.creatorId - ) } await createUsersNotifications(userToReasonTexts) @@ -737,3 +718,38 @@ export async function filterUserIdsForOnlyFollowerIds( ) return userIds.filter((id) => contractFollowersIds.includes(id)) } + +export const createUniqueBettorBonusNotification = async ( + contractCreatorId: string, + bettor: User, + txnId: string, + contract: Contract, + amount: number, + idempotencyKey: string +) => { + const notificationRef = firestore + .collection(`/users/${contractCreatorId}/notifications`) + .doc(idempotencyKey) + const notification: Notification = { + id: idempotencyKey, + userId: contractCreatorId, + reason: 'unique_bettors_on_your_contract', + createdTime: Date.now(), + isSeen: false, + sourceId: txnId, + sourceType: 'bonus', + sourceUpdateType: 'created', + sourceUserName: bettor.name, + sourceUserUsername: bettor.username, + sourceUserAvatarUrl: bettor.avatarUrl, + sourceText: amount.toString(), + sourceSlug: contract.slug, + sourceTitle: contract.question, + // Perhaps not necessary, but just in case + sourceContractSlug: contract.slug, + sourceContractId: contract.id, + sourceContractTitle: contract.question, + sourceContractCreatorUsername: contract.creatorUsername, + } + return await notificationRef.set(removeUndefinedProps(notification)) +} diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts index 35394e90..eabe0fd0 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -1,6 +1,5 @@ import * as admin from 'firebase-admin' import { z } from 'zod' -import { uniq } from 'lodash' import { PrivateUser, User } from '../../common/user' import { getUser, getUserByUsername, getValues } from './utils' @@ -17,7 +16,7 @@ import { import { track } from './analytics' import { APIError, newEndpoint, validate } from './api' -import { Group, NEW_USER_GROUP_SLUGS } from '../../common/group' +import { Group } from '../../common/group' import { SUS_STARTING_BALANCE, STARTING_BALANCE } from '../../common/economy' const bodySchema = z.object({ @@ -117,23 +116,8 @@ const addUserToDefaultGroups = async (user: User) => { firestore.collection('groups').where('slug', '==', slug) ) await firestore - .collection('groups') - .doc(groups[0].id) - .update({ - memberIds: uniq(groups[0].memberIds.concat(user.id)), - }) - } - - for (const slug of NEW_USER_GROUP_SLUGS) { - const groups = await getValues( - firestore.collection('groups').where('slug', '==', slug) - ) - const group = groups[0] - await firestore - .collection('groups') - .doc(group.id) - .update({ - memberIds: uniq(group.memberIds.concat(user.id)), - }) + .collection(`groups/${groups[0].id}/groupMembers`) + .doc(user.id) + .set({ userId: user.id, createdTime: Date.now() }) } } diff --git a/functions/src/email-templates/market-close.html b/functions/src/email-templates/market-close.html index 01a53e98..fa44c1d5 100644 --- a/functions/src/email-templates/market-close.html +++ b/functions/src/email-templates/market-close.html @@ -351,8 +351,7 @@ font-size: 16px; margin: 0; " /> - Resolve your market to earn {{creatorFee}} as the - creator commission. + Please resolve your market.
diff --git a/og-image/api/_lib/parser.ts b/og-image/api/_lib/parser.ts index 6d5c9b3d..131a3cc4 100644 --- a/og-image/api/_lib/parser.ts +++ b/og-image/api/_lib/parser.ts @@ -21,6 +21,7 @@ export function parseRequest(req: IncomingMessage) { creatorName, creatorUsername, creatorAvatarUrl, + resolution, // Challenge attributes: challengerAmount, @@ -71,6 +72,7 @@ export function parseRequest(req: IncomingMessage) { question: getString(question) || 'Will you create a prediction market on Manifold?', + resolution: getString(resolution), probability: getString(probability), numericValue: getString(numericValue) || '', metadata: getString(metadata) || 'Jan 1  •  M$ 123 pool', diff --git a/og-image/api/_lib/template-css.ts b/og-image/api/_lib/template-css.ts new file mode 100644 index 00000000..f4ca6660 --- /dev/null +++ b/og-image/api/_lib/template-css.ts @@ -0,0 +1,81 @@ +import { sanitizeHtml } from './sanitizer' + +export function getTemplateCss(theme: string, fontSize: string) { + let background = 'white' + let foreground = 'black' + let radial = 'lightgray' + + if (theme === 'dark') { + background = 'black' + foreground = 'white' + radial = 'dimgray' + } + // To use Readex Pro: `font-family: 'Readex Pro', sans-serif;` + return ` + @import url('https://fonts.googleapis.com/css2?family=Major+Mono+Display&family=Readex+Pro:wght@400;700&display=swap'); + + body { + background: ${background}; + background-image: radial-gradient(circle at 25px 25px, ${radial} 2%, transparent 0%), radial-gradient(circle at 75px 75px, ${radial} 2%, transparent 0%); + background-size: 100px 100px; + height: 100vh; + font-family: "Readex Pro", sans-serif; + } + + code { + color: #D400FF; + font-family: 'Vera'; + white-space: pre-wrap; + letter-spacing: -5px; + } + + code:before, code:after { + content: '\`'; + } + + .logo-wrapper { + display: flex; + align-items: center; + align-content: center; + justify-content: center; + justify-items: center; + } + + .logo { + margin: 0 75px; + } + + .plus { + color: #BBB; + font-family: Times New Roman, Verdana; + font-size: 100px; + } + + .spacer { + margin: 150px; + } + + .emoji { + height: 1em; + width: 1em; + margin: 0 .05em 0 .1em; + vertical-align: -0.1em; + } + + .heading { + font-family: 'Major Mono Display', monospace; + font-size: ${sanitizeHtml(fontSize)}; + font-style: normal; + color: ${foreground}; + line-height: 1.8; + } + + .font-major-mono { + font-family: "Major Mono Display", monospace; + } + + .text-primary { + color: #11b981; + } + ` +} diff --git a/og-image/api/_lib/template.ts b/og-image/api/_lib/template.ts index f59740c5..f8e235b7 100644 --- a/og-image/api/_lib/template.ts +++ b/og-image/api/_lib/template.ts @@ -1,85 +1,5 @@ -import { sanitizeHtml } from './sanitizer' import { ParsedRequest } from './types' - -function getCss(theme: string, fontSize: string) { - let background = 'white' - let foreground = 'black' - let radial = 'lightgray' - - if (theme === 'dark') { - background = 'black' - foreground = 'white' - radial = 'dimgray' - } - // To use Readex Pro: `font-family: 'Readex Pro', sans-serif;` - return ` - @import url('https://fonts.googleapis.com/css2?family=Major+Mono+Display&family=Readex+Pro:wght@400;700&display=swap'); - - body { - background: ${background}; - background-image: radial-gradient(circle at 25px 25px, ${radial} 2%, transparent 0%), radial-gradient(circle at 75px 75px, ${radial} 2%, transparent 0%); - background-size: 100px 100px; - height: 100vh; - font-family: "Readex Pro", sans-serif; - } - - code { - color: #D400FF; - font-family: 'Vera'; - white-space: pre-wrap; - letter-spacing: -5px; - } - - code:before, code:after { - content: '\`'; - } - - .logo-wrapper { - display: flex; - align-items: center; - align-content: center; - justify-content: center; - justify-items: center; - } - - .logo { - margin: 0 75px; - } - - .plus { - color: #BBB; - font-family: Times New Roman, Verdana; - font-size: 100px; - } - - .spacer { - margin: 150px; - } - - .emoji { - height: 1em; - width: 1em; - margin: 0 .05em 0 .1em; - vertical-align: -0.1em; - } - - .heading { - font-family: 'Major Mono Display', monospace; - font-size: ${sanitizeHtml(fontSize)}; - font-style: normal; - color: ${foreground}; - line-height: 1.8; - } - - .font-major-mono { - font-family: "Major Mono Display", monospace; - } - - .text-primary { - color: #11b981; - } - ` -} +import { getTemplateCss } from './template-css' export function getHtml(parsedReq: ParsedRequest) { const { @@ -92,6 +12,7 @@ export function getHtml(parsedReq: ParsedRequest) { creatorUsername, creatorAvatarUrl, numericValue, + resolution, } = parsedReq const MAX_QUESTION_CHARS = 100 const truncatedQuestion = @@ -99,6 +20,49 @@ export function getHtml(parsedReq: ParsedRequest) { ? question.slice(0, MAX_QUESTION_CHARS) + '...' : question const hideAvatar = creatorAvatarUrl ? '' : 'hidden' + + let resolutionColor = 'text-primary' + let resolutionString = 'YES' + switch (resolution) { + case 'YES': + break + case 'NO': + resolutionColor = 'text-red-500' + resolutionString = 'NO' + break + case 'CANCEL': + resolutionColor = 'text-yellow-500' + resolutionString = 'N/A' + break + case 'MKT': + resolutionColor = 'text-blue-500' + resolutionString = numericValue ? numericValue : probability + break + } + + const resolutionDiv = ` + +
+ ${resolutionString} +
+
${ + resolution === 'CANCEL' ? '' : 'resolved' + }
+
` + + const probabilityDiv = ` + +
${probability}
+
chance
+
` + + const numericValueDiv = ` + +
${numericValue}
+
expected
+
+ ` + return ` @@ -108,7 +72,7 @@ export function getHtml(parsedReq: ParsedRequest) {
@@ -148,21 +112,22 @@ export function getHtml(parsedReq: ParsedRequest) {
${truncatedQuestion}
-
-
${probability}
-
${probability !== '' ? 'chance' : ''}
- -
${ - numericValue !== '' && probability === '' ? numericValue : '' - }
-
${numericValue !== '' ? 'expected' : ''}
-
+
+ ${ + resolution + ? resolutionDiv + : numericValue + ? numericValueDiv + : probability + ? probabilityDiv + : '' + }
-
+
${metadata}
diff --git a/og-image/api/_lib/types.ts b/og-image/api/_lib/types.ts index ef0a8135..ac1e7699 100644 --- a/og-image/api/_lib/types.ts +++ b/og-image/api/_lib/types.ts @@ -19,6 +19,7 @@ export interface ParsedRequest { creatorName: string creatorUsername: string creatorAvatarUrl: string + resolution: string // Challenge attributes: challengerAmount: string challengerOutcome: string diff --git a/web/components/amount-input.tsx b/web/components/amount-input.tsx index 971a5496..2ad745a8 100644 --- a/web/components/amount-input.tsx +++ b/web/components/amount-input.tsx @@ -84,6 +84,7 @@ export function BuyAmountInput(props: { setError: (error: string | undefined) => void minimumAmount?: number disabled?: boolean + showSliderOnMobile?: boolean className?: string inputClassName?: string // Needed to focus the amount input @@ -94,6 +95,7 @@ export function BuyAmountInput(props: { onChange, error, setError, + showSliderOnMobile: showSlider, disabled, className, inputClassName, @@ -120,16 +122,41 @@ export function BuyAmountInput(props: { } } + const parseRaw = (x: number) => { + if (x <= 100) return x + if (x <= 130) return 100 + (x - 100) * 5 + return 250 + (x - 130) * 10 + } + + const getRaw = (x: number) => { + if (x <= 100) return x + if (x <= 250) return 100 + (x - 100) / 5 + return 130 + (x - 250) / 10 + } + return ( - + <> + + {showSlider && ( + onAmountChange(parseRaw(parseInt(e.target.value)))} + className="range range-lg only-thumb z-40 mb-2 xl:hidden" + step="5" + /> + )} + ) } diff --git a/web/components/answers/answer-bet-panel.tsx b/web/components/answers/answer-bet-panel.tsx index f04d752f..f84ff1a3 100644 --- a/web/components/answers/answer-bet-panel.tsx +++ b/web/components/answers/answer-bet-panel.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx' -import { useEffect, useRef, useState } from 'react' +import React, { useEffect, useRef, useState } from 'react' import { XIcon } from '@heroicons/react/solid' import { Answer } from 'common/answer' @@ -26,7 +26,7 @@ import { Bet } from 'common/bet' import { track } from 'web/lib/service/analytics' import { BetSignUpPrompt } from '../sign-up-prompt' import { isIOS } from 'web/lib/util/device' -import { AlertBox } from '../alert-box' +import { WarningConfirmationButton } from '../warning-confirmation-button' export function AnswerBetPanel(props: { answer: Answer @@ -116,11 +116,20 @@ export function AnswerBetPanel(props: { const bankrollFraction = (betAmount ?? 0) / (user?.balance ?? 1e9) + const warning = + (betAmount ?? 0) > 10 && bankrollFraction >= 0.5 && bankrollFraction <= 1 + ? `You might not want to spend ${formatPercent( + bankrollFraction + )} of your balance on a single bet. \n\nCurrent balance: ${formatMoney( + user?.balance ?? 0 + )}` + : undefined + return (
- Bet on {isModal ? `"${answer.text}"` : 'this answer'} + Buy answer: {isModal ? `"${answer.text}"` : 'this answer'}
{!isModal && ( @@ -132,7 +141,11 @@ export function AnswerBetPanel(props: { )}
-
Amount
+ + Amount + Balance: {formatMoney(user?.balance ?? 0)} + + - {(betAmount ?? 0) > 10 && - bankrollFraction >= 0.5 && - bankrollFraction <= 1 ? ( - - ) : ( - '' - )} -
Probability
@@ -193,16 +192,17 @@ export function AnswerBetPanel(props: { {user ? ( - + /> ) : ( )} diff --git a/web/components/answers/answers-graph.tsx b/web/components/answers/answers-graph.tsx index dae3a8b5..e4167d11 100644 --- a/web/components/answers/answers-graph.tsx +++ b/web/components/answers/answers-graph.tsx @@ -18,19 +18,20 @@ export const AnswersGraph = memo(function AnswersGraph(props: { }) { const { contract, bets, height } = props const { createdTime, resolutionTime, closeTime, answers } = contract + const now = Date.now() const { probsByOutcome, sortedOutcomes } = computeProbsByOutcome( bets, contract ) - const isClosed = !!closeTime && Date.now() > closeTime + const isClosed = !!closeTime && now > closeTime const latestTime = dayjs( resolutionTime && isClosed ? Math.min(resolutionTime, closeTime) : isClosed ? closeTime - : resolutionTime ?? Date.now() + : resolutionTime ?? now ) const { width } = useWindowSize() @@ -71,14 +72,14 @@ export const AnswersGraph = memo(function AnswersGraph(props: { const yTickValues = [0, 25, 50, 75, 100] const numXTickValues = isLargeWidth ? 5 : 2 - const startDate = new Date(contract.createdTime) - const endDate = dayjs(startDate).add(1, 'hour').isAfter(latestTime) - ? latestTime.add(1, 'hours').toDate() - : latestTime.toDate() - const includeMinute = dayjs(endDate).diff(startDate, 'hours') < 2 + const startDate = dayjs(contract.createdTime) + const endDate = startDate.add(1, 'hour').isAfter(latestTime) + ? latestTime.add(1, 'hours') + : latestTime + const includeMinute = endDate.diff(startDate, 'hours') < 2 - const multiYear = !dayjs(startDate).isSame(latestTime, 'year') - const lessThanAWeek = dayjs(startDate).add(1, 'week').isAfter(latestTime) + const multiYear = !startDate.isSame(latestTime, 'year') + const lessThanAWeek = startDate.add(1, 'week').isAfter(latestTime) return (
- formatTime(+d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek) + formatTime(now, +d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek) } axisBottom={{ tickValues: numXTickValues, format: (time) => - formatTime(+time, multiYear, lessThanAWeek, includeMinute), + formatTime(now, +time, multiYear, lessThanAWeek, includeMinute), }} colors={[ '#fca5a5', // red-300 @@ -158,23 +159,20 @@ function formatPercent(y: DatumValue) { } function formatTime( + now: number, time: number, includeYear: boolean, includeHour: boolean, includeMinute: boolean ) { const d = dayjs(time) - - if ( - d.add(1, 'minute').isAfter(Date.now()) && - d.subtract(1, 'minute').isBefore(Date.now()) - ) + if (d.add(1, 'minute').isAfter(now) && d.subtract(1, 'minute').isBefore(now)) return 'Now' let format: string - if (d.isSame(Date.now(), 'day')) { + if (d.isSame(now, 'day')) { format = '[Today]' - } else if (d.add(1, 'day').isSame(Date.now(), 'day')) { + } else if (d.add(1, 'day').isSame(now, 'day')) { format = '[Yesterday]' } else { format = 'MMM D' diff --git a/web/components/answers/create-answer-panel.tsx b/web/components/answers/create-answer-panel.tsx index cef60138..7e20e92e 100644 --- a/web/components/answers/create-answer-panel.tsx +++ b/web/components/answers/create-answer-panel.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx' -import { useState } from 'react' +import React, { useState } from 'react' import Textarea from 'react-expanding-textarea' import { findBestMatch } from 'string-similarity' @@ -120,7 +120,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { return ( - +
Add your answer