diff --git a/common/.eslintrc.js b/common/.eslintrc.js index 3d6cfa82..c6f9703e 100644 --- a/common/.eslintrc.js +++ b/common/.eslintrc.js @@ -1,6 +1,7 @@ module.exports = { plugins: ['lodash'], extends: ['eslint:recommended'], + ignorePatterns: ['lib'], env: { browser: true, node: true, @@ -31,6 +32,7 @@ module.exports = { rules: { 'no-extra-semi': 'off', 'no-constant-condition': ['error', { checkLoops: false }], + 'linebreak-style': ['error', 'unix'], 'lodash/import-scope': [2, 'member'], }, } diff --git a/common/antes.ts b/common/antes.ts index becc9b7e..b3dd990b 100644 --- a/common/antes.ts +++ b/common/antes.ts @@ -10,14 +10,12 @@ import { import { User } from './user' import { LiquidityProvision } from './liquidity-provision' import { noFees } from './fees' +import { ENV_CONFIG } from './envs/constants' -export const FIXED_ANTE = 100 - -// deprecated -export const PHANTOM_ANTE = 0.001 -export const MINIMUM_ANTE = 50 +export const FIXED_ANTE = ENV_CONFIG.fixedAnte ?? 100 export const HOUSE_LIQUIDITY_PROVIDER_ID = 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2' // @ManifoldMarkets' id +export const DEV_HOUSE_LIQUIDITY_PROVIDER_ID = '94YYTk1AFWfbWMpfYcvnnwI1veP2' // @ManifoldMarkets' id export function getCpmmInitialLiquidity( providerId: string, diff --git a/common/envs/prod.ts b/common/envs/prod.ts index f5a0e55e..f8aaf4cc 100644 --- a/common/envs/prod.ts +++ b/common/envs/prod.ts @@ -18,13 +18,17 @@ export type EnvConfig = { faviconPath?: string // Should be a file in /public navbarLogoPath?: string newQuestionPlaceholders: string[] + + // Currency controls + fixedAnte?: number + startingBalance?: number } type FirebaseConfig = { apiKey: string authDomain: string projectId: string - region: string + region?: string storageBucket: string messagingSenderId: string appId: string diff --git a/common/new-bet.ts b/common/new-bet.ts index 73f3cf86..c35d5bed 100644 --- a/common/new-bet.ts +++ b/common/new-bet.ts @@ -30,9 +30,9 @@ import { floatingLesserEqual, } from './util/math' -export type CandidateBet = Omit +export type CandidateBet = Omit export type BetInfo = { - newBet: CandidateBet + newBet: CandidateBet newPool?: { [outcome: string]: number } newTotalShares?: { [outcome: string]: number } newTotalBets?: { [outcome: string]: number } @@ -209,7 +209,7 @@ export const getBinaryCpmmBetInfo = ( const takerShares = sumBy(takers, 'shares') const isFilled = floatingEqual(betAmount, takerAmount) - const newBet = removeUndefinedProps({ + const newBet: CandidateBet = removeUndefinedProps({ amount: betAmount, limitProb, isFilled, @@ -269,7 +269,7 @@ export const getNewBinaryDpmBetInfo = ( const probBefore = getDpmProbability(contract.totalShares) const probAfter = getDpmProbability(newTotalShares) - const newBet: CandidateBet = { + const newBet: CandidateBet = { contractId: contract.id, amount, loanAmount, @@ -306,7 +306,7 @@ export const getNewMultiBetInfo = ( const probBefore = getDpmOutcomeProbability(totalShares, outcome) const probAfter = getDpmOutcomeProbability(newTotalShares, outcome) - const newBet: CandidateBet = { + const newBet: CandidateBet = { contractId: contract.id, amount, loanAmount, diff --git a/common/notification.ts b/common/notification.ts index 64a00a36..da8a045a 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -22,6 +22,8 @@ export type Notification = { sourceSlug?: string sourceTitle?: string + + isSeenOnHref?: string } export type notification_source_types = | 'contract' @@ -34,6 +36,7 @@ export type notification_source_types = | 'admin_message' | 'group' | 'user' + | 'bonus' export type notification_source_update_types = | 'created' @@ -56,3 +59,6 @@ export type notification_reason_types = | 'added_you_to_group' | 'you_referred_user' | 'user_joined_to_bet_on_your_market' + | 'unique_bettors_on_your_contract' + | 'on_group_you_are_member_of' + | 'tip_received' diff --git a/common/numeric-constants.ts b/common/numeric-constants.ts index ef364b74..46885668 100644 --- a/common/numeric-constants.ts +++ b/common/numeric-constants.ts @@ -3,3 +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 diff --git a/common/pseudo-numeric.ts b/common/pseudo-numeric.ts index 9a322e35..c99e670f 100644 --- a/common/pseudo-numeric.ts +++ b/common/pseudo-numeric.ts @@ -17,7 +17,7 @@ export const getMappedValue = if (isLogScale) { const logValue = p * Math.log10(max - min) - return 10 ** logValue + min + return 10 ** logValue + min } return p * (max - min) + min diff --git a/common/redeem.ts b/common/redeem.ts new file mode 100644 index 00000000..4a4080f6 --- /dev/null +++ b/common/redeem.ts @@ -0,0 +1,54 @@ +import { partition, sumBy } from 'lodash' + +import { Bet } from './bet' +import { getProbability } from './calculate' +import { CPMMContract } from './contract' +import { noFees } from './fees' +import { CandidateBet } from './new-bet' + +type RedeemableBet = Pick + +export const getRedeemableAmount = (bets: RedeemableBet[]) => { + const [yesBets, noBets] = partition(bets, (b) => b.outcome === 'YES') + const yesShares = sumBy(yesBets, (b) => b.shares) + const noShares = sumBy(noBets, (b) => b.shares) + const shares = Math.max(Math.min(yesShares, noShares), 0) + const loanAmount = sumBy(bets, (bet) => bet.loanAmount ?? 0) + const loanPayment = Math.min(loanAmount, shares) + const netAmount = shares - loanPayment + return { shares, loanPayment, netAmount } +} + +export const getRedemptionBets = ( + shares: number, + loanPayment: number, + contract: CPMMContract +) => { + const p = getProbability(contract) + const createdTime = Date.now() + const yesBet: CandidateBet = { + contractId: contract.id, + amount: p * -shares, + shares: -shares, + loanAmount: loanPayment ? -loanPayment / 2 : 0, + outcome: 'YES', + probBefore: p, + probAfter: p, + createdTime, + isRedemption: true, + fees: noFees, + } + const noBet: CandidateBet = { + contractId: contract.id, + amount: (1 - p) * -shares, + shares: -shares, + loanAmount: loanPayment ? -loanPayment / 2 : 0, + outcome: 'NO', + probBefore: p, + probAfter: p, + createdTime, + isRedemption: true, + fees: noFees, + } + return [yesBet, noBet] +} diff --git a/common/txn.ts b/common/txn.ts index 0e772e0d..53b08501 100644 --- a/common/txn.ts +++ b/common/txn.ts @@ -1,6 +1,6 @@ // A txn (pronounced "texan") respresents a payment between two ids on Manifold // Shortened from "transaction" to distinguish from Firebase transactions (and save chars) -type AnyTxnType = Donation | Tip | Manalink | Referral +type AnyTxnType = Donation | Tip | Manalink | Referral | Bonus type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK' export type Txn = { @@ -16,7 +16,8 @@ export type Txn = { amount: number token: 'M$' // | 'USD' | MarketOutcome - category: 'CHARITY' | 'MANALINK' | 'TIP' | 'REFERRAL' // | 'BET' + category: 'CHARITY' | 'MANALINK' | 'TIP' | 'REFERRAL' | 'UNIQUE_BETTOR_BONUS' + // Any extra data data?: { [key: string]: any } @@ -52,6 +53,12 @@ type Referral = { category: 'REFERRAL' } +type Bonus = { + fromType: 'BANK' + toType: 'USER' + category: 'UNIQUE_BETTOR_BONUS' +} + export type DonationTxn = Txn & Donation export type TipTxn = Txn & Tip export type ManalinkTxn = Txn & Manalink diff --git a/common/user.ts b/common/user.ts index 0a8565dd..477139fd 100644 --- a/common/user.ts +++ b/common/user.ts @@ -1,3 +1,5 @@ +import { ENV_CONFIG } from './envs/constants' + export type User = { id: string createdTime: number @@ -38,8 +40,9 @@ export type User = { referredByContractId?: string } -export const STARTING_BALANCE = 1000 -export const SUS_STARTING_BALANCE = 10 // for sus users, i.e. multiple sign ups for same person +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 type PrivateUser = { id: string // same as User.id @@ -54,6 +57,7 @@ export type PrivateUser = { initialIpAddress?: string apiKey?: string notificationPreferences?: notification_subscribe_types + lastTimeCheckedBonuses?: number } export type notification_subscribe_types = 'all' | 'less' | 'none' diff --git a/firestore.indexes.json b/firestore.indexes.json index 064f6f2f..e0cee632 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -337,6 +337,20 @@ "order": "DESCENDING" } ] + }, + { + "collectionGroup": "portfolioHistory", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "userId", + "order": "ASCENDING" + }, + { + "fieldPath": "timestamp", + "order": "ASCENDING" + } + ] } ], "fieldOverrides": [ diff --git a/firestore.rules b/firestore.rules index 4645343d..28ff4485 100644 --- a/firestore.rules +++ b/firestore.rules @@ -21,16 +21,15 @@ service cloud.firestore { allow update: if resource.data.id == request.auth.uid && request.resource.data.diff(resource.data).affectedKeys() .hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'referredByContractId']); - allow update: if resource.data.id == request.auth.uid - && request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['referredByUserId']) - // only one referral allowed per user - && !("referredByUserId" in resource.data) - // user can't refer themselves - && (resource.data.id != request.resource.data.referredByUserId) - // user can't refer someone who referred them quid pro quo - && get(/databases/$(database)/documents/users/$(request.resource.data.referredByUserId)).referredByUserId != resource.data.id; - + allow update: if resource.data.id == request.auth.uid + && request.resource.data.diff(resource.data).affectedKeys() + .hasOnly(['referredByUserId']) + // only one referral allowed per user + && !("referredByUserId" in resource.data) + // user can't refer themselves + && !(resource.data.id == request.resource.data.referredByUserId); + // quid pro quos enabled (only once though so nbd) - bc I can't make this work: + // && (get(/databases/$(database)/documents/users/$(request.resource.data.referredByUserId)).referredByUserId == resource.data.id); } match /{somePath=**}/portfolioHistory/{portfolioHistoryId} { diff --git a/functions/.env b/functions/.env new file mode 100644 index 00000000..0c4303df --- /dev/null +++ b/functions/.env @@ -0,0 +1,3 @@ +# This sets which EnvConfig is deployed to Firebase Cloud Functions + +NEXT_PUBLIC_FIREBASE_ENV=PROD diff --git a/functions/.eslintrc.js b/functions/.eslintrc.js index 7f571610..2c607231 100644 --- a/functions/.eslintrc.js +++ b/functions/.eslintrc.js @@ -1,7 +1,7 @@ module.exports = { plugins: ['lodash'], extends: ['eslint:recommended'], - ignorePatterns: ['lib'], + ignorePatterns: ['dist', 'lib'], env: { node: true, }, @@ -30,6 +30,7 @@ module.exports = { }, ], rules: { + 'linebreak-style': ['error', 'unix'], 'lodash/import-scope': [2, 'member'], }, } diff --git a/functions/.gitignore b/functions/.gitignore index 2aeae30c..58f30dcb 100644 --- a/functions/.gitignore +++ b/functions/.gitignore @@ -1,5 +1,4 @@ # Secrets -.env* .runtimeconfig.json # GCP deployment artifact diff --git a/functions/package.json b/functions/package.json index ee7bc92d..4c9f4338 100644 --- a/functions/package.json +++ b/functions/package.json @@ -5,7 +5,7 @@ "firestore": "dev-mantic-markets.appspot.com" }, "scripts": { - "build": "yarn compile && rm -rf dist && mkdir -p dist/functions && cp -R ../common/lib dist/common && cp -R lib/src dist/functions/src && cp ../yarn.lock dist && cp package.json dist", + "build": "yarn compile && rm -rf dist && mkdir -p dist/functions && cp -R ../common/lib dist/common && cp -R lib/src dist/functions/src && cp ../yarn.lock dist && cp package.json dist && cp .env dist", "compile": "tsc -b", "watch": "tsc -w", "shell": "yarn build && firebase functions:shell", @@ -23,6 +23,7 @@ "main": "functions/src/index.js", "dependencies": { "@amplitude/node": "1.10.0", + "@google-cloud/functions-framework": "3.1.2", "firebase-admin": "10.0.0", "firebase-functions": "3.21.2", "lodash": "4.17.21", diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index a32ed3bc..49bff5f7 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -17,7 +17,7 @@ import { removeUndefinedProps } from '../../common/util/object' const firestore = admin.firestore() type user_to_reason_texts = { - [userId: string]: { reason: notification_reason_types } + [userId: string]: { reason: notification_reason_types; isSeeOnHref?: string } } export const createNotification = async ( @@ -66,12 +66,11 @@ export const createNotification = async ( sourceUserAvatarUrl: sourceUser.avatarUrl, sourceText, sourceContractCreatorUsername: sourceContract?.creatorUsername, - // TODO: move away from sourceContractTitle to sourceTitle sourceContractTitle: sourceContract?.question, - // TODO: move away from sourceContractSlug to sourceSlug sourceContractSlug: sourceContract?.slug, sourceSlug: sourceSlug ? sourceSlug : sourceContract?.slug, sourceTitle: sourceTitle ? sourceTitle : sourceContract?.question, + isSeenOnHref: userToReasonTexts[userId].isSeeOnHref, } await notificationRef.set(removeUndefinedProps(notification)) }) @@ -267,6 +266,35 @@ export const createNotification = async ( } } + const notifyContractCreatorOfUniqueBettorsBonus = async ( + userToReasonTexts: user_to_reason_texts, + userId: string + ) => { + userToReasonTexts[userId] = { + reason: 'unique_bettors_on_your_contract', + } + } + + const notifyOtherGroupMembersOfComment = async ( + userToReasons: user_to_reason_texts, + userId: string + ) => { + if (shouldGetNotification(userId, userToReasons)) + userToReasons[userId] = { + reason: 'on_group_you_are_member_of', + isSeeOnHref: sourceSlug, + } + } + const notifyTippedUserOfNewTip = async ( + userToReasonTexts: user_to_reason_texts, + userId: string + ) => { + if (shouldGetNotification(userId, userToReasonTexts)) + userToReasonTexts[userId] = { + reason: 'tip_received', + } + } + const getUsersToNotify = async () => { const userToReasonTexts: user_to_reason_texts = {} // The following functions modify the userToReasonTexts object in place. @@ -277,10 +305,13 @@ export const createNotification = async ( await notifyUserAddedToGroup(userToReasonTexts, relatedUserId) } else if (sourceType === 'user' && relatedUserId) { await notifyUserReceivedReferralBonus(userToReasonTexts, relatedUserId) + } else if (sourceType === 'comment' && !sourceContract && relatedUserId) { + await notifyOtherGroupMembersOfComment(userToReasonTexts, relatedUserId) } // The following functions need sourceContract to be defined. if (!sourceContract) return userToReasonTexts + if ( sourceType === 'comment' || sourceType === 'answer' || @@ -309,6 +340,14 @@ export const createNotification = async ( }) } else if (sourceType === 'liquidity' && sourceUpdateType === 'created') { await notifyContractCreator(userToReasonTexts, sourceContract) + } else if (sourceType === 'bonus' && sourceUpdateType === 'created') { + // Note: the daily bonus won't have a contract attached to it + await notifyContractCreatorOfUniqueBettorsBonus( + userToReasonTexts, + sourceContract.creatorId + ) + } else if (sourceType === 'tip' && relatedUserId) { + await notifyTippedUserOfNewTip(userToReasonTexts, relatedUserId) } return userToReasonTexts } diff --git a/functions/src/get-daily-bonuses.ts b/functions/src/get-daily-bonuses.ts new file mode 100644 index 00000000..017c32fc --- /dev/null +++ b/functions/src/get-daily-bonuses.ts @@ -0,0 +1,142 @@ +import { APIError, newEndpoint } from './api' +import { isProd, log } from './utils' +import * as admin from 'firebase-admin' +import { PrivateUser } from '../../common/lib/user' +import { uniq } from 'lodash' +import { Bet } from '../../common/lib/bet' +const firestore = admin.firestore() +import { + DEV_HOUSE_LIQUIDITY_PROVIDER_ID, + HOUSE_LIQUIDITY_PROVIDER_ID, +} from '../../common/antes' +import { runTxn, TxnData } from './transact' +import { createNotification } from './create-notification' +import { User } from '../../common/lib/user' +import { Contract } from '../../common/lib/contract' +import { UNIQUE_BETTOR_BONUS_AMOUNT } from '../../common/numeric-constants' + +const BONUS_START_DATE = new Date('2022-07-01T00:00:00.000Z').getTime() +const QUERY_LIMIT_SECONDS = 60 + +export const getdailybonuses = newEndpoint({}, async (req, auth) => { + const { user, lastTimeCheckedBonuses } = await firestore.runTransaction( + async (trans) => { + const userSnap = await trans.get( + firestore.doc(`private-users/${auth.uid}`) + ) + if (!userSnap.exists) throw new APIError(400, 'User not found.') + const user = userSnap.data() as PrivateUser + const lastTimeCheckedBonuses = user.lastTimeCheckedBonuses ?? 0 + if (Date.now() - lastTimeCheckedBonuses < QUERY_LIMIT_SECONDS * 1000) + throw new APIError( + 400, + `Limited to one query per user per ${QUERY_LIMIT_SECONDS} seconds.` + ) + await trans.update(userSnap.ref, { + lastTimeCheckedBonuses: Date.now(), + }) + return { + user, + lastTimeCheckedBonuses, + } + } + ) + const fromUserId = isProd() + ? HOUSE_LIQUIDITY_PROVIDER_ID + : DEV_HOUSE_LIQUIDITY_PROVIDER_ID + const fromSnap = await firestore.doc(`users/${fromUserId}`).get() + if (!fromSnap.exists) throw new APIError(400, 'From user not found.') + const fromUser = fromSnap.data() as User + // Get all users contracts made since implementation time + const userContractsSnap = await firestore + .collection(`contracts`) + .where('creatorId', '==', user.id) + .where('createdTime', '>=', BONUS_START_DATE) + .get() + const userContracts = userContractsSnap.docs.map( + (doc) => doc.data() as Contract + ) + const nullReturn = { status: 'no bets', txn: null } + for (const contract of userContracts) { + const result = await firestore.runTransaction(async (trans) => { + const contractId = contract.id + // Get all bets made on user's contracts + const bets = ( + await firestore + .collection(`contracts/${contractId}/bets`) + .where('userId', '!=', user.id) + .get() + ).docs.map((bet) => bet.ref) + if (bets.length === 0) { + return nullReturn + } + const contractBetsSnap = await trans.getAll(...bets) + const contractBets = contractBetsSnap.map((doc) => doc.data() as Bet) + + const uniqueBettorIdsBeforeLastResetTime = uniq( + contractBets + .filter((bet) => bet.createdTime < lastTimeCheckedBonuses) + .map((bet) => bet.userId) + ) + + // Filter users for ONLY those that have made bets since the last daily bonus received time + const uniqueBettorIdsWithBetsAfterLastResetTime = uniq( + contractBets + .filter((bet) => bet.createdTime > lastTimeCheckedBonuses) + .map((bet) => bet.userId) + ) + + // Filter for users only present in the above list + const newUniqueBettorIds = + uniqueBettorIdsWithBetsAfterLastResetTime.filter( + (userId) => !uniqueBettorIdsBeforeLastResetTime.includes(userId) + ) + newUniqueBettorIds.length > 0 && + log( + `Got ${newUniqueBettorIds.length} new unique bettors since last bonus` + ) + if (newUniqueBettorIds.length === 0) { + return nullReturn + } + // Create combined txn for all unique bettors + const bonusTxnDetails = { + contractId: contractId, + uniqueBettors: newUniqueBettorIds.length, + } + const bonusTxn: TxnData = { + fromId: fromUser.id, + fromType: 'BANK', + toId: user.id, + toType: 'USER', + amount: UNIQUE_BETTOR_BONUS_AMOUNT * newUniqueBettorIds.length, + token: 'M$', + category: 'UNIQUE_BETTOR_BONUS', + description: JSON.stringify(bonusTxnDetails), + } + return await runTxn(trans, bonusTxn) + }) + + if (result.status != 'success' || !result.txn) { + result.status != nullReturn.status && + log(`No bonus for user: ${user.id} - reason:`, result.status) + } else { + log(`Bonus txn for user: ${user.id} completed:`, result.txn?.id) + await createNotification( + result.txn.id, + 'bonus', + 'created', + fromUser, + result.txn.id, + result.txn.amount + '', + contract, + undefined, + // No need to set the user id, we'll use the contract creator id + undefined, + contract.slug, + contract.question + ) + } + } + + return { userId: user.id, message: 'success' } +}) diff --git a/functions/src/index.ts b/functions/src/index.ts index f0a9c683..5b0bf89b 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -10,7 +10,7 @@ export * from './stripe' export * from './create-user' export * from './create-answer' export * from './on-create-bet' -export * from './on-create-comment' +export * from './on-create-comment-on-contract' export * from './on-view' export * from './unsubscribe' export * from './update-metrics' @@ -28,6 +28,8 @@ export * from './on-create-liquidity-provision' export * from './on-update-group' export * from './on-create-group' export * from './on-update-user' +export * from './on-create-comment-on-group' +export * from './on-create-txn' // v2 export * from './health' @@ -39,3 +41,4 @@ export * from './create-contract' export * from './withdraw-liquidity' export * from './create-group' export * from './resolve-market' +export * from './get-daily-bonuses' diff --git a/functions/src/on-create-comment.ts b/functions/src/on-create-comment-on-contract.ts similarity index 98% rename from functions/src/on-create-comment.ts rename to functions/src/on-create-comment-on-contract.ts index 8d52fd46..f7839b44 100644 --- a/functions/src/on-create-comment.ts +++ b/functions/src/on-create-comment-on-contract.ts @@ -11,7 +11,7 @@ import { createNotification } from './create-notification' const firestore = admin.firestore() -export const onCreateComment = functions +export const onCreateCommentOnContract = functions .runWith({ secrets: ['MAILGUN_KEY'] }) .firestore.document('contracts/{contractId}/comments/{commentId}') .onCreate(async (change, context) => { diff --git a/functions/src/on-create-comment-on-group.ts b/functions/src/on-create-comment-on-group.ts new file mode 100644 index 00000000..7217e602 --- /dev/null +++ b/functions/src/on-create-comment-on-group.ts @@ -0,0 +1,52 @@ +import * as functions from 'firebase-functions' +import { Comment } from '../../common/comment' +import * as admin from 'firebase-admin' +import { Group } from '../../common/group' +import { User } from '../../common/user' +import { createNotification } from './create-notification' +const firestore = admin.firestore() + +export const onCreateCommentOnGroup = functions.firestore + .document('groups/{groupId}/comments/{commentId}') + .onCreate(async (change, context) => { + const { eventId } = context + const { groupId } = context.params as { + groupId: string + } + + const comment = change.data() as Comment + const creatorSnapshot = await firestore + .collection('users') + .doc(comment.userId) + .get() + if (!creatorSnapshot.exists) throw new Error('Could not find user') + + const groupSnapshot = await firestore + .collection('groups') + .doc(groupId) + .get() + if (!groupSnapshot.exists) throw new Error('Could not find group') + + const group = groupSnapshot.data() as Group + await firestore.collection('groups').doc(groupId).update({ + mostRecentActivityTime: comment.createdTime, + }) + + await Promise.all( + group.memberIds.map(async (memberId) => { + return await createNotification( + comment.id, + 'comment', + 'created', + creatorSnapshot.data() as User, + eventId, + comment.text, + undefined, + undefined, + memberId, + `/group/${group.slug}`, + `${group.name}` + ) + }) + ) + }) diff --git a/functions/src/on-create-txn.ts b/functions/src/on-create-txn.ts new file mode 100644 index 00000000..d877ecac --- /dev/null +++ b/functions/src/on-create-txn.ts @@ -0,0 +1,68 @@ +import * as functions from 'firebase-functions' +import { Txn } from 'common/txn' +import { getContract, getUser, log } from './utils' +import { createNotification } from './create-notification' +import * as admin from 'firebase-admin' +import { Comment } from 'common/comment' + +const firestore = admin.firestore() + +export const onCreateTxn = functions.firestore + .document('txns/{txnId}') + .onCreate(async (change, context) => { + const txn = change.data() as Txn + const { eventId } = context + + if (txn.category === 'TIP') { + await handleTipTxn(txn, eventId) + } + }) + +async function handleTipTxn(txn: Txn, eventId: string) { + // get user sending and receiving tip + const [sender, receiver] = await Promise.all([ + getUser(txn.fromId), + getUser(txn.toId), + ]) + if (!sender || !receiver) { + log('Could not find corresponding users') + return + } + + if (!txn.data?.contractId || !txn.data?.commentId) { + log('No contractId or comment id in tip txn.data') + return + } + + const contract = await getContract(txn.data.contractId) + if (!contract) { + log('Could not find contract') + return + } + + const commentSnapshot = await firestore + .collection('contracts') + .doc(contract.id) + .collection('comments') + .doc(txn.data.commentId) + .get() + if (!commentSnapshot.exists) { + log('Could not find comment') + return + } + const comment = commentSnapshot.data() as Comment + + await createNotification( + txn.id, + 'tip', + 'created', + sender, + eventId, + txn.amount.toString(), + contract, + 'comment', + receiver.id, + txn.data?.commentId, + comment.text + ) +} diff --git a/functions/src/on-update-group.ts b/functions/src/on-update-group.ts index bc6f6ab4..feaa6443 100644 --- a/functions/src/on-update-group.ts +++ b/functions/src/on-update-group.ts @@ -12,6 +12,7 @@ export const onUpdateGroup = functions.firestore // ignore the update we just made if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime) return + // TODO: create notification with isSeeOnHref set to the group's /group/questions url await firestore .collection('groups') diff --git a/functions/src/redeem-shares.ts b/functions/src/redeem-shares.ts index 67922a65..0a69521f 100644 --- a/functions/src/redeem-shares.ts +++ b/functions/src/redeem-shares.ts @@ -1,96 +1,46 @@ import * as admin from 'firebase-admin' -import { partition, sumBy } from 'lodash' import { Bet } from '../../common/bet' -import { getProbability } from '../../common/calculate' +import { getRedeemableAmount, getRedemptionBets } from '../../common/redeem' import { Contract } from '../../common/contract' -import { noFees } from '../../common/fees' import { User } from '../../common/user' export const redeemShares = async (userId: string, contractId: string) => { - return await firestore.runTransaction(async (transaction) => { + return await firestore.runTransaction(async (trans) => { const contractDoc = firestore.doc(`contracts/${contractId}`) - const contractSnap = await transaction.get(contractDoc) + const contractSnap = await trans.get(contractDoc) if (!contractSnap.exists) return { status: 'error', message: 'Invalid contract' } const contract = contractSnap.data() as Contract - const { mechanism, outcomeType } = contract - if ( - !(outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') || - mechanism !== 'cpmm-1' - ) - return { status: 'success' } + const { mechanism } = contract + if (mechanism !== 'cpmm-1') return { status: 'success' } - const betsSnap = await transaction.get( - firestore - .collection(`contracts/${contract.id}/bets`) - .where('userId', '==', userId) - ) + const betsColl = firestore.collection(`contracts/${contract.id}/bets`) + const betsSnap = await trans.get(betsColl.where('userId', '==', userId)) const bets = betsSnap.docs.map((doc) => doc.data() as Bet) - const [yesBets, noBets] = partition(bets, (b) => b.outcome === 'YES') - const yesShares = sumBy(yesBets, (b) => b.shares) - const noShares = sumBy(noBets, (b) => b.shares) - - const amount = Math.min(yesShares, noShares) - if (amount <= 0) return - - const prevLoanAmount = sumBy(bets, (bet) => bet.loanAmount ?? 0) - const loanPaid = Math.min(prevLoanAmount, amount) - const netAmount = amount - loanPaid - - const p = getProbability(contract) - const createdTime = Date.now() - - const yesDoc = firestore.collection(`contracts/${contract.id}/bets`).doc() - const yesBet: Bet = { - id: yesDoc.id, - userId: userId, - contractId: contract.id, - amount: p * -amount, - shares: -amount, - loanAmount: loanPaid ? -loanPaid / 2 : 0, - outcome: 'YES', - probBefore: p, - probAfter: p, - createdTime, - isRedemption: true, - fees: noFees, - } - - const noDoc = firestore.collection(`contracts/${contract.id}/bets`).doc() - const noBet: Bet = { - id: noDoc.id, - userId: userId, - contractId: contract.id, - amount: (1 - p) * -amount, - shares: -amount, - loanAmount: loanPaid ? -loanPaid / 2 : 0, - outcome: 'NO', - probBefore: p, - probAfter: p, - createdTime, - isRedemption: true, - fees: noFees, + const { shares, loanPayment, netAmount } = getRedeemableAmount(bets) + if (netAmount === 0) { + return { status: 'success' } } + const [yesBet, noBet] = getRedemptionBets(shares, loanPayment, contract) const userDoc = firestore.doc(`users/${userId}`) - const userSnap = await transaction.get(userDoc) + const userSnap = await trans.get(userDoc) if (!userSnap.exists) return { status: 'error', message: 'User not found' } - const user = userSnap.data() as User - const newBalance = user.balance + netAmount if (!isFinite(newBalance)) { throw new Error('Invalid user balance for ' + user.username) } - transaction.update(userDoc, { balance: newBalance }) - - transaction.create(yesDoc, yesBet) - transaction.create(noDoc, noBet) + const yesDoc = betsColl.doc() + const noDoc = betsColl.doc() + trans.update(userDoc, { balance: newBalance }) + trans.create(yesDoc, { id: yesDoc.id, userId, ...yesBet }) + trans.create(noDoc, { id: noDoc.id, userId, ...noBet }) return { status: 'success' } }) diff --git a/functions/src/sell-shares.ts b/functions/src/sell-shares.ts index a0c19f2c..62e43105 100644 --- a/functions/src/sell-shares.ts +++ b/functions/src/sell-shares.ts @@ -46,7 +46,7 @@ export const sellshares = newEndpoint({}, async (req, auth) => { const outcomeBets = userBets.filter((bet) => bet.outcome == outcome) const maxShares = sumBy(outcomeBets, (bet) => bet.shares) - if (shares > maxShares + 0.000000000001) + if (shares > maxShares) throw new APIError(400, `You can only sell up to ${maxShares} shares.`) const { newBet, newPool, newP, fees } = getCpmmSellBetInfo( diff --git a/functions/src/withdraw-liquidity.ts b/functions/src/withdraw-liquidity.ts index 4c48ce49..cc8c84cf 100644 --- a/functions/src/withdraw-liquidity.ts +++ b/functions/src/withdraw-liquidity.ts @@ -1,138 +1,138 @@ -import * as functions from 'firebase-functions' -import * as admin from 'firebase-admin' - -import { CPMMContract } from '../../common/contract' -import { User } from '../../common/user' -import { subtractObjects } from '../../common/util/object' -import { LiquidityProvision } from '../../common/liquidity-provision' -import { getUserLiquidityShares } from '../../common/calculate-cpmm' -import { Bet } from '../../common/bet' -import { getProbability } from '../../common/calculate' -import { noFees } from '../../common/fees' - -import { APIError } from './api' -import { redeemShares } from './redeem-shares' - -export const withdrawLiquidity = functions - .runWith({ minInstances: 1 }) - .https.onCall( - async ( - data: { - contractId: string - }, - context - ) => { - const userId = context?.auth?.uid - if (!userId) return { status: 'error', message: 'Not authorized' } - - const { contractId } = data - if (!contractId) - return { status: 'error', message: 'Missing contract id' } - - return await firestore - .runTransaction(async (trans) => { - const lpDoc = firestore.doc(`users/${userId}`) - const lpSnap = await trans.get(lpDoc) - if (!lpSnap.exists) throw new APIError(400, 'User not found.') - const lp = lpSnap.data() as User - - const contractDoc = firestore.doc(`contracts/${contractId}`) - const contractSnap = await trans.get(contractDoc) - if (!contractSnap.exists) - throw new APIError(400, 'Contract not found.') - const contract = contractSnap.data() as CPMMContract - - const liquidityCollection = firestore.collection( - `contracts/${contractId}/liquidity` - ) - - const liquiditiesSnap = await trans.get(liquidityCollection) - - const liquidities = liquiditiesSnap.docs.map( - (doc) => doc.data() as LiquidityProvision - ) - - const userShares = getUserLiquidityShares( - userId, - contract, - liquidities - ) - - // zero all added amounts for now - // can add support for partial withdrawals in the future - liquiditiesSnap.docs - .filter( - (_, i) => - !liquidities[i].isAnte && liquidities[i].userId === userId - ) - .forEach((doc) => trans.update(doc.ref, { amount: 0 })) - - const payout = Math.min(...Object.values(userShares)) - if (payout <= 0) return {} - - const newBalance = lp.balance + payout - const newTotalDeposits = lp.totalDeposits + payout - trans.update(lpDoc, { - balance: newBalance, - totalDeposits: newTotalDeposits, - } as Partial) - - const newPool = subtractObjects(contract.pool, userShares) - - const minPoolShares = Math.min(...Object.values(newPool)) - const adjustedTotal = contract.totalLiquidity - payout - - // total liquidity is a bogus number; use minPoolShares to prevent from going negative - const newTotalLiquidity = Math.max(adjustedTotal, minPoolShares) - - trans.update(contractDoc, { - pool: newPool, - totalLiquidity: newTotalLiquidity, - }) - - const prob = getProbability(contract) - - // surplus shares become user's bets - const bets = Object.entries(userShares) - .map(([outcome, shares]) => - shares - payout < 1 // don't create bet if less than 1 share - ? undefined - : ({ - userId: userId, - contractId: contract.id, - amount: - (outcome === 'YES' ? prob : 1 - prob) * (shares - payout), - shares: shares - payout, - outcome, - probBefore: prob, - probAfter: prob, - createdTime: Date.now(), - isLiquidityProvision: true, - fees: noFees, - } as Omit) - ) - .filter((x) => x !== undefined) - - for (const bet of bets) { - const doc = firestore - .collection(`contracts/${contract.id}/bets`) - .doc() - trans.create(doc, { id: doc.id, ...bet }) - } - - return userShares - }) - .then(async (result) => { - // redeem surplus bet with pre-existing bets - await redeemShares(userId, contractId) - - console.log('userid', userId, 'withdraws', result) - return { status: 'success', userShares: result } - }) - .catch((e) => { - return { status: 'error', message: e.message } - }) - } - ) - -const firestore = admin.firestore() +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' + +import { CPMMContract } from '../../common/contract' +import { User } from '../../common/user' +import { subtractObjects } from '../../common/util/object' +import { LiquidityProvision } from '../../common/liquidity-provision' +import { getUserLiquidityShares } from '../../common/calculate-cpmm' +import { Bet } from '../../common/bet' +import { getProbability } from '../../common/calculate' +import { noFees } from '../../common/fees' + +import { APIError } from './api' +import { redeemShares } from './redeem-shares' + +export const withdrawLiquidity = functions + .runWith({ minInstances: 1 }) + .https.onCall( + async ( + data: { + contractId: string + }, + context + ) => { + const userId = context?.auth?.uid + if (!userId) return { status: 'error', message: 'Not authorized' } + + const { contractId } = data + if (!contractId) + return { status: 'error', message: 'Missing contract id' } + + return await firestore + .runTransaction(async (trans) => { + const lpDoc = firestore.doc(`users/${userId}`) + const lpSnap = await trans.get(lpDoc) + if (!lpSnap.exists) throw new APIError(400, 'User not found.') + const lp = lpSnap.data() as User + + const contractDoc = firestore.doc(`contracts/${contractId}`) + const contractSnap = await trans.get(contractDoc) + if (!contractSnap.exists) + throw new APIError(400, 'Contract not found.') + const contract = contractSnap.data() as CPMMContract + + const liquidityCollection = firestore.collection( + `contracts/${contractId}/liquidity` + ) + + const liquiditiesSnap = await trans.get(liquidityCollection) + + const liquidities = liquiditiesSnap.docs.map( + (doc) => doc.data() as LiquidityProvision + ) + + const userShares = getUserLiquidityShares( + userId, + contract, + liquidities + ) + + // zero all added amounts for now + // can add support for partial withdrawals in the future + liquiditiesSnap.docs + .filter( + (_, i) => + !liquidities[i].isAnte && liquidities[i].userId === userId + ) + .forEach((doc) => trans.update(doc.ref, { amount: 0 })) + + const payout = Math.min(...Object.values(userShares)) + if (payout <= 0) return {} + + const newBalance = lp.balance + payout + const newTotalDeposits = lp.totalDeposits + payout + trans.update(lpDoc, { + balance: newBalance, + totalDeposits: newTotalDeposits, + } as Partial) + + const newPool = subtractObjects(contract.pool, userShares) + + const minPoolShares = Math.min(...Object.values(newPool)) + const adjustedTotal = contract.totalLiquidity - payout + + // total liquidity is a bogus number; use minPoolShares to prevent from going negative + const newTotalLiquidity = Math.max(adjustedTotal, minPoolShares) + + trans.update(contractDoc, { + pool: newPool, + totalLiquidity: newTotalLiquidity, + }) + + const prob = getProbability(contract) + + // surplus shares become user's bets + const bets = Object.entries(userShares) + .map(([outcome, shares]) => + shares - payout < 1 // don't create bet if less than 1 share + ? undefined + : ({ + userId: userId, + contractId: contract.id, + amount: + (outcome === 'YES' ? prob : 1 - prob) * (shares - payout), + shares: shares - payout, + outcome, + probBefore: prob, + probAfter: prob, + createdTime: Date.now(), + isLiquidityProvision: true, + fees: noFees, + } as Omit) + ) + .filter((x) => x !== undefined) + + for (const bet of bets) { + const doc = firestore + .collection(`contracts/${contract.id}/bets`) + .doc() + trans.create(doc, { id: doc.id, ...bet }) + } + + return userShares + }) + .then(async (result) => { + // redeem surplus bet with pre-existing bets + await redeemShares(userId, contractId) + + console.log('userid', userId, 'withdraws', result) + return { status: 'success', userShares: result } + }) + .catch((e) => { + return { status: 'error', message: e.message } + }) + } + ) + +const firestore = admin.firestore() diff --git a/web/.eslintrc.js b/web/.eslintrc.js index b55b3277..fec650f9 100644 --- a/web/.eslintrc.js +++ b/web/.eslintrc.js @@ -19,6 +19,7 @@ module.exports = { ], '@next/next/no-img-element': 'off', '@next/next/no-typos': 'off', + 'linebreak-style': ['error', 'unix'], 'lodash/import-scope': [2, 'member'], }, env: { diff --git a/web/components/avatar.tsx b/web/components/avatar.tsx index e6506c03..53257deb 100644 --- a/web/components/avatar.tsx +++ b/web/components/avatar.tsx @@ -53,7 +53,7 @@ export function EmptyAvatar(props: { size?: number; multi?: boolean }) { return (
diff --git a/web/components/contract/contract-info-dialog.tsx b/web/components/contract/contract-info-dialog.tsx index 12fd8dd9..b5ecea15 100644 --- a/web/components/contract/contract-info-dialog.tsx +++ b/web/components/contract/contract-info-dialog.tsx @@ -7,11 +7,7 @@ import { Bet } from 'common/bet' import { Contract } from 'common/contract' import { formatMoney } from 'common/util/format' -import { - contractPath, - contractPool, - getBinaryProbPercent, -} from 'web/lib/firebase/contracts' +import { contractPath, contractPool } from 'web/lib/firebase/contracts' import { LiquidityPanel } from '../liquidity-panel' import { Col } from '../layout/col' import { Modal } from '../layout/modal' @@ -21,6 +17,7 @@ import { Title } from '../title' import { TweetButton } from '../tweet-button' import { InfoTooltip } from '../info-tooltip' import { TagsInput } from 'web/components/tags-input' +import { DuplicateContractButton } from '../copy-contract-button' export const contractDetailsButtonClassName = 'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500' @@ -68,9 +65,10 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) { +
@@ -155,23 +153,13 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) { ) } -const getTweetText = (contract: Contract, isCreator: boolean) => { - const { question, creatorName, resolution, outcomeType } = contract - const isBinary = outcomeType === 'BINARY' +const getTweetText = (contract: Contract) => { + const { question, resolution } = contract - const tweetQuestion = isCreator - ? question - : `${question}\nAsked by ${creatorName}.` - const tweetDescription = resolution - ? `Resolved ${resolution}!` - : isBinary - ? `Currently ${getBinaryProbPercent( - contract - )} chance, place your bets here:` - : `Submit your own answer:` + const tweetDescription = resolution ? `\n\nResolved ${resolution}!` : '' const timeParam = `${Date.now()}`.substring(7) const url = `https://manifold.markets${contractPath(contract)}?t=${timeParam}` - return `${tweetQuestion}\n\n${tweetDescription}\n\n${url}` + return `${question}\n\n${url}${tweetDescription}` } diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index 65e1ad36..1fc8e077 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -79,6 +79,11 @@ export const ContractOverview = (props: { {tradingAllowed(contract) && } + ) : isPseudoNumeric ? ( + + + {tradingAllowed(contract) && } + ) : ( outcomeType === 'FREE_RESPONSE' && resolution && ( diff --git a/web/components/copy-contract-button.tsx b/web/components/copy-contract-button.tsx new file mode 100644 index 00000000..fcb3a347 --- /dev/null +++ b/web/components/copy-contract-button.tsx @@ -0,0 +1,58 @@ +import { DuplicateIcon } from '@heroicons/react/outline' +import clsx from 'clsx' +import { Contract } from 'common/contract' +import { ENV_CONFIG } from 'common/envs/constants' +import { getMappedValue } from 'common/pseudo-numeric' +import { contractPath } from 'web/lib/firebase/contracts' +import { trackCallback } from 'web/lib/service/analytics' + +export function DuplicateContractButton(props: { + contract: Contract + className?: string +}) { + const { contract, className } = props + + return ( + + + ) +} + +// Pass along the Uri to create a new contract +function duplicateContractHref(contract: Contract) { + const params = { + q: contract.question, + closeTime: contract.closeTime || 0, + description: + (contract.description ? `${contract.description}\n\n` : '') + + `(Copied from https://${ENV_CONFIG.domain}${contractPath(contract)})`, + outcomeType: contract.outcomeType, + } as Record + + if (contract.outcomeType === 'PSEUDO_NUMERIC') { + params.min = contract.min + params.max = contract.max + params.isLogScale = contract.isLogScale + params.initValue = getMappedValue(contract)(contract.initialProbability) + } + + return ( + `/create?` + + Object.entries(params) + .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) + .join('&') + ) +} diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx index ed02128e..c327d8af 100644 --- a/web/components/feed/feed-comments.tsx +++ b/web/components/feed/feed-comments.tsx @@ -224,7 +224,7 @@ export function FeedComment(props: { return ( diff --git a/web/components/filter-select-users.tsx b/web/components/filter-select-users.tsx index 93badf20..7ce73cf8 100644 --- a/web/components/filter-select-users.tsx +++ b/web/components/filter-select-users.tsx @@ -1,4 +1,4 @@ -import { UserIcon } from '@heroicons/react/outline' +import { UserIcon, XIcon } from '@heroicons/react/outline' import { useUsers } from 'web/hooks/use-users' import { User } from 'common/user' import { Fragment, useMemo, useState } from 'react' @@ -6,13 +6,24 @@ import clsx from 'clsx' import { Menu, Transition } from '@headlessui/react' import { Avatar } from 'web/components/avatar' import { Row } from 'web/components/layout/row' +import { UserLink } from 'web/components/user-page' export function FilterSelectUsers(props: { setSelectedUsers: (users: User[]) => void selectedUsers: User[] ignoreUserIds: string[] + showSelectedUsersTitle?: boolean + selectedUsersClassName?: string + maxUsers?: number }) { - const { ignoreUserIds, selectedUsers, setSelectedUsers } = props + const { + ignoreUserIds, + selectedUsers, + setSelectedUsers, + showSelectedUsersTitle, + selectedUsersClassName, + maxUsers, + } = props const users = useUsers() const [query, setQuery] = useState('') const [filteredUsers, setFilteredUsers] = useState([]) @@ -24,94 +35,124 @@ export function FilterSelectUsers(props: { return ( !selectedUsers.map((user) => user.name).includes(user.name) && !ignoreUserIds.includes(user.id) && - user.name.toLowerCase().includes(query.toLowerCase()) + (user.name.toLowerCase().includes(query.toLowerCase()) || + user.username.toLowerCase().includes(query.toLowerCase())) ) }) ) }, [beginQuerying, users, selectedUsers, ignoreUserIds, query]) - + const shouldShow = maxUsers ? selectedUsers.length < maxUsers : true return (
-
-
-
- setQuery(e.target.value)} - className="input input-bordered block w-full pl-10 focus:border-gray-300 " - placeholder="Austin Chen" - /> -
- - {({}) => ( - +
+
+
+ setQuery(e.target.value)} + className="input input-bordered block w-full pl-10 focus:border-gray-300 " + placeholder="Austin Chen" + /> +
+ - -
- {filteredUsers.map((user: User) => ( - - {({ active }) => ( - ( + + +
+ {filteredUsers.map((user: User) => ( + + {({ active }) => ( + { + setQuery('') + setSelectedUsers([...selectedUsers, user]) + }} + > + + {user.name} + )} - onClick={() => { - setQuery('') - setSelectedUsers([...selectedUsers, user]) - }} - > - - {user.name} - - )} - - ))} -
-
-
- )} -
+ + ))} +
+ + + )} + + + )} {selectedUsers.length > 0 && ( <> -
Added members:
- +
+ {showSelectedUsersTitle && 'Added members:'} +
+ {selectedUsers.map((user: User) => ( -
- + + + + + + setSelectedUsers([ + ...selectedUsers.filter((u) => u.id != user.id), + ]) + } + className=" h-5 w-5 cursor-pointer text-gray-400" + aria-hidden="true" /> - {user.name}
))}
diff --git a/web/components/nav/nav-bar.tsx b/web/components/nav/nav-bar.tsx index 5a997b46..9f0f8ddd 100644 --- a/web/components/nav/nav-bar.tsx +++ b/web/components/nav/nav-bar.tsx @@ -63,6 +63,7 @@ export function BottomNavBar() { currentPage={currentPage} item={{ name: formatMoney(user.balance), + trackingEventName: 'profile', href: `/${user.username}?tab=bets`, icon: () => ( @@ -102,7 +104,7 @@ function NavBarItem(props: { item: Item; currentPage: string }) { 'block w-full py-1 px-3 text-center hover:bg-indigo-200 hover:text-indigo-700', currentPage === item.href && 'bg-gray-200 text-indigo-700' )} - onClick={trackCallback('navbar: ' + item.name)} + onClick={track} > {item.icon && } {item.name} diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 8c3ceb02..b9449ea0 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -18,7 +18,7 @@ import { ManifoldLogo } from './manifold-logo' import { MenuButton } from './menu' import { ProfileSummary } from './profile-menu' import NotificationsIcon from 'web/components/notifications-icon' -import React from 'react' +import React, { useEffect } from 'react' import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants' import { CreateQuestionButton } from 'web/components/create-question-button' import { useMemberGroups } from 'web/hooks/use-group' @@ -26,6 +26,8 @@ import { groupPath } from 'web/lib/firebase/groups' import { trackCallback, withTracking } from 'web/lib/service/analytics' import { Group } from 'common/group' import { Spacer } from '../layout/spacer' +import { usePreferredNotifications } from 'web/hooks/use-notifications' +import { setNotificationsAsSeen } from 'web/pages/notifications' function getNavigation() { return [ @@ -120,6 +122,7 @@ function getMoreMobileNav() { export type Item = { name: string + trackingEventName?: string href: string icon?: React.ComponentType<{ className?: string }> } @@ -217,7 +220,11 @@ export default function Sidebar(props: { className?: string }) { /> )} - +
{/* Desktop navigation */} @@ -236,14 +243,36 @@ export default function Sidebar(props: { className?: string }) {
)} - + ) } -function GroupsList(props: { currentPage: string; memberItems: Item[] }) { - const { currentPage, memberItems } = props +function GroupsList(props: { + currentPage: string + memberItems: Item[] + user: User | null | undefined +}) { + const { currentPage, memberItems, user } = props + const preferredNotifications = usePreferredNotifications(user?.id, { + unseenOnly: true, + customHref: '/group/', + }) + + // Set notification as seen if our current page is equal to the isSeenOnHref property + useEffect(() => { + preferredNotifications.forEach((notification) => { + if (notification.isSeenOnHref === currentPage) { + setNotificationsAsSeen([notification]) + } + }) + }, [currentPage, preferredNotifications]) + return ( <> !n.isSeen && n.isSeenOnHref === item.href + ) && 'font-bold' + )} > -   {item.name} + {item.name} ))} diff --git a/web/components/notifications-icon.tsx b/web/components/notifications-icon.tsx index e2618870..8f45a054 100644 --- a/web/components/notifications-icon.tsx +++ b/web/components/notifications-icon.tsx @@ -2,17 +2,29 @@ import { BellIcon } from '@heroicons/react/outline' import clsx from 'clsx' import { Row } from 'web/components/layout/row' import { useEffect, useState } from 'react' -import { useUser } from 'web/hooks/use-user' +import { usePrivateUser, useUser } from 'web/hooks/use-user' import { useRouter } from 'next/router' import { usePreferredGroupedNotifications } from 'web/hooks/use-notifications' +import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications' +import { requestBonuses } from 'web/lib/firebase/api-call' export default function NotificationsIcon(props: { className?: string }) { const user = useUser() - const notifications = usePreferredGroupedNotifications(user?.id, { + const privateUser = usePrivateUser(user?.id) + const notifications = usePreferredGroupedNotifications(privateUser?.id, { unseenOnly: true, }) const [seen, setSeen] = useState(false) + useEffect(() => { + if (!privateUser) return + + if (Date.now() - (privateUser.lastTimeCheckedBonuses ?? 0) > 65 * 1000) + requestBonuses({}).catch((error) => { + console.log("couldn't get bonuses:", error.message) + }) + }, [privateUser]) + const router = useRouter() useEffect(() => { if (router.pathname.endsWith('notifications')) return setSeen(true) @@ -24,7 +36,9 @@ export default function NotificationsIcon(props: { className?: string }) {
{!seen && notifications && notifications.length > 0 && (
- {notifications.length} + {notifications.length > NOTIFICATIONS_PER_PAGE + ? `${NOTIFICATIONS_PER_PAGE}+` + : notifications.length}
)} diff --git a/web/components/referrals-button.tsx b/web/components/referrals-button.tsx index c23958fc..74fc113d 100644 --- a/web/components/referrals-button.tsx +++ b/web/components/referrals-button.tsx @@ -10,9 +10,11 @@ import { Row } from 'web/components/layout/row' import { Avatar } from 'web/components/avatar' import { UserLink } from 'web/components/user-page' import { useReferrals } from 'web/hooks/use-referrals' +import { FilterSelectUsers } from 'web/components/filter-select-users' +import { getUser, updateUser } from 'web/lib/firebase/users' -export function ReferralsButton(props: { user: User }) { - const { user } = props +export function ReferralsButton(props: { user: User; currentUser?: User }) { + const { user, currentUser } = props const [isOpen, setIsOpen] = useState(false) const referralIds = useReferrals(user.id) @@ -28,6 +30,7 @@ export function ReferralsButton(props: { user: User }) { referralIds={referralIds ?? []} isOpen={isOpen} setIsOpen={setIsOpen} + currentUser={currentUser} /> ) @@ -38,8 +41,21 @@ function ReferralsDialog(props: { referralIds: string[] isOpen: boolean setIsOpen: (isOpen: boolean) => void + currentUser?: User }) { - const { user, referralIds, isOpen, setIsOpen } = props + const { user, referralIds, isOpen, setIsOpen, currentUser } = props + const [referredBy, setReferredBy] = useState([]) + const [isSubmitting, setIsSubmitting] = useState(false) + const [errorText, setErrorText] = useState('') + + const [referredByUser, setReferredByUser] = useState() + useEffect(() => { + if (isOpen && !referredByUser && user?.referredByUserId) { + getUser(user.referredByUserId).then((user) => { + setReferredByUser(user) + }) + } + }, [isOpen, referredByUser, user.referredByUserId]) useEffect(() => { prefetchUsers(referralIds) @@ -56,6 +72,75 @@ function ReferralsDialog(props: { title: 'Referrals', content: , }, + { + title: 'Referred by', + content: ( + <> + {user.id === currentUser?.id && !referredByUser ? ( + <> + + + + + + {referredBy.length > 0 && + 'Careful: you can only set who referred you once!'} + + {errorText} + + ) : ( +
+ {referredByUser ? ( + + + + + ) : ( + No one... + )} +
+ )} + + ), + }, ]} /> diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index d72a2a16..c33476aa 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -45,15 +45,16 @@ export function UserLink(props: { username: string showUsername?: boolean className?: string + justFirstName?: boolean }) { - const { name, username, showUsername, className } = props + const { name, username, showUsername, className, justFirstName } = props return ( - {name} + {justFirstName ? name.split(' ')[0] : name} {showUsername && ` (@${username})`} ) @@ -159,7 +160,7 @@ export function UserPage(props: {
@@ -202,7 +203,7 @@ export function UserPage(props: { - + diff --git a/web/hooks/use-notifications.ts b/web/hooks/use-notifications.ts index c947e8d0..98b0f2fd 100644 --- a/web/hooks/use-notifications.ts +++ b/web/hooks/use-notifications.ts @@ -7,9 +7,10 @@ import { groupBy, map } from 'lodash' export type NotificationGroup = { notifications: Notification[] - sourceContractId: string + groupedById: string isSeen: boolean timePeriod: string + type: 'income' | 'normal' } export function usePreferredGroupedNotifications( @@ -37,25 +38,45 @@ export function groupNotifications(notifications: Notification[]) { new Date(notification.createdTime).toDateString() ) Object.keys(notificationGroupsByDay).forEach((day) => { - // Group notifications by contract: + const notificationsGroupedByDay = notificationGroupsByDay[day] + const incomeNotifications = notificationsGroupedByDay.filter( + (notification) => + notification.sourceType === 'bonus' || notification.sourceType === 'tip' + ) + const normalNotificationsGroupedByDay = notificationsGroupedByDay.filter( + (notification) => + notification.sourceType !== 'bonus' && notification.sourceType !== 'tip' + ) + if (incomeNotifications.length > 0) { + notificationGroups = notificationGroups.concat({ + notifications: incomeNotifications, + groupedById: 'income' + day, + isSeen: incomeNotifications[0].isSeen, + timePeriod: day, + type: 'income', + }) + } + // Group notifications by contract, filtering out bonuses: const groupedNotificationsByContractId = groupBy( - notificationGroupsByDay[day], + normalNotificationsGroupedByDay, (notification) => { return notification.sourceContractId } ) notificationGroups = notificationGroups.concat( map(groupedNotificationsByContractId, (notifications, contractId) => { + const notificationsForContractId = groupedNotificationsByContractId[ + contractId + ].sort((a, b) => { + return b.createdTime - a.createdTime + }) // Create a notification group for each contract within each day const notificationGroup: NotificationGroup = { - notifications: groupedNotificationsByContractId[contractId].sort( - (a, b) => { - return b.createdTime - a.createdTime - } - ), - sourceContractId: contractId, - isSeen: groupedNotificationsByContractId[contractId][0].isSeen, + notifications: notificationsForContractId, + groupedById: contractId, + isSeen: notificationsForContractId[0].isSeen, timePeriod: day, + type: 'normal', } return notificationGroup }) @@ -64,11 +85,11 @@ export function groupNotifications(notifications: Notification[]) { return notificationGroups } -function usePreferredNotifications( +export function usePreferredNotifications( userId: string | undefined, - options: { unseenOnly: boolean } + options: { unseenOnly: boolean; customHref?: string } ) { - const { unseenOnly } = options + const { unseenOnly, customHref } = options const [privateUser, setPrivateUser] = useState(null) const [notifications, setNotifications] = useState([]) const [userAppropriateNotifications, setUserAppropriateNotifications] = @@ -93,9 +114,11 @@ function usePreferredNotifications( const notificationsToShow = getAppropriateNotifications( notifications, privateUser.notificationPreferences + ).filter((n) => + customHref ? n.isSeenOnHref?.includes(customHref) : !n.isSeenOnHref ) setUserAppropriateNotifications(notificationsToShow) - }, [privateUser, notifications]) + }, [privateUser, notifications, customHref]) return userAppropriateNotifications } diff --git a/web/lib/firebase/api-call.ts b/web/lib/firebase/api-call.ts index a5fb35f5..d93068dd 100644 --- a/web/lib/firebase/api-call.ts +++ b/web/lib/firebase/api-call.ts @@ -77,3 +77,7 @@ export function sellBet(params: any) { export function createGroup(params: any) { return call(getFunctionUrl('creategroup'), 'POST', params) } + +export function requestBonuses(params: any) { + return call(getFunctionUrl('getdailybonuses'), 'POST', params) +} diff --git a/web/pages/account.tsx b/web/pages/account.tsx deleted file mode 100644 index 59d938c3..00000000 --- a/web/pages/account.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React from 'react' -import { Page } from 'web/components/page' -import { UserPage } from 'web/components/user-page' -import { useUser } from 'web/hooks/use-user' -import { firebaseLogin } from 'web/lib/firebase/users' - -function SignInCard() { - return ( -
-
- -
-
-

Welcome!

-

Sign in to get started

-
- -
-
-
- ) -} - -export default function Account() { - const user = useUser() - return user ? ( - - ) : ( - - - - ) -} diff --git a/web/pages/admin.tsx b/web/pages/admin.tsx index db24996d..e709e875 100644 --- a/web/pages/admin.tsx +++ b/web/pages/admin.tsx @@ -62,13 +62,19 @@ function UsersTable() { class="hover:underline hover:decoration-indigo-400 hover:decoration-2" href="/${cell}">@${cell}`), }, + { + id: 'name', + name: 'Name', + formatter: (cell) => + html(`${cell}`), + }, { id: 'email', name: 'Email', }, { id: 'createdTime', - name: 'Created Time', + name: 'Created', formatter: (cell) => html( `${dayjs(cell as number).format( diff --git a/web/pages/create.tsx b/web/pages/create.tsx index c7b8f02e..df83fb9f 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -7,7 +7,7 @@ import { Spacer } from 'web/components/layout/spacer' import { useUser } from 'web/hooks/use-user' import { Contract, contractPath } from 'web/lib/firebase/contracts' import { createMarket } from 'web/lib/firebase/api-call' -import { FIXED_ANTE, MINIMUM_ANTE } from 'common/antes' +import { FIXED_ANTE } from 'common/antes' import { InfoTooltip } from 'web/components/info-tooltip' import { Page } from 'web/components/page' import { Row } from 'web/components/layout/row' @@ -25,17 +25,34 @@ import { useTracking } from 'web/hooks/use-tracking' import { useWarnUnsavedChanges } from 'web/hooks/use-warn-unsaved-changes' import { track } from 'web/lib/service/analytics' import { GroupSelector } from 'web/components/groups/group-selector' -import { CATEGORIES } from 'common/categories' import { User } from 'common/user' -export default function Create() { - const [question, setQuestion] = useState('') - // get query params: - const router = useRouter() - const { groupId } = router.query as { groupId: string } - useTracking('view create page') - const creator = useUser() +type NewQuestionParams = { + groupId?: string + q: string + type: string + description: string + closeTime: string + outcomeType: string + // Params for PSEUDO_NUMERIC outcomeType + min?: string + max?: string + isLogScale?: string + initValue?: string +} +export default function Create() { + useTracking('view create page') + const router = useRouter() + const params = router.query as NewQuestionParams + // TODO: Not sure why Question is pulled out as its own component; + // Maybe merge into newContract and then we don't need useEffect here. + const [question, setQuestion] = useState('') + useEffect(() => { + setQuestion(params.q ?? '') + }, [params.q]) + + const creator = useUser() useEffect(() => { if (creator === null) router.push('/') }, [creator, router]) @@ -65,11 +82,7 @@ export default function Create() { - + @@ -80,20 +93,21 @@ export default function Create() { export function NewContract(props: { creator: User question: string - groupId?: string + params?: NewQuestionParams }) { - const { creator, question, groupId } = props - const [outcomeType, setOutcomeType] = useState('BINARY') + const { creator, question, params } = props + const { groupId, initValue } = params ?? {} + const [outcomeType, setOutcomeType] = useState( + (params?.outcomeType as outcomeType) ?? 'BINARY' + ) const [initialProb] = useState(50) - const [minString, setMinString] = useState('') - const [maxString, setMaxString] = useState('') - const [isLogScale, setIsLogScale] = useState(false) - const [initialValueString, setInitialValueString] = useState('') + const [minString, setMinString] = useState(params?.min ?? '') + const [maxString, setMaxString] = useState(params?.max ?? '') + const [isLogScale, setIsLogScale] = useState(!!params?.isLogScale) + const [initialValueString, setInitialValueString] = useState(initValue) - const [description, setDescription] = useState('') - // const [tagText, setTagText] = useState(tag ?? '') - // const tags = parseWordsAsTags(tagText) + const [description, setDescription] = useState(params?.description ?? '') useEffect(() => { if (groupId && creator) getGroup(groupId).then((group) => { @@ -105,25 +119,23 @@ export function NewContract(props: { }, [creator, groupId]) const [ante, _setAnte] = useState(FIXED_ANTE) - // useEffect(() => { - // if (ante === null && creator) { - // const initialAnte = creator.balance < 100 ? MINIMUM_ANTE : 100 - // setAnte(initialAnte) - // } - // }, [ante, creator]) - - // const [anteError, setAnteError] = useState() + // If params.closeTime is set, extract out the specified date and time // By default, close the market a week from today const weekFromToday = dayjs().add(7, 'day').format('YYYY-MM-DD') - const [closeDate, setCloseDate] = useState(weekFromToday) - const [closeHoursMinutes, setCloseHoursMinutes] = useState('23:59') + const timeInMs = Number(params?.closeTime ?? 0) + const initDate = timeInMs + ? dayjs(timeInMs).format('YYYY-MM-DD') + : weekFromToday + const initTime = timeInMs ? dayjs(timeInMs).format('HH:mm') : '23:59' + const [closeDate, setCloseDate] = useState(initDate) + const [closeHoursMinutes, setCloseHoursMinutes] = useState(initTime) + const [marketInfoText, setMarketInfoText] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) const [selectedGroup, setSelectedGroup] = useState( undefined ) const [showGroupSelector, setShowGroupSelector] = useState(true) - const [category, setCategory] = useState('') const closeTime = closeDate ? dayjs(`${closeDate}T${closeHoursMinutes}`).valueOf() @@ -156,7 +168,6 @@ export function NewContract(props: { question.length > 0 && ante !== undefined && ante !== null && - ante >= MINIMUM_ANTE && ante <= balance && // closeTime must be in the future closeTime && @@ -197,7 +208,6 @@ export function NewContract(props: { initialValue, isLogScale: (min ?? 0) < 0 ? false : isLogScale, groupId: selectedGroup?.id, - tags: category ? [category] : undefined, }) ) track('create market', { @@ -339,28 +349,6 @@ export function NewContract(props: { )} -
- - - -
-
(undefined) - const allNotificationGroups = usePreferredGroupedNotifications(user?.id, { + const [page, setPage] = useState(1) + + const groupedNotifications = usePreferredGroupedNotifications(user?.id, { unseenOnly: false, }) - + const [paginatedNotificationGroups, setPaginatedNotificationGroups] = + useState([]) useEffect(() => { - if (!allNotificationGroups) return - // Don't re-add notifications that are visible right now or have been seen already. - const currentlyVisibleUnseenNotificationIds = Object.values( - unseenNotificationGroups ?? [] - ) - .map((n) => n.notifications.map((n) => n.id)) - .flat() - const unseenGroupedNotifications = groupNotifications( - allNotificationGroups - .map((notification: NotificationGroup) => notification.notifications) - .flat() - .filter( - (notification: Notification) => - !notification.isSeen || - currentlyVisibleUnseenNotificationIds.includes(notification.id) - ) - ) - setUnseenNotificationGroups(unseenGroupedNotifications) - - // We don't want unseenNotificationsGroup to be in the dependencies as we update it here. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [allNotificationGroups]) + if (!groupedNotifications) return + const start = (page - 1) * NOTIFICATIONS_PER_PAGE + const end = start + NOTIFICATIONS_PER_PAGE + const maxNotificationsToShow = groupedNotifications.slice(start, end) + const remainingNotification = groupedNotifications.slice(end) + for (const notification of remainingNotification) { + if (notification.isSeen) break + else setNotificationsAsSeen(notification.notifications) + } + setPaginatedNotificationGroups(maxNotificationsToShow) + }, [groupedNotifications, page]) if (user === undefined) { return @@ -80,7 +68,6 @@ export default function Notifications() { return } - // TODO: use infinite scroll return (
@@ -90,13 +77,18 @@ export default function Notifications() { defaultIndex={0} tabs={[ { - title: 'New Notifications', - content: unseenNotificationGroups ? ( + title: 'Notifications', + content: groupedNotifications ? (
- {unseenNotificationGroups.length === 0 && - "You don't have any new notifications."} - {unseenNotificationGroups.map((notification) => - notification.notifications.length === 1 ? ( + {paginatedNotificationGroups.length === 0 && + "You don't have any notifications. Try changing your settings to see more."} + {paginatedNotificationGroups.map((notification) => + notification.type === 'income' ? ( + + ) : notification.notifications.length === 1 ? ( ) )} -
- ) : ( - - ), - }, - { - title: 'All Notifications', - content: allNotificationGroups ? ( -
- {allNotificationGroups.length === 0 && - "You don't have any notifications. Try changing your settings to see more."} - {allNotificationGroups.map((notification) => - notification.notifications.length === 1 ? ( - - ) : ( - - ) + {groupedNotifications.length > NOTIFICATIONS_PER_PAGE && ( + )}
) : ( @@ -158,13 +166,12 @@ export default function Notifications() { ) } -const setNotificationsAsSeen = (notifications: Notification[]) => { +export const setNotificationsAsSeen = (notifications: Notification[]) => { notifications.forEach((notification) => { if (!notification.isSeen) updateDoc( doc(db, `users/${notification.userId}/notifications/`, notification.id), { - ...notification, isSeen: true, viewTime: new Date(), } @@ -173,31 +180,302 @@ const setNotificationsAsSeen = (notifications: Notification[]) => { return notifications } -function NotificationGroupItem(props: { +function IncomeNotificationGroupItem(props: { notificationGroup: NotificationGroup className?: string }) { const { notificationGroup, className } = props const { notifications } = notificationGroup - const { - sourceContractTitle, - sourceContractSlug, - sourceContractCreatorUsername, - } = notifications[0] const numSummaryLines = 3 - const [expanded, setExpanded] = useState(false) + const [highlighted, setHighlighted] = useState( + notifications.some((n) => !n.isSeen) + ) useEffect(() => { setNotificationsAsSeen(notifications) }, [notifications]) + useEffect(() => { + if (expanded) setHighlighted(false) + }, [expanded]) + + const totalIncome = sum( + notifications.map((notification) => + notification.sourceText ? parseInt(notification.sourceText) : 0 + ) + ) + // Loop through the contracts and combine the notification items into one + function combineNotificationsByAddingNumericSourceTexts( + notifications: Notification[] + ) { + const newNotifications = [] + const groupedNotificationsBySourceType = groupBy( + notifications, + (n) => n.sourceType + ) + for (const sourceType in groupedNotificationsBySourceType) { + const groupedNotificationsByContractId = groupBy( + groupedNotificationsBySourceType[sourceType], + (notification) => { + return notification.sourceContractId + } + ) + for (const contractId in groupedNotificationsByContractId) { + const notificationsForContractId = + groupedNotificationsByContractId[contractId] + if (notificationsForContractId.length === 1) { + newNotifications.push(notificationsForContractId[0]) + continue + } + let sum = 0 + notificationsForContractId.forEach( + (notification) => + notification.sourceText && + (sum = parseInt(notification.sourceText) + sum) + ) + const uniqueUsers = uniq( + notificationsForContractId.map((notification) => { + return notification.sourceUserUsername + }) + ) + + const newNotification = { + ...notificationsForContractId[0], + sourceText: sum.toString(), + sourceUserUsername: + uniqueUsers.length > 1 + ? MULTIPLE_USERS_KEY + : notificationsForContractId[0].sourceType, + } + newNotifications.push(newNotification) + } + } + return newNotifications + } + + const combinedNotifs = + combineNotificationsByAddingNumericSourceTexts(notifications) + return (
setExpanded(!expanded)} + > + {expanded && ( +