diff --git a/common/.eslintrc.js b/common/.eslintrc.js index 7904e7ad..3d6cfa82 100644 --- a/common/.eslintrc.js +++ b/common/.eslintrc.js @@ -17,6 +17,14 @@ module.exports = { }, rules: { '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': [ + 'warn', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], }, }, ], diff --git a/common/envs/dev.ts b/common/envs/dev.ts index 151de075..e643800b 100644 --- a/common/envs/dev.ts +++ b/common/envs/dev.ts @@ -18,4 +18,5 @@ export const DEV_CONFIG: EnvConfig = { sellbet: 'https://sellbet-w3txbmd3ba-uc.a.run.app', createmarket: 'https://createmarket-w3txbmd3ba-uc.a.run.app', }, + amplitudeApiKey: 'fd8cbfd964b9a205b8678a39faae71b3', } diff --git a/common/envs/prod.ts b/common/envs/prod.ts index 4352af64..bce8cac4 100644 --- a/common/envs/prod.ts +++ b/common/envs/prod.ts @@ -8,6 +8,7 @@ export type EnvConfig = { domain: string firebaseConfig: FirebaseConfig functionEndpoints: Record + amplitudeApiKey?: string // Access controls adminEmails: string[] @@ -34,6 +35,8 @@ type FirebaseConfig = { export const PROD_CONFIG: EnvConfig = { domain: 'manifold.markets', + amplitudeApiKey: '2d6509fd4185ebb8be29709842752a15', + firebaseConfig: { apiKey: 'AIzaSyDp3J57vLeAZCzxLD-vcPaGIkAmBoGOSYw', authDomain: 'mantic-markets.firebaseapp.com', diff --git a/common/quadratic-funding.ts b/common/quadratic-funding.ts new file mode 100644 index 00000000..844e81a5 --- /dev/null +++ b/common/quadratic-funding.ts @@ -0,0 +1,27 @@ +import { groupBy, mapValues, sum, sumBy } from 'lodash' +import { Txn } from './txn' + +// Returns a map of charity ids to the amount of M$ matched +export function quadraticMatches( + allCharityTxns: Txn[], + matchingPool: number +): Record { + // For each charity, group the donations by each individual donor + const donationsByCharity = groupBy(allCharityTxns, 'toId') + const donationsByDonors = mapValues(donationsByCharity, (txns) => + groupBy(txns, 'fromId') + ) + + // Weight for each charity = [sum of sqrt(individual donor)] ^ 2 + const weights = mapValues(donationsByDonors, (byDonor) => { + const sumByDonor = Object.values(byDonor).map((txns) => + sumBy(txns, 'amount') + ) + const sumOfRoots = sumBy(sumByDonor, Math.sqrt) + return sumOfRoots ** 2 + }) + + // Then distribute the matching pool based on the individual weights + const totalWeight = sum(Object.values(weights)) + return mapValues(weights, (weight) => matchingPool * (weight / totalWeight)) +} diff --git a/common/util/math.ts b/common/util/math.ts index a89b31e1..66bcff1b 100644 --- a/common/util/math.ts +++ b/common/util/math.ts @@ -1,4 +1,4 @@ -import { sortBy } from 'lodash' +import { sortBy, sum } from 'lodash' export const logInterpolation = (min: number, max: number, value: number) => { if (value <= min) return 0 @@ -20,13 +20,17 @@ export function normpdf(x: number, mean = 0, variance = 1) { export const TAU = Math.PI * 2 -export function median(values: number[]) { - if (values.length === 0) return NaN +export function median(xs: number[]) { + if (xs.length === 0) return NaN - const sorted = sortBy(values, (x) => x) + const sorted = sortBy(xs, (x) => x) const mid = Math.floor(sorted.length / 2) if (sorted.length % 2 === 0) { return (sorted[mid - 1] + sorted[mid]) / 2 } return sorted[mid] } + +export function average(xs: number[]) { + return sum(xs) / xs.length +} diff --git a/docs/docs/$how-to.md b/docs/docs/$how-to.md new file mode 100644 index 00000000..785bffa5 --- /dev/null +++ b/docs/docs/$how-to.md @@ -0,0 +1,58 @@ +# How to Manifold + +Manifold Markets is a novel site where users can bet against each other to predict the outcomes of all types of questions. Engage in intense discussion, or joke with friends, whilst putting play-money where your mouth is. + +## Mana + +Mana (M$) is our virtual play currency that cannot be converted to real money. + +- **Its Value** + + You can redeem your Mana and we will [donate to a charity](http://manifold.markets/charity) on your behalf. Redeeming and purchasing Mana occurs at a rate of M$100 to $1. You will be able to redeem it for merch and other cool items soon too! + +- **It sets us apart** + Using play-money sets us apart from other similar sites as we don’t want our users to solely focus on monetary gains. Instead we prioritize providing value in the form of an enjoyable experience and facilitating a more informed world through the power of prediction markets. + +## How probabilities work + +The probability of a market represents what the collective bets of users predict the chances of an outcome occurring is. How this is calculated depends on the type of market - see below! + +## Types of markets + +There are currently 3 types of markets: Yes/No (binary), Free response, and Numerical. + +- **Yes/No (Binary)** + + The creator asks a question where traders can bet yes or no. + + Check out [Maniswap](https://www.notion.so/Maniswap-ce406e1e897d417cbd491071ea8a0c39) for more info on its automated market maker. + +- **Free Response** + + The creator asks an open ended question. Both the creator and users can propose answers which can be bet on. Don’t be intimidated to add new answers! The payout system and initial liquidity rewards users who bet on new answers early. The algorithm used to determine the probability and payout is complicated but if you want to learn more check out [DPM](https://www.notion.so/DPM-b9b48a09ea1f45b88d991231171730c5). + +- **Numerical** + Retracted whilst we make improvements. You still may see some old ones floating around though. Questions which can be answered by a number within a given range. Betting on a value will cause you to buy shares from ‘buckets’ surrounding the number you choose. + +## Compete and build your portfolio + +Generate profits to prove your expertise and shine above your friends. + +To the moon 🚀 + +- **Find inaccurate probabilities** + + Use your superior knowledge on topics to identify markets which have inaccurate probabilities. This gives you favorable odds, so bet accordingly to shift the probability to what you think it should be. + +- **React to news** + + Markets are dynamic and ongoing events can drastically affect what the probability should look like. Be the keenest to react and there is a lot of Mana to be made. + +- **Buy low, sell high** + + Similar to a stock market, probabilities can be overvalued and undervalued. If you bet (buy shares) at one end of the spectrum and subsequently other users buy even more shares of that same type, the value of your own shares will increase. Sometimes it will be most profitable to wait for the market to resolve but often it can be wise to sell your shares and take the immediate profits. This can also be a great way to free up Mana if you are lacking funds. + +- **Create innovative answers** + Certain free response markets provide room for creativity! The answers themselves can often affect the outcome based on how compelling they are. + +More questions? Check out **[this community-driven FAQ](https://outsidetheasylum.blog/manifold-markets-faq/)**! diff --git a/docs/docs/about.md b/docs/docs/about.md index 7d8220ee..bb1c1cf6 100644 --- a/docs/docs/about.md +++ b/docs/docs/about.md @@ -5,7 +5,7 @@ slug: / # About Manifold Markets -Manifold Markets lets anyone create a prediction market on any topic. Win virtual money betting on what you know, from **[chess tournaments](https://manifold.markets/SG/will-magnus-carlsen-lose-any-regula)** to **[lunar collisions](https://manifold.markets/Duncan/will-the-wayward-falcon-9-booster-h)** to **[newsletter subscriber rates](https://manifold.markets/Nu%C3%B1oSempere/how-many-additional-subscribers-wil)** - or learn about the future by creating your own market! +Manifold Markets lets anyone create a prediction market on any topic. Win virtual play money betting on what you know, from **[chess tournaments](https://manifold.markets/SG/will-magnus-carlsen-lose-any-regula)** to **[lunar collisions](https://manifold.markets/Duncan/will-the-wayward-falcon-9-booster-h)** to **[newsletter subscriber rates](https://manifold.markets/Nu%C3%B1oSempere/how-many-additional-subscribers-wil)** - or learn about the future by creating your own market! ### **What are prediction markets?** @@ -17,20 +17,6 @@ If I think the Democrats are very likely to win, and you disagree, I might offer Now, you or I could be mistaken and overshooting the true probability one way or another. If so, there's an incentive for someone else to bet and correct it! Over time, the implied probability will converge to the **[market's best estimate](https://en.wikipedia.org/wiki/Efficient-market_hypothesis)**. Since these probabilities are public, anyone can use them to make better decisions! -### **How does Manifold Markets work?** - -1. **Anyone can create a market for any yes-or-no question.** - - You can ask questions about the future like "Will Taiwan remove its 14-day COVID quarantine by Jun 01, 2022?" If the market thinks this is very likely, you can plan more activities for your trip. - - You can also ask subjective, personal questions like "Will I enjoy my 2022 Taiwan trip?". Then share the market with your family and friends and get their takes! - -2. **Anyone can bet on a market using Manifold Dollars (M$), our platform currency.** - - You get M$ 1,000 just for signing up, so you can start betting immediately! When a market creator decides an outcome in your favor, you'll win Manifold Dollars from people who bet against you. - -More questions? Check out **[this community-driven FAQ](https://outsidetheasylum.blog/manifold-markets-faq/)**! - ### **Can prediction markets work without real money?** Yes! There is substantial evidence that play-money prediction markets provide real predictive power. Examples include **[sports betting](http://www.electronicmarkets.org/fileadmin/user_upload/doc/Issues/Volume_16/Issue_01/V16I1_Statistical_Tests_of_Real-Money_versus_Play-Money_Prediction_Markets.pdf)** and internal prediction markets at firms like **[Google](https://www.networkworld.com/article/2284098/google-bets-on-value-of-prediction-markets.html)**. diff --git a/docs/docs/api.md b/docs/docs/api.md index f7217045..487c43f1 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -449,7 +449,7 @@ $ curl https://manifold.markets/api/v0/market -X POST -H 'Content-Type: applicat "question":"Is there life on Mars?", \ "description":"I'm not going to type some long ass example description.", \ "closeTime":1700000000000, \ - initialProb:25}' + "initialProb":25}' ``` ## Changelog diff --git a/docs/docs/bounties.md b/docs/docs/bounties.md index 62aeb072..51ca6802 100644 --- a/docs/docs/bounties.md +++ b/docs/docs/bounties.md @@ -15,35 +15,97 @@ Our community is the beating heart of Manifold; your individual contributions ar ## Awarded bounties -🥧 *Awarded 2022-03-14* +🎈 *Awarded on 2022-06-14* -**[Kevin Zielnicki](https://manifold.markets/kjz): M$ 10,000** +**[Wasabipesto](https://manifold.markets/wasabipesto): M$20,000** + +- For creating an awesome stats page which features and analyses various data sets! This can be found on the second tab of our [analytics page](https://manifold.markets/stats). + +**[Jack](https://manifold.markets/jack): M$10,000** + +- For adding a bunch of charities to [Manifold for Good](https://manifold.markets/charity), working out market math with Austin, and excellent comment activity. + +**[Forrest](https://manifold.markets/Forrest): M$10,000** + +- For a variety of [open source code contributions](https://github.com/manifoldmarkets/manifold/commits?author=ForrestWeiswolf), making our code base easier to use and maintain. + +**[IsaacKing](https://manifold.markets/IsaacKing): M$10,000** + +- For responsible disclosure of an exploit involving liquidity withdrawal, which has [now been fixed](https://github.com/manifoldmarkets/manifold/pull/472)! Removing one infinite money glitch at a time. + +**[Sjlver](https://manifold.markets/Sjlver): M$5,000** + +- For responsible disclosure of a potential exploit. We would say what it is, but it isn’t quite fixed yet! 🤫 + +_🌿 Announced on 2022-05-02_ + +**[Marshall Polaris](https://manifold.markets/mqp): M$200K** + +- For spearheading the effort to [open-source Manifold](https://github.com/manifoldmarkets/manifold), by documenting our processes, triaging bugs, and improving the new contributor experience. +- Marshall contributed over 2 weeks of part-time volunteer work; as such, we are awarding an amount that reflects the extraordinary amount of effort he’s put in. + +**[Vincent Luczkow](https://manifold.markets/VincentLuczkow): M$10,000** + +- For building and releasing https://github.com/vluzko/manifold-markets-python, a super cool Python visualization of the calibration accuracy of all Manifold markets. Turns out we’re doing okay! + +**[Akhil Wable](https://manifold.markets/AkhilWable): M$10,000** + +- For writing up [Akhil’s Product Suggestions](https://www.notion.so/Akhil-s-Product-Suggestions-672e1cba393d4242852ff95ae79528df), an extensive, thoughtful list of improvements we could make to our platform. + +**[Alex K. Chen](https://manifold.markets/AlexKChen): M$6,000** + +- For the creation of a metric ton of innovative, long term questions. At the time of award, Alex was singlehandedly responsible for 20% of all markets posted in April. + +**[ZorbaTHut](https://manifold.markets/ZorbaTHut): M$5,000** + +- For [testing out futarchy](https://manifold.markets/tag/themotte_leaving) on an important problem for the community of The Motte. + +**[Tetraspace](https://manifold.markets/Tetraspace): M$3,500** + +- For the creation of [a focused set of questions on UK politics](https://twitter.com/TetraspaceWest/status/1516824123149848579), with relevant real-world predictions. +- For the idea and execution of using FR bounded buckets for mapping out a scalar range ([example market](https://manifold.markets/Tetraspace/if-ron-desantis-is-elected-presiden), [discussion here](https://manifold.markets/StephenMalina/how-many-daily-active-users-will-ma)). + +**[tcheasdfjkl](https://manifold.markets/tcheasdfjkl): M$2,500** + +- For calling out numerous areas of improvement, e.g. around our profit numbers being wonky, and problems with the DPM ⇒ CFMM market conversions. + +**[Jack](https://manifold.markets/JackC): M$500** + +- For recommending we list the Long-Term Future Fund as a supported charity. + +**[N.C. Young](https://manifold.markets/NcyRocks): M$500** + +- For recommending we list the Givewell Maximum Impact Fund as a supported charity. + + \**🥧 *Awarded 2022-03-14\* + +**[Kevin Zielnicki](https://manifold.markets/kjz): M$10,000** - For identifying issues with our Dynamic Parimutuel Market Maker in an [excellent blog post](https://kevin.zielnicki.com/2022/02/17/manifold/) (and [associated market](https://manifold.markets/kjz/will-manifolds-developers-agree-wit)), leading us to change to a different mechanism. -**[Pepe](https://manifold.markets/Pepe): M$ 10,000** +**[Pepe](https://manifold.markets/Pepe): M$10,000** -- For developing the function used in our Constant Function Market Maker and working with us to polish it on Discord, making it easier for us to provision liquidity compared to a CPMM. +- For developing the function used in our Constant Function Market Maker, making it easier for us to provision liquidity compared to a CPMM. -**[Gurkenglas](https://manifold.markets/Gurkenglas): M$ 5,000** +**[Gurkenglas](https://manifold.markets/Gurkenglas): M$5,000** -- For concrete suggestions on Discord around improving our market maker algorithms, and creating useful graphs to make our different market makers more legible. +- For concrete suggestions around improving our market maker algorithms, and creating useful graphs to make our different market makers more legible. -**[Scott Alexander](https://manifold.markets/ScottAlexander): M$ 5,000** +**[Scott Alexander](https://manifold.markets/ScottAlexander): M$5,000** - For [developing and publicizing the idea of providing interest-free loans on each market](https://astralcodexten.substack.com/p/play-money-and-reputation-systems), helping make long-term markets more accurate. -**[David Glidden](https://manifold.markets/dglid): M$ 5,000** +**[David Glidden](https://manifold.markets/dglid): M$5,000** - For taking on the mantle of [@MetaculusBot](https://manifold.markets/MetaculusBot), which allows traders access to a wider spread of topics, and permits head-to-head comparisons between our prediction markets and other forecasting platforms. -**[Isaac King](https://manifold.markets/IsaacKing): M$ 5,000** +**[Isaac King](https://manifold.markets/IsaacKing): M$5,000** -- For [compiling a comprehensive FAQ](https://outsidetheasylum.blog/manifold-markets-faq/) that answers a variety of questions that new users commonly face, and also inspiring us to move to [this open-source docs platform](http://docs.manifold.markets/). +- For [compiling an FAQ](https://outsidetheasylum.blog/manifold-markets-faq/) that answers a variety of questions that new users commonly face, and also inspiring us to move to [this open-source docs platform](http://docs.manifold.markets/). -**[Blazer](https://manifold.markets/BlazingDarkness): M$ 2,500** +**[Blazer](https://manifold.markets/BlazingDarkness/was-it-an-unpleasant-surprise-when): M$2,500** -- For [calling out our mistake](https://manifold.markets/BlazingDarkness/was-it-an-unpleasant-surprise-when) in retroactively publicizing all market creator’s trades, leading us to revert this feature entirely. +- For [calling out our mistake](https://manifold.markets/BlazingDarkness/was-it-an-unpleasant-surprise-when) in retroactively publicizing the market creator’s trades, leading us to revert this feature entirely. ⛑️ _Awarded 2022-01-09_ diff --git a/functions/.eslintrc.js b/functions/.eslintrc.js index 91ca1cea..7f571610 100644 --- a/functions/.eslintrc.js +++ b/functions/.eslintrc.js @@ -19,7 +19,7 @@ module.exports = { '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-extra-semi': 'off', '@typescript-eslint/no-unused-vars': [ - 'error', + 'warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', diff --git a/functions/package.json b/functions/package.json index c51afd82..34115a61 100644 --- a/functions/package.json +++ b/functions/package.json @@ -20,6 +20,7 @@ }, "main": "lib/functions/src/index.js", "dependencies": { + "@amplitude/node": "1.10.0", "cors": "2.8.5", "fetch": "1.1.0", "firebase-admin": "10.0.0", diff --git a/functions/src/analytics.ts b/functions/src/analytics.ts new file mode 100644 index 00000000..9c01fe36 --- /dev/null +++ b/functions/src/analytics.ts @@ -0,0 +1,24 @@ +import * as Amplitude from '@amplitude/node' + +import { DEV_CONFIG } from '../../common/envs/dev' +import { PROD_CONFIG } from '../../common/envs/prod' + +import { isProd } from './utils' + +const key = isProd ? PROD_CONFIG.amplitudeApiKey : DEV_CONFIG.amplitudeApiKey + +const amp = Amplitude.init(key ?? '') + +export const track = async ( + userId: string, + eventName: string, + eventProperties?: any, + amplitudeProperties?: Partial +) => { + await amp.logEvent({ + event_type: eventName, + user_id: userId, + event_properties: eventProperties, + ...amplitudeProperties, + }) +} diff --git a/functions/src/index.ts b/functions/src/index.ts index 2e2ee54c..b21ae6fd 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -16,8 +16,7 @@ export * from './on-fold-follow' export * from './on-fold-delete' export * from './on-view' export * from './unsubscribe' -export * from './update-contract-metrics' -export * from './update-user-metrics' +export * from './update-metrics' export * from './update-recommendations' export * from './backup-db' export * from './change-user-info' diff --git a/functions/src/scripts/update-metrics.ts b/functions/src/scripts/update-metrics.ts index f3095442..e34f83d8 100644 --- a/functions/src/scripts/update-metrics.ts +++ b/functions/src/scripts/update-metrics.ts @@ -2,15 +2,12 @@ import { initAdmin } from './script-init' initAdmin() import { log, logMemory } from '../utils' -import { updateContractMetricsCore } from '../update-contract-metrics' -import { updateUserMetricsCore } from '../update-user-metrics' +import { updateMetricsCore } from '../update-metrics' async function updateMetrics() { logMemory() - log('Updating contract metrics...') - await updateContractMetricsCore() - log('Updating user metrics...') - await updateUserMetricsCore() + log('Updating metrics...') + await updateMetricsCore() } if (require.main === module) { diff --git a/functions/src/sell-shares.ts b/functions/src/sell-shares.ts index 1d0a4c23..dd4e2ec5 100644 --- a/functions/src/sell-shares.ts +++ b/functions/src/sell-shares.ts @@ -3,7 +3,7 @@ import * as admin from 'firebase-admin' import { z } from 'zod' import { APIError, newEndpoint, validate } from './api' -import { Contract } from '../../common/contract' +import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract' import { User } from '../../common/user' import { getCpmmSellBetInfo } from '../../common/sell-bet' import { addObjects, removeUndefinedProps } from '../../common/util/object' @@ -57,8 +57,12 @@ export const sellshares = newEndpoint(['POST'], async (req, auth) => { prevLoanAmount ) - if (!isFinite(newP)) { - throw new APIError(500, 'Trade rejected due to overflow error.') + if ( + !newP || + !isFinite(newP) || + Math.min(...Object.values(newPool ?? {})) < CPMM_MIN_POOL_QTY + ) { + throw new APIError(400, 'Sale too large for current liquidity pool.') } const newBetDoc = firestore.collection(`contracts/${contractId}/bets`).doc() diff --git a/functions/src/stripe.ts b/functions/src/stripe.ts index f9da593f..6fbc182c 100644 --- a/functions/src/stripe.ts +++ b/functions/src/stripe.ts @@ -4,6 +4,7 @@ import Stripe from 'stripe' import { getPrivateUser, getUser, isProd, payUser } from './utils' import { sendThankYouEmail } from './emails' +import { track } from './analytics' export type StripeSession = Stripe.Event.Data.Object & { id: string @@ -152,6 +153,13 @@ const issueMoneys = async (session: StripeSession) => { if (!privateUser) return await sendThankYouEmail(user, privateUser) + + await track( + userId, + 'M$ purchase', + { amount: payout, sessionId }, + { revenue: payout / 100 } + ) } const firestore = admin.firestore() diff --git a/functions/src/update-contract-metrics.ts b/functions/src/update-contract-metrics.ts deleted file mode 100644 index 3b7f8eef..00000000 --- a/functions/src/update-contract-metrics.ts +++ /dev/null @@ -1,47 +0,0 @@ -import * as functions from 'firebase-functions' -import * as admin from 'firebase-admin' -import { max, sumBy } from 'lodash' - -import { getValues, log, logMemory, mapAsync } from './utils' -import { Bet } from '../../common/bet' - -const firestore = admin.firestore() - -const oneDay = 1000 * 60 * 60 * 24 - -const computeVolumes = async (contractId: string, durationsMs: number[]) => { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const longestDurationMs = max(durationsMs)! - const allBets = await getValues( - firestore - .collection(`contracts/${contractId}/bets`) - .where('createdTime', '>', Date.now() - longestDurationMs) - ) - return durationsMs.map((duration) => { - const cutoff = Date.now() - duration - const bets = allBets.filter((b) => b.createdTime > cutoff) - return sumBy(bets, (bet) => (bet.isRedemption ? 0 : Math.abs(bet.amount))) - }) -} - -export const updateContractMetricsCore = async () => { - const contractDocs = await firestore.collection('contracts').listDocuments() - log(`Loaded ${contractDocs.length} contract IDs.`) - logMemory() - await mapAsync(contractDocs, async (doc) => { - const [volume24Hours, volume7Days] = await computeVolumes(doc.id, [ - oneDay, - oneDay * 7, - ]) - return await doc.update({ - volume24Hours, - volume7Days, - }) - }) - log(`Updated metrics for ${contractDocs.length} contracts.`) -} - -export const updateContractMetrics = functions - .runWith({ memory: '1GB' }) - .pubsub.schedule('every 15 minutes') - .onRun(updateContractMetricsCore) diff --git a/functions/src/update-metrics.ts b/functions/src/update-metrics.ts new file mode 100644 index 00000000..0a51e13c --- /dev/null +++ b/functions/src/update-metrics.ts @@ -0,0 +1,94 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' +import { groupBy, sum, sumBy } from 'lodash' + +import { getValues, log, logMemory, writeUpdatesAsync } from './utils' +import { Bet } from '../../common/bet' +import { Contract } from '../../common/contract' +import { User } from '../../common/user' +import { calculatePayout } from '../../common/calculate' + +const firestore = admin.firestore() + +const oneDay = 1000 * 60 * 60 * 24 + +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') + return payout - (bet.loanAmount ?? 0) + }) +} + +const computeTotalPool = (contracts: Contract[]) => { + return sum(contracts.map((contract) => sum(Object.values(contract.pool)))) +} + +export const updateMetricsCore = async () => { + const [users, contracts, bets] = await Promise.all([ + getValues(firestore.collection('users')), + getValues(firestore.collection('contracts')), + getValues(firestore.collectionGroup('bets')), + ]) + log( + `Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.length} bets.` + ) + logMemory() + + const now = Date.now() + const betsByContract = groupBy(bets, (bet) => bet.contractId) + const contractUpdates = contracts.map((contract) => { + const contractBets = betsByContract[contract.id] ?? [] + return { + doc: firestore.collection('contracts').doc(contract.id), + fields: { + volume24Hours: computeVolume(contractBets, now - oneDay), + volume7Days: computeVolume(contractBets, now - oneDay * 7), + }, + } + }) + await writeUpdatesAsync(firestore, contractUpdates) + log(`Updated metrics for ${contracts.length} contracts.`) + + const contractsById = Object.fromEntries( + contracts.map((contract) => [contract.id, contract]) + ) + const contractsByUser = groupBy(contracts, (contract) => contract.creatorId) + const betsByUser = groupBy(bets, (bet) => bet.userId) + const userUpdates = users.map((user) => { + const investmentValue = computeInvestmentValue( + betsByUser[user.id] ?? [], + contractsById + ) + const creatorContracts = contractsByUser[user.id] ?? [] + const creatorVolume = computeTotalPool(creatorContracts) + const totalValue = user.balance + investmentValue + const totalPnL = totalValue - user.totalDeposits + return { + doc: firestore.collection('users').doc(user.id), + fields: { + totalPnLCached: totalPnL, + creatorVolumeCached: creatorVolume, + }, + } + }) + await writeUpdatesAsync(firestore, userUpdates) + log(`Updated metrics for ${users.length} users.`) +} + +const computeVolume = (contractBets: Bet[], since: number) => { + return sumBy(contractBets, (b) => + b.createdTime > since && !b.isRedemption ? Math.abs(b.amount) : 0 + ) +} + +export const updateMetrics = functions + .runWith({ memory: '1GB' }) + .pubsub.schedule('every 15 minutes') + .onRun(updateMetricsCore) diff --git a/functions/src/update-user-metrics.ts b/functions/src/update-user-metrics.ts deleted file mode 100644 index 42471981..00000000 --- a/functions/src/update-user-metrics.ts +++ /dev/null @@ -1,79 +0,0 @@ -import * as functions from 'firebase-functions' -import * as admin from 'firebase-admin' -import { groupBy, sum, sumBy } from 'lodash' - -import { getValues, log, logMemory, mapAsync } from './utils' -import { Contract } from '../../common/contract' -import { Bet } from '../../common/bet' -import { User } from '../../common/user' -import { calculatePayout } from '../../common/calculate' - -const firestore = admin.firestore() - -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') - return payout - (bet.loanAmount ?? 0) - }) -} - -const computeTotalPool = ( - user: User, - contractsDict: { [k: string]: Contract } -) => { - const creatorContracts = Object.values(contractsDict).filter( - (contract) => contract.creatorId === user.id - ) - const pools = creatorContracts.map((contract) => - sum(Object.values(contract.pool)) - ) - return sum(pools) -} - -export const updateUserMetricsCore = async () => { - const [users, contracts, bets] = await Promise.all([ - getValues(firestore.collection('users')), - getValues(firestore.collection('contracts')), - firestore.collectionGroup('bets').get(), - ]) - log( - `Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.docs.length} bets.` - ) - logMemory() - - const contractsDict = Object.fromEntries( - contracts.map((contract) => [contract.id, contract]) - ) - - const betsByUser = groupBy( - bets.docs.map((doc) => doc.data() as Bet), - (bet) => bet.userId - ) - - await mapAsync(users, async (user) => { - const investmentValue = computeInvestmentValue( - betsByUser[user.id] ?? [], - contractsDict - ) - const creatorVolume = computeTotalPool(user, contractsDict) - const totalValue = user.balance + investmentValue - const totalPnL = totalValue - user.totalDeposits - return await firestore.collection('users').doc(user.id).update({ - totalPnLCached: totalPnL, - creatorVolumeCached: creatorVolume, - }) - }) - log(`Updated metrics for ${users.length} users.`) -} - -export const updateUserMetrics = functions - .runWith({ memory: '1GB' }) - .pubsub.schedule('every 15 minutes') - .onRun(updateUserMetricsCore) diff --git a/functions/src/utils.ts b/functions/src/utils.ts index 7f69584b..9cac3409 100644 --- a/functions/src/utils.ts +++ b/functions/src/utils.ts @@ -15,18 +15,25 @@ export const logMemory = () => { } } -export const mapAsync = async ( - xs: T[], - fn: (x: T) => Promise, - concurrency = 100 +type UpdateSpec = { + doc: admin.firestore.DocumentReference + fields: { [k: string]: unknown } +} + +export const writeUpdatesAsync = async ( + db: admin.firestore.Firestore, + updates: UpdateSpec[], + batchSize = 500 // 500 = Firestore batch limit ) => { - const results = [] - const chunks = chunk(xs, concurrency) + const chunks = chunk(updates, batchSize) for (let i = 0; i < chunks.length; i++) { - log(`${i * concurrency}/${xs.length} processed...`) - results.push(...(await Promise.all(chunks[i].map(fn)))) + log(`${i * batchSize}/${updates.length} updates written...`) + const batch = db.batch() + for (const { doc, fields } of chunks[i]) { + batch.update(doc, fields) + } + await batch.commit() } - return results } export const isProd = diff --git a/functions/src/withdraw-liquidity.ts b/functions/src/withdraw-liquidity.ts index cee3002e..4c48ce49 100644 --- a/functions/src/withdraw-liquidity.ts +++ b/functions/src/withdraw-liquidity.ts @@ -78,7 +78,13 @@ export const withdrawLiquidity = functions } as Partial) const newPool = subtractObjects(contract.pool, userShares) - const newTotalLiquidity = contract.totalLiquidity - payout + + 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, diff --git a/web/.eslintrc.js b/web/.eslintrc.js index 2f3ac31a..b55b3277 100644 --- a/web/.eslintrc.js +++ b/web/.eslintrc.js @@ -10,7 +10,7 @@ module.exports = { '@typescript-eslint/no-empty-function': 'off', '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-unused-vars': [ - 'error', + 'warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', diff --git a/web/components/add-funds-button.tsx b/web/components/add-funds-button.tsx index 14c9b063..90b24b2c 100644 --- a/web/components/add-funds-button.tsx +++ b/web/components/add-funds-button.tsx @@ -61,7 +61,7 @@ export function AddFundsButton(props: { className?: string }) { > diff --git a/web/components/alert-box.tsx b/web/components/alert-box.tsx new file mode 100644 index 00000000..a8306583 --- /dev/null +++ b/web/components/alert-box.tsx @@ -0,0 +1,24 @@ +import { ExclamationIcon } from '@heroicons/react/solid' +import { Linkify } from './linkify' + +export function AlertBox(props: { title: string; text: string }) { + const { title, text } = props + return ( +
+
+
+
+
+

{title}

+
+ +
+
+
+
+ ) +} diff --git a/web/components/answers/answer-bet-panel.tsx b/web/components/answers/answer-bet-panel.tsx index ccb4fd14..705433b1 100644 --- a/web/components/answers/answer-bet-panel.tsx +++ b/web/components/answers/answer-bet-panel.tsx @@ -22,8 +22,9 @@ import { calculateDpmPayoutAfterCorrectBet, getDpmOutcomeProbabilityAfterBet, } from 'common/calculate-dpm' -import { firebaseLogin } from 'web/lib/firebase/users' import { Bet } from 'common/bet' +import { track } from 'web/lib/service/analytics' +import { SignUpPrompt } from '../sign-up-prompt' export function AnswerBetPanel(props: { answer: Answer @@ -72,6 +73,15 @@ export function AnswerBetPanel(props: { } setIsSubmitting(false) }) + + track('bet', { + location: 'answer panel', + outcomeType: contract.outcomeType, + slug: contract.slug, + contractId: contract.id, + amount: betAmount, + outcome: answerId, + }) } const betDisabled = isSubmitting || !betAmount || error @@ -173,12 +183,7 @@ export function AnswerBetPanel(props: { {isSubmitting ? 'Submitting...' : 'Submit trade'} ) : ( - + )} ) diff --git a/web/components/answers/create-answer-panel.tsx b/web/components/answers/create-answer-panel.tsx index 6d9739cc..2a089f50 100644 --- a/web/components/answers/create-answer-panel.tsx +++ b/web/components/answers/create-answer-panel.tsx @@ -22,6 +22,7 @@ import { import { firebaseLogin } from 'web/lib/firebase/users' import { Bet } from 'common/bet' import { MAX_ANSWER_LENGTH } from 'common/answer' +import { withTracking } from 'web/lib/service/analytics' export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { const { contract } = props @@ -143,7 +144,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { isSubmitting && 'loading' )} disabled={!canSubmit} - onClick={submitAnswer} + onClick={withTracking(submitAnswer, 'submit answer')} > Submit answer & buy @@ -151,7 +152,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) { text && ( diff --git a/web/components/bet-panel.tsx b/web/components/bet-panel.tsx index 42d40a25..76626210 100644 --- a/web/components/bet-panel.tsx +++ b/web/components/bet-panel.tsx @@ -40,6 +40,7 @@ import { useSaveShares } from './use-save-shares' import { SignUpPrompt } from './sign-up-prompt' import { isIOS } from 'web/lib/util/device' import { ProbabilityInput } from './probability-input' +import { track } from 'web/lib/service/analytics' export function BetPanel(props: { contract: CPMMBinaryContract @@ -266,6 +267,15 @@ function BuyPanel(props: { } setIsSubmitting(false) }) + + track('bet', { + location: 'bet panel', + outcomeType: contract.outcomeType, + slug: contract.slug, + contractId: contract.id, + amount: betAmount, + outcome: betChoice, + }) } const betDisabled = isSubmitting || !betAmount || error @@ -429,6 +439,14 @@ export function SellPanel(props: { } setIsSubmitting(false) }) + + track('sell shares', { + outcomeType: contract.outcomeType, + slug: contract.slug, + contractId: contract.id, + shares: sellAmount, + outcome: sharesOutcome, + }) } const initialProb = getProbability(contract) diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index d227ac88..906f3af5 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -482,7 +482,10 @@ export function ContractBetsTable(props: { }) { const { contract, className, isYourBets } = props - const bets = props.bets.filter((b) => !b.isAnte) + const bets = sortBy( + props.bets.filter((b) => !b.isAnte), + (bet) => bet.createdTime + ).reverse() const [sales, buys] = partition(bets, (bet) => bet.sale) diff --git a/web/components/charity/charity-card.tsx b/web/components/charity/charity-card.tsx index c5b351a7..31995284 100644 --- a/web/components/charity/charity-card.tsx +++ b/web/components/charity/charity-card.tsx @@ -6,9 +6,11 @@ import { Charity } from 'common/charity' import { useCharityTxns } from 'web/hooks/use-charity-txns' import { manaToUSD } from '../../../common/util/format' import { Row } from '../layout/row' +import { Col } from '../layout/col' -export function CharityCard(props: { charity: Charity }) { - const { slug, photo, preview, id, tags } = props.charity +export function CharityCard(props: { charity: Charity; match?: number }) { + const { charity, match } = props + const { slug, photo, preview, id, tags } = charity const txns = useCharityTxns(id) const raised = sumBy(txns, (txn) => txn.amount) @@ -32,14 +34,22 @@ export function CharityCard(props: { charity: Charity }) { {/*

{name}

*/}
{preview}
{raised > 0 && ( - - - {raised < 100 - ? manaToUSD(raised) - : '$' + Math.floor(raised / 100)} - - raised - + <> + + + + {formatUsd(raised)} + + raised + + {match && ( + + +{formatUsd(match)} + match + + )} + + )} @@ -47,6 +57,10 @@ export function CharityCard(props: { charity: Charity }) { ) } +function formatUsd(mana: number) { + return mana < 100 ? manaToUSD(mana) : '$' + Math.floor(mana / 100) +} + function FeaturedBadge() { return ( diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index e56be8b8..afb9c398 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -26,6 +26,8 @@ import { EditCategoriesButton } from './feed/category-selector' import { CATEGORIES } from 'common/categories' import { Tabs } from './layout/tabs' import { EditFollowingButton } from './following-button' +import { track } from '@amplitude/analytics-browser' +import { trackCallback } from 'web/lib/service/analytics' const searchClient = algoliasearch( 'GJQPAYENIF', @@ -134,6 +136,7 @@ export function ContractSearch(props: { className="!select !select-bordered" value={filter} onChange={(e) => setFilter(e.target.value as filter)} + onBlur={trackCallback('select search filter')} > @@ -145,6 +148,7 @@ export function ContractSearch(props: { classNames={{ select: '!select !select-bordered', }} + onBlur={trackCallback('select search sort')} /> setMode(index === 0 ? 'categories' : 'following')} + onClick={(_, index) => { + const mode = index === 0 ? 'categories' : 'following' + setMode(mode) + track(`click ${mode} tab`) + }} /> ) } diff --git a/web/components/contract/contract-card.tsx b/web/components/contract/contract-card.tsx index 3f600b55..a79351be 100644 --- a/web/components/contract/contract-card.tsx +++ b/web/components/contract/contract-card.tsx @@ -22,6 +22,8 @@ import { getExpectedValue, getValueFromBucket } from 'common/calculate-dpm' import { QuickBet, ProbBar, getColor } from './quick-bet' import { useContractWithPreload } from 'web/hooks/use-contract' import { useUser } from 'web/hooks/use-user' +import { track } from '@amplitude/analytics-browser' +import { trackCallback } from 'web/lib/service/analytics' export function ContractCard(props: { contract: Contract @@ -71,12 +73,22 @@ export function ContractCard(props: { if (e.ctrlKey || e.metaKey) return e.preventDefault() + track('click market card', { + slug: contract.slug, + contractId: contract.id, + }) onClick() }} /> ) : ( - + )} diff --git a/web/components/contract/quick-bet.tsx b/web/components/contract/quick-bet.tsx index 1a532eac..5dbb0fb5 100644 --- a/web/components/contract/quick-bet.tsx +++ b/web/components/contract/quick-bet.tsx @@ -24,6 +24,7 @@ import { OUTCOME_TO_COLOR } from '../outcome-label' import { useSaveShares } from '../use-save-shares' import { sellShares } from 'web/lib/firebase/api-call' import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm' +import { track } from 'web/lib/service/analytics' const BET_SIZE = 10 @@ -121,6 +122,12 @@ export function QuickBet(props: { contract: Contract; user: User }) { success: message, error: (err) => `${err.message}`, }) + + track('quick bet', { + slug: contract.slug, + direction, + contractId: contract.id, + }) } function quickOutcome(contract: Contract, direction: 'UP' | 'DOWN') { diff --git a/web/components/copy-link-button.tsx b/web/components/copy-link-button.tsx index 9366af8f..ab6dd66f 100644 --- a/web/components/copy-link-button.tsx +++ b/web/components/copy-link-button.tsx @@ -2,11 +2,13 @@ import React, { Fragment } from 'react' import { LinkIcon } from '@heroicons/react/outline' import { Menu, Transition } from '@headlessui/react' import clsx from 'clsx' + import { Contract } from 'common/contract' import { copyToClipboard } from 'web/lib/util/copy' import { contractPath } from 'web/lib/firebase/contracts' import { ENV_CONFIG } from 'common/envs/constants' import { ToastClipboard } from 'web/components/toast-clipboard' +import { track } from 'web/lib/service/analytics' function copyContractUrl(contract: Contract) { copyToClipboard(`https://${ENV_CONFIG.domain}${contractPath(contract)}`) @@ -23,7 +25,10 @@ export function CopyLinkButton(props: { copyContractUrl(contract)} + onMouseUp={() => { + copyContractUrl(contract) + track('copy share link') + }} > string -}) { - const { feed, mode, getContractPath } = props - const user = useUser() - - return ( - - {feed.map((item) => ( - - ))} - - ) -} diff --git a/web/components/feed/activity-items.ts b/web/components/feed/activity-items.ts index 4af7d385..ee7239e3 100644 --- a/web/components/feed/activity-items.ts +++ b/web/components/feed/activity-items.ts @@ -1,4 +1,4 @@ -import { last, findLastIndex, uniq, sortBy } from 'lodash' +import { uniq, sortBy } from 'lodash' import { Answer } from 'common/answer' import { Bet } from 'common/bet' @@ -6,14 +6,11 @@ import { getOutcomeProbability } from 'common/calculate' import { Comment } from 'common/comment' import { Contract, FreeResponseContract } from 'common/contract' import { User } from 'common/user' -import { mapCommentsByBetId } from 'web/lib/firebase/comments' export type ActivityItem = | DescriptionItem | QuestionItem | BetItem - | CommentItem - | BetGroupItem | AnswerGroupItem | CloseItem | ResolveItem @@ -49,15 +46,6 @@ export type BetItem = BaseActivityItem & { hideComment?: boolean } -export type CommentItem = BaseActivityItem & { - type: 'comment' - comment: Comment - betsBySameUser: Bet[] - probAtCreatedTime?: number - truncate?: boolean - smallAvatar?: boolean -} - export type CommentThreadItem = BaseActivityItem & { type: 'commentThread' parentComment: Comment @@ -65,12 +53,6 @@ export type CommentThreadItem = BaseActivityItem & { bets: Bet[] } -export type BetGroupItem = BaseActivityItem & { - type: 'betgroup' - bets: Bet[] - hideOutcome: boolean -} - export type AnswerGroupItem = BaseActivityItem & { type: 'answergroup' user: User | undefined | null @@ -87,172 +69,6 @@ export type ResolveItem = BaseActivityItem & { type: 'resolve' } -const DAY_IN_MS = 24 * 60 * 60 * 1000 -const ABBREVIATED_NUM_COMMENTS_OR_BETS_TO_SHOW = 3 - -// Group together bets that are: -// - Within a day of the first in the group -// (Unless the bets are older: then are grouped by 7-days.) -// - Do not have a comment -// - Were not created by this user -// Return a list of ActivityItems -function groupBets( - bets: Bet[], - comments: Comment[], - contract: Contract, - userId: string | undefined, - options: { - hideOutcome: boolean - abbreviated: boolean - smallAvatar: boolean - reversed: boolean - } -) { - const { hideOutcome, abbreviated, smallAvatar, reversed } = options - - const commentsMap = mapCommentsByBetId(comments) - const items: ActivityItem[] = [] - let group: Bet[] = [] - - // Turn the current group into an ActivityItem - function pushGroup() { - if (group.length == 1) { - items.push(toActivityItem(group[0])) - } else if (group.length > 1) { - items.push({ - type: 'betgroup', - bets: [...group], - id: group[0].id, - contract, - hideOutcome, - }) - } - group = [] - } - - function toActivityItem(bet: Bet): ActivityItem { - const comment = commentsMap[bet.id] - return comment - ? { - type: 'comment' as const, - id: bet.id, - comment, - betsBySameUser: [bet], - contract, - truncate: abbreviated, - smallAvatar, - } - : { - type: 'bet' as const, - id: bet.id, - bet, - contract, - hideOutcome, - smallAvatar, - } - } - - for (const bet of bets) { - const isCreator = userId === bet.userId - - // If first bet in group is older than 3 days, group by 7 days. Otherwise, group by 1 day. - const windowMs = - Date.now() - (group[0]?.createdTime ?? bet.createdTime) > DAY_IN_MS * 3 - ? DAY_IN_MS * 7 - : DAY_IN_MS - - if (commentsMap[bet.id] || isCreator) { - pushGroup() - // Create a single item for this - items.push(toActivityItem(bet)) - } else { - if ( - group.length > 0 && - bet.createdTime - group[0].createdTime > windowMs - ) { - // More than `windowMs` has passed; start a new group - pushGroup() - } - group.push(bet) - } - } - if (group.length > 0) { - pushGroup() - } - const abbrItems = abbreviated - ? items.slice(-ABBREVIATED_NUM_COMMENTS_OR_BETS_TO_SHOW) - : items - if (reversed) abbrItems.reverse() - return abbrItems -} - -function getAnswerGroups( - contract: FreeResponseContract, - bets: Bet[], - comments: Comment[], - user: User | undefined | null, - options: { - sortByProb: boolean - abbreviated: boolean - reversed: boolean - } -) { - const { sortByProb, abbreviated, reversed } = options - - let outcomes = uniq(bets.map((bet) => bet.outcome)) - if (abbreviated) { - const lastComment = last(comments) - const lastCommentOutcome = bets.find( - (bet) => bet.id === lastComment?.betId - )?.outcome - const lastBetOutcome = last(bets)?.outcome - if (lastCommentOutcome && lastBetOutcome) { - outcomes = uniq([ - ...outcomes.filter( - (outcome) => - outcome !== lastCommentOutcome && outcome !== lastBetOutcome - ), - lastCommentOutcome, - lastBetOutcome, - ]) - } - outcomes = outcomes.slice(-2) - } - if (sortByProb) { - outcomes = sortBy(outcomes, (outcome) => - getOutcomeProbability(contract, outcome) - ) - } else { - // Sort by recent bet. - outcomes = sortBy(outcomes, (outcome) => - findLastIndex(bets, (bet) => bet.outcome === outcome) - ) - } - - const answerGroups = outcomes - .map((outcome) => { - const answer = contract.answers?.find( - (answer) => answer.id === outcome - ) as Answer - - // TODO: this doesn't abbreviate these groups for activity feed anymore - return { - id: outcome, - type: 'answergroup' as const, - contract, - user, - answer, - comments, - bets, - } - }) - .filter((group) => group.answer) - - if (reversed) answerGroups.reverse() - - return answerGroups -} - function getAnswerAndCommentInputGroups( contract: FreeResponseContract, bets: Bet[], @@ -284,54 +100,6 @@ function getAnswerAndCommentInputGroups( return answerGroups } -function groupBetsAndComments( - bets: Bet[], - comments: Comment[], - contract: Contract, - userId: string | undefined, - options: { - hideOutcome: boolean - abbreviated: boolean - smallAvatar: boolean - reversed: boolean - } -) { - const { smallAvatar, abbreviated, reversed } = options - // Comments in feed don't show user's position? - const commentsWithoutBets = comments - .filter((comment) => !comment.betId) - .map((comment) => ({ - type: 'comment' as const, - id: comment.id, - contract: contract, - comment, - betsBySameUser: [], - truncate: abbreviated, - smallAvatar, - })) - - const groupedBets = groupBets(bets, comments, contract, userId, options) - - // iterate through the bets and comment activity items and add them to the items in order of comment creation time: - const unorderedBetsAndComments = [...commentsWithoutBets, ...groupedBets] - const sortedBetsAndComments = sortBy(unorderedBetsAndComments, (item) => { - if (item.type === 'comment') { - return item.comment.createdTime - } else if (item.type === 'bet') { - return item.bet.createdTime - } else if (item.type === 'betgroup') { - return item.bets[0].createdTime - } - }) - - const abbrItems = abbreviated - ? sortedBetsAndComments.slice(-ABBREVIATED_NUM_COMMENTS_OR_BETS_TO_SHOW) - : sortedBetsAndComments - - if (reversed) abbrItems.reverse() - return abbrItems -} - function getCommentThreads( bets: Bet[], comments: Comment[], @@ -351,122 +119,6 @@ function getCommentThreads( return items } -export function getAllContractActivityItems( - contract: Contract, - bets: Bet[], - comments: Comment[], - user: User | null | undefined, - options: { - abbreviated: boolean - } -) { - const { abbreviated } = options - const { outcomeType } = contract - const reversed = true - - bets = - outcomeType === 'BINARY' - ? bets.filter((bet) => !bet.isAnte && !bet.isRedemption) - : bets.filter((bet) => !(bet.isAnte && (bet.outcome as string) === '0')) - - const items: ActivityItem[] = abbreviated - ? [ - { - type: 'question', - id: '0', - contract, - showDescription: false, - }, - ] - : [{ type: 'description', id: '0', contract }] - - if (outcomeType === 'FREE_RESPONSE') { - const onlyUsersBetsOrBetsWithComments = bets.filter((bet) => - comments.some( - (comment) => comment.betId === bet.id || bet.userId === user?.id - ) - ) - items.push( - ...groupBetsAndComments( - onlyUsersBetsOrBetsWithComments, - comments, - contract, - user?.id, - { - hideOutcome: false, - abbreviated, - smallAvatar: false, - reversed, - } - ) - ) - } else { - items.push( - ...groupBetsAndComments(bets, comments, contract, user?.id, { - hideOutcome: false, - abbreviated, - smallAvatar: false, - reversed, - }) - ) - } - - if (contract.closeTime && contract.closeTime <= Date.now()) { - items.push({ type: 'close', id: `${contract.closeTime}`, contract }) - } - if (contract.resolution) { - items.push({ type: 'resolve', id: `${contract.resolutionTime}`, contract }) - } - - if (reversed) items.reverse() - - return items -} - -export function getRecentContractActivityItems( - contract: Contract, - bets: Bet[], - comments: Comment[], - user: User | null | undefined, - options: { - contractPath?: string - } -) { - const { contractPath } = options - bets = bets.sort((b1, b2) => b1.createdTime - b2.createdTime) - comments = comments.sort((c1, c2) => c1.createdTime - c2.createdTime) - - const questionItem: QuestionItem = { - type: 'question', - id: '0', - contract, - showDescription: false, - contractPath, - } - - const items = [] - if (contract.outcomeType === 'FREE_RESPONSE') { - items.push( - ...getAnswerGroups(contract, bets, comments, user, { - sortByProb: false, - abbreviated: true, - reversed: true, - }) - ) - } else { - items.push( - ...groupBetsAndComments(bets, comments, contract, user?.id, { - hideOutcome: false, - abbreviated: true, - smallAvatar: false, - reversed: true, - }) - ) - } - - return [questionItem, ...items] -} - function commentIsGeneralComment(comment: Comment, contract: Contract) { return ( comment.answerOutcome === undefined && diff --git a/web/components/feed/category-selector.tsx b/web/components/feed/category-selector.tsx index 8f7c5e75..db9a856a 100644 --- a/web/components/feed/category-selector.tsx +++ b/web/components/feed/category-selector.tsx @@ -9,6 +9,7 @@ import { Col } from '../layout/col' import { useState } from 'react' import { updateUser, User } from 'web/lib/firebase/users' import { Checkbox } from '../checkbox' +import { track } from 'web/lib/service/analytics' export function CategorySelector(props: { category: string @@ -93,7 +94,10 @@ export function EditCategoriesButton(props: { className, 'btn btn-sm btn-ghost cursor-pointer gap-2 whitespace-nowrap text-sm normal-case text-gray-700' )} - onClick={() => setIsOpen(true)} + onClick={() => { + setIsOpen(true) + track('edit categories button') + }} > Categories diff --git a/web/components/feed/contract-activity.tsx b/web/components/feed/contract-activity.tsx index 95bc4bfe..8c436333 100644 --- a/web/components/feed/contract-activity.tsx +++ b/web/components/feed/contract-activity.tsx @@ -3,11 +3,7 @@ import { Comment } from 'web/lib/firebase/comments' import { Bet } from 'common/bet' import { useBets } from 'web/hooks/use-bets' import { useComments } from 'web/hooks/use-comments' -import { - getAllContractActivityItems, - getRecentContractActivityItems, - getSpecificContractActivityItems, -} from './activity-items' +import { getSpecificContractActivityItems } from './activity-items' import { FeedItems } from './feed-items' import { User } from 'common/user' import { useContractWithPreload } from 'web/hooks/use-contract' @@ -17,45 +13,27 @@ export function ContractActivity(props: { bets: Bet[] comments: Comment[] user: User | null | undefined - mode: - | 'only-recent' - | 'abbreviated' - | 'all' - | 'comments' - | 'bets' - | 'free-response-comment-answer-groups' + mode: 'comments' | 'bets' | 'free-response-comment-answer-groups' contractPath?: string className?: string betRowClassName?: string }) { - const { user, mode, contractPath, className, betRowClassName } = props + const { user, mode, className, betRowClassName } = props const contract = useContractWithPreload(props.contract) ?? props.contract - const updatedComments = - // eslint-disable-next-line react-hooks/rules-of-hooks - mode === 'only-recent' ? undefined : useComments(contract.id) + const updatedComments = useComments(contract.id) const comments = updatedComments ?? props.comments - const updatedBets = - // eslint-disable-next-line react-hooks/rules-of-hooks - mode === 'only-recent' ? undefined : useBets(contract.id) + const updatedBets = useBets(contract.id) const bets = (updatedBets ?? props.bets).filter((bet) => !bet.isRedemption) - const items = - mode === 'only-recent' - ? getRecentContractActivityItems(contract, bets, comments, user, { - contractPath, - }) - : mode === 'comments' || - mode === 'bets' || - mode === 'free-response-comment-answer-groups' - ? getSpecificContractActivityItems(contract, bets, comments, user, { - mode, - }) - : // only used in abbreviated mode with folds/communities, all mode isn't used - getAllContractActivityItems(contract, bets, comments, user, { - abbreviated: mode === 'abbreviated', - }) + const items = getSpecificContractActivityItems( + contract, + bets, + comments, + user, + { mode } + ) return ( case 'description': return - case 'comment': - return case 'bet': return - case 'betgroup': - return case 'answergroup': return case 'close': diff --git a/web/components/follow-button.tsx b/web/components/follow-button.tsx index f2a67b9b..09495169 100644 --- a/web/components/follow-button.tsx +++ b/web/components/follow-button.tsx @@ -2,6 +2,7 @@ import clsx from 'clsx' import { useFollows } from 'web/hooks/use-follows' import { useUser } from 'web/hooks/use-user' import { follow, unfollow } from 'web/lib/firebase/users' +import { withTracking } from 'web/lib/service/analytics' export function FollowButton(props: { isFollowing: boolean | undefined @@ -34,7 +35,7 @@ export function FollowButton(props: { small && smallStyle, className )} - onClick={onUnfollow} + onClick={withTracking(onUnfollow, 'unfollow')} > Following @@ -44,7 +45,7 @@ export function FollowButton(props: { return ( diff --git a/web/components/following-button.tsx b/web/components/following-button.tsx index f402f3d0..c9aecbff 100644 --- a/web/components/following-button.tsx +++ b/web/components/following-button.tsx @@ -1,5 +1,6 @@ import clsx from 'clsx' import { PencilIcon } from '@heroicons/react/outline' + import { User } from 'common/user' import { useEffect, useState } from 'react' import { useFollowers, useFollows } from 'web/hooks/use-follows' @@ -10,6 +11,7 @@ import { Modal } from './layout/modal' import { Tabs } from './layout/tabs' import { useDiscoverUsers } from 'web/hooks/use-users' import { TextButton } from './text-button' +import { track } from 'web/lib/service/analytics' export function FollowingButton(props: { user: User }) { const { user } = props @@ -48,7 +50,10 @@ export function EditFollowingButton(props: { user: User; className?: string }) { className, 'btn btn-sm btn-ghost cursor-pointer gap-2 whitespace-nowrap text-sm normal-case text-gray-700' )} - onClick={() => setIsOpen(true)} + onClick={() => { + setIsOpen(true) + track('edit following button') + }} > Following diff --git a/web/components/landing-page-panel.tsx b/web/components/landing-page-panel.tsx index f3ddf4f1..bcfdaf1e 100644 --- a/web/components/landing-page-panel.tsx +++ b/web/components/landing-page-panel.tsx @@ -7,10 +7,14 @@ import { firebaseLogin } from 'web/lib/firebase/users' import { ContractsGrid } from './contract/contracts-list' import { Col } from './layout/col' import { Row } from './layout/row' +import { withTracking } from 'web/lib/service/analytics' +import { useTracking } from 'web/hooks/use-tracking' export function LandingPagePanel(props: { hotContracts: Contract[] }) { const { hotContracts } = props + useTracking('view landing page') + return ( <> @@ -45,7 +49,7 @@ export function LandingPagePanel(props: { hotContracts: Contract[] }) { {' '} diff --git a/web/components/liquidity-panel.tsx b/web/components/liquidity-panel.tsx index 4f836da7..33efb335 100644 --- a/web/components/liquidity-panel.tsx +++ b/web/components/liquidity-panel.tsx @@ -12,6 +12,7 @@ import { Tabs } from './layout/tabs' import { NoLabel, YesLabel } from './outcome-label' import { Col } from './layout/col' import { InfoTooltip } from './info-tooltip' +import { track } from 'web/lib/service/analytics' export function LiquidityPanel(props: { contract: CPMMContract }) { const { contract } = props @@ -57,7 +58,7 @@ export function LiquidityPanel(props: { contract: CPMMContract }) { function AddLiquidityPanel(props: { contract: CPMMContract }) { const { contract } = props - const { id: contractId } = contract + const { id: contractId, slug } = contract const user = useUser() @@ -99,6 +100,8 @@ function AddLiquidityPanel(props: { contract: CPMMContract }) { } }) .catch((_) => setError('Server error')) + + track('add liquidity', { amount, contractId, slug }) } return ( @@ -177,6 +180,8 @@ function WithdrawLiquidityPanel(props: { setIsLoading(false) }) .catch((_) => setError('Server error')) + + track('withdraw liquidity') } if (isSuccess) diff --git a/web/components/nav/nav-bar.tsx b/web/components/nav/nav-bar.tsx index 7a291606..926b8ae8 100644 --- a/web/components/nav/nav-bar.tsx +++ b/web/components/nav/nav-bar.tsx @@ -17,6 +17,7 @@ import clsx from 'clsx' import { useRouter } from 'next/router' import NotificationsIcon from 'web/components/notifications-icon' import { useIsIframe } from 'web/hooks/use-is-iframe' +import { trackCallback } from 'web/lib/service/analytics' function getNavigation(username: string) { return [ @@ -106,6 +107,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)} > {item.name} diff --git a/web/components/nav/profile-menu.tsx b/web/components/nav/profile-menu.tsx index 9af9f59c..397f6e4e 100644 --- a/web/components/nav/profile-menu.tsx +++ b/web/components/nav/profile-menu.tsx @@ -1,13 +1,18 @@ import Link from 'next/link' + import { User } from 'web/lib/firebase/users' import { formatMoney } from 'common/util/format' import { Avatar } from '../avatar' +import { trackCallback } from 'web/lib/service/analytics' export function ProfileSummary(props: { user: User }) { const { user } = props return ( - +
diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 8dbd1e0a..b5f118c2 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -26,6 +26,7 @@ import { Row } from '../layout/row' import NotificationsIcon from 'web/components/notifications-icon' import React, { useEffect, useState } from 'react' import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants' +import { trackCallback, withTracking } from 'web/lib/service/analytics' // Create an icon from the url of an image function IconFromUrl(url: string): React.ComponentType<{ className?: string }> { @@ -72,7 +73,7 @@ function getMoreNavigation(user?: User | null) { { name: 'Blog', href: 'https://news.manifold.markets' }, { name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' }, { name: 'Twitter', href: 'https://twitter.com/ManifoldMarkets' }, - { name: 'About', href: 'https://docs.manifold.markets' }, + { name: 'About', href: 'https://docs.manifold.markets/$how-to' }, { name: 'Sign out', href: '#', onClick: () => firebaseLogout() }, ] } @@ -81,7 +82,11 @@ const signedOutNavigation = [ { name: 'Home', href: '/home', icon: HomeIcon }, { name: 'Explore', href: '/markets', icon: SearchIcon }, { name: 'Charity', href: '/charity', icon: HeartIcon }, - { name: 'About', href: 'https://docs.manifold.markets', icon: BookOpenIcon }, + { + name: 'About', + href: 'https://docs.manifold.markets/$how-to', + icon: BookOpenIcon, + }, ] const signedOutMobileNavigation = [ @@ -98,7 +103,11 @@ const signedOutMobileNavigation = [ href: 'https://twitter.com/ManifoldMarkets', icon: IconFromUrl('/twitter-logo.svg'), }, - { name: 'About', href: 'https://docs.manifold.markets', icon: BookOpenIcon }, + { + name: 'About', + href: 'https://docs.manifold.markets/$how-to', + icon: BookOpenIcon, + }, ] const mobileNavigation = [ @@ -117,6 +126,7 @@ function SidebarItem(props: { item: Item; currentPage: string }) { return ( firebaseLogout() }, + { + name: 'Sign out', + href: '#', + onClick: withTracking(firebaseLogout, 'sign out'), + }, ]} buttonContent={} /> @@ -229,14 +243,17 @@ export default function Sidebar(props: { className?: string }) {
{user ? ( - ) : ( diff --git a/web/components/numeric-bet-panel.tsx b/web/components/numeric-bet-panel.tsx index df52c0f7..9246bc89 100644 --- a/web/components/numeric-bet-panel.tsx +++ b/web/components/numeric-bet-panel.tsx @@ -19,6 +19,7 @@ import { Col } from './layout/col' import { Row } from './layout/row' import { Spacer } from './layout/spacer' import { SignUpPrompt } from './sign-up-prompt' +import { track } from 'web/lib/service/analytics' export function NumericBetPanel(props: { contract: NumericContract @@ -96,6 +97,15 @@ function NumericBuyPanel(props: { } setIsSubmitting(false) }) + + track('bet', { + location: 'numeric panel', + outcomeType: contract.outcomeType, + slug: contract.slug, + contractId: contract.id, + amount: betAmount, + value, + }) } const betDisabled = isSubmitting || !betAmount || !bucketChoice || error diff --git a/web/components/share-embed-button.tsx b/web/components/share-embed-button.tsx index e21c4cfe..1b24c689 100644 --- a/web/components/share-embed-button.tsx +++ b/web/components/share-embed-button.tsx @@ -1,11 +1,13 @@ import React, { Fragment } from 'react' import { CodeIcon } from '@heroicons/react/outline' import { Menu, Transition } from '@headlessui/react' + import { Contract } from 'common/contract' import { contractPath } from 'web/lib/firebase/contracts' import { DOMAIN } from 'common/envs/constants' import { copyToClipboard } from 'web/lib/util/copy' import { ToastClipboard } from 'web/components/toast-clipboard' +import { track } from 'web/lib/service/analytics' function copyEmbedCode(contract: Contract) { const title = contract.question @@ -26,7 +28,10 @@ export function ShareEmbedButton(props: { copyEmbedCode(contract)} + onMouseUp={() => { + copyEmbedCode(contract) + track('copy embed code') + }} > Sign up to bet! diff --git a/web/components/tags-input.tsx b/web/components/tags-input.tsx index 6a0cdcda..22ce52f9 100644 --- a/web/components/tags-input.tsx +++ b/web/components/tags-input.tsx @@ -6,6 +6,7 @@ import { Col } from './layout/col' import { Row } from './layout/row' import { TagsList } from './tags-list' import { MAX_TAG_LENGTH } from 'common/contract' +import { track } from 'web/lib/service/analytics' export function TagsInput(props: { contract: Contract; className?: string }) { const { contract, className } = props @@ -24,6 +25,7 @@ export function TagsInput(props: { contract: Contract; className?: string }) { }) setIsSubmitting(false) setTagText('') + track('save tags') } return ( diff --git a/web/components/tweet-button.tsx b/web/components/tweet-button.tsx index 44f32ab9..23410165 100644 --- a/web/components/tweet-button.tsx +++ b/web/components/tweet-button.tsx @@ -1,4 +1,5 @@ import clsx from 'clsx' +import { trackCallback } from 'web/lib/service/analytics' export function TweetButton(props: { className?: string; tweetText: string }) { const { tweetText, className } = props @@ -12,6 +13,7 @@ export function TweetButton(props: { className?: string; tweetText: string }) { color: '#1da1f2', }} href={getTweetHref(tweetText)} + onClick={trackCallback('share tweet')} target="_blank" > diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index 9ab2fe83..4c906540 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -1,4 +1,8 @@ import clsx from 'clsx' +import { uniq } from 'lodash' +import { LinkIcon } from '@heroicons/react/solid' +import { PencilIcon } from '@heroicons/react/outline' + import { follow, unfollow, User } from 'web/lib/firebase/users' import { CreatorContractsList } from './contract/contracts-list' import { SEO } from './SEO' @@ -9,9 +13,7 @@ import { Col } from './layout/col' import { Linkify } from './linkify' import { Spacer } from './layout/spacer' import { Row } from './layout/row' -import { LinkIcon } from '@heroicons/react/solid' import { genHash } from 'common/util/random' -import { PencilIcon } from '@heroicons/react/outline' import { Tabs } from './layout/tabs' import { UserCommentsList } from './comments-list' import { useEffect, useState } from 'react' @@ -22,8 +24,10 @@ import { LoadingIndicator } from './loading-indicator' import { BetsList } from './bets-list' import { Bet } from 'common/bet' import { getUserBets } from 'web/lib/firebase/bets' -import { uniq } from 'lodash' import { FollowersButton, FollowingButton } from './following-button' +import { AlertBox } from './alert-box' +import { useFollows } from 'web/hooks/use-follows' +import { FollowButton } from './follow-button' export function UserLink(props: { name: string @@ -305,29 +309,3 @@ export function defaultBannerUrl(userId: string) { ] return defaultBanner[genHash(userId)() % defaultBanner.length] } - -import { ExclamationIcon } from '@heroicons/react/solid' -import { FollowButton } from './follow-button' -import { useFollows } from 'web/hooks/use-follows' - -function AlertBox(props: { title: string; text: string }) { - const { title, text } = props - return ( -
-
-
-
-
-

{title}

-
- -
-
-
-
- ) -} diff --git a/web/hooks/use-tracking.ts b/web/hooks/use-tracking.ts new file mode 100644 index 00000000..018e82a0 --- /dev/null +++ b/web/hooks/use-tracking.ts @@ -0,0 +1,8 @@ +import { track } from '@amplitude/analytics-browser' +import { useEffect } from 'react' + +export const useTracking = (eventName: string, eventProperties?: any) => { + useEffect(() => { + track(eventName, eventProperties) + }, []) +} diff --git a/web/hooks/use-user.ts b/web/hooks/use-user.ts index b57a0256..c4d1dff9 100644 --- a/web/hooks/use-user.ts +++ b/web/hooks/use-user.ts @@ -13,6 +13,7 @@ import { userDocRef, } from 'web/lib/firebase/users' import { useStateCheckEquality } from './use-state-check-equality' +import { identifyUser, setUserProperty } from 'web/lib/service/analytics' export const useUser = () => { const [user, setUser] = useStateCheckEquality( @@ -21,11 +22,14 @@ export const useUser = () => { useEffect(() => listenForLogin(setUser), [setUser]) - const userId = user?.id - useEffect(() => { - if (userId) return listenForUser(userId, setUser) - }, [userId, setUser]) + if (user) { + identifyUser(user.id) + setUserProperty('username', user.username) + + return listenForUser(user.id, setUser) + } + }, [user, setUser]) return user } diff --git a/web/hooks/use-warn-unsaved-changes.ts b/web/hooks/use-warn-unsaved-changes.ts new file mode 100644 index 00000000..b871b8b2 --- /dev/null +++ b/web/hooks/use-warn-unsaved-changes.ts @@ -0,0 +1,31 @@ +import { Router } from 'next/router' +import { useEffect } from 'react' + +export const useWarnUnsavedChanges = (hasUnsavedChanges: boolean) => { + useEffect(() => { + if (!hasUnsavedChanges) return + + const confirmationMessage = 'Changes you made may not be saved.' + + const warnUnsavedChanges = (e: BeforeUnloadEvent) => { + const event = e || window.event + event.returnValue = confirmationMessage + return confirmationMessage + } + + const beforeRouteHandler = () => { + if (!confirm(confirmationMessage)) { + Router.events.emit('routeChangeError') + throw 'Abort route change. Please ignore this error.' + } + } + + window.addEventListener('beforeunload', warnUnsavedChanges) + Router.events.on('routeChangeStart', beforeRouteHandler) + + return () => { + window.removeEventListener('beforeunload', warnUnsavedChanges) + Router.events.off('routeChangeStart', beforeRouteHandler) + } + }, [hasUnsavedChanges]) +} diff --git a/web/lib/firebase/comments.ts b/web/lib/firebase/comments.ts index e96c080b..6be845cb 100644 --- a/web/lib/firebase/comments.ts +++ b/web/lib/firebase/comments.ts @@ -14,6 +14,7 @@ import { db } from './init' import { User } from 'common/user' import { Comment } from 'common/comment' import { removeUndefinedProps } from 'common/util/object' +import { track } from '@amplitude/analytics-browser' export type { Comment } @@ -43,6 +44,12 @@ export async function createComment( answerOutcome: answerOutcome, replyToCommentId: replyToCommentId, }) + track('comment', { + contractId, + commentId: ref.id, + betId: betId, + replyToCommentId: replyToCommentId, + }) return await setDoc(ref, comment) } diff --git a/web/lib/service/analytics.ts b/web/lib/service/analytics.ts new file mode 100644 index 00000000..3ac58055 --- /dev/null +++ b/web/lib/service/analytics.ts @@ -0,0 +1,42 @@ +import { + init, + track, + identify, + setUserId, + Identify, +} from '@amplitude/analytics-browser' + +import { ENV_CONFIG } from 'common/envs/constants' + +init(ENV_CONFIG.amplitudeApiKey ?? '', undefined, { includeReferrer: true }) + +export { track } + +// Convenience functions: + +export const trackCallback = + (eventName: string, eventProperties?: any) => () => { + track(eventName, eventProperties) + } + +export const withTracking = + ( + f: (() => void) | (() => Promise), + eventName: string, + eventProperties?: any + ) => + async () => { + const promise = f() + track(eventName, eventProperties) + await promise + } + +export async function identifyUser(userId: string) { + setUserId(userId) +} + +export async function setUserProperty(property: string, value: string) { + const identifyObj = new Identify() + identifyObj.set(property, value) + await identify(identifyObj) +} diff --git a/web/package.json b/web/package.json index 35b71341..4fc83ad0 100644 --- a/web/package.json +++ b/web/package.json @@ -17,6 +17,7 @@ "verify": "(cd .. && yarn verify)" }, "dependencies": { + "@amplitude/analytics-browser": "0.4.1", "@headlessui/react": "1.6.1", "@heroicons/react": "1.0.5", "@nivo/core": "0.74.0", @@ -37,7 +38,7 @@ "react-confetti": "6.0.1", "react-dom": "17.0.2", "react-expanding-textarea": "2.3.5", - "react-hot-toast": "^2.2.0", + "react-hot-toast": "2.2.0", "react-instantsearch-hooks-web": "6.24.1", "react-query": "3.39.0" }, diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index c52bc46f..ccef541c 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -40,6 +40,8 @@ import { useIsIframe } from 'web/hooks/use-is-iframe' import ContractEmbedPage from '../embed/[username]/[contractSlug]' import { useBets } from 'web/hooks/use-bets' import { CPMMBinaryContract } from 'common/contract' +import { AlertBox } from 'web/components/alert-box' +import { useTracking } from 'web/hooks/use-tracking' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { @@ -108,6 +110,12 @@ export function ContractPageContent( const contract = useContractWithPreload(props.contract) ?? props.contract + useTracking('view market', { + slug: contract.slug, + contractId: contract.id, + creatorId: contract.creatorId, + }) + const bets = useBets(contract.id) ?? props.bets // Sort for now to see if bug is fixed. comments.sort((c1, c2) => c1.createdTime - c2.createdTime) @@ -193,6 +201,12 @@ export function ContractPageContent( bets={bets} comments={comments ?? []} /> + {isNumeric && ( + + )} {outcomeType === 'FREE_RESPONSE' && ( <> diff --git a/web/pages/[username]/index.tsx b/web/pages/[username]/index.tsx index eaef8471..9bca8991 100644 --- a/web/pages/[username]/index.tsx +++ b/web/pages/[username]/index.tsx @@ -5,6 +5,7 @@ import { getUserByUsername, User } from 'web/lib/firebase/users' import { UserPage } from 'web/components/user-page' import { useUser } from 'web/hooks/use-user' import Custom404 from '../404' +import { useTracking } from 'web/hooks/use-tracking' export default function UserProfile(props: { tab?: 'markets' | 'comments' | 'bets' @@ -12,6 +13,7 @@ export default function UserProfile(props: { const router = useRouter() const [user, setUser] = useState('loading') const { username } = router.query as { username: string } + useEffect(() => { if (username) { getUserByUsername(username).then(setUser) @@ -20,6 +22,8 @@ export default function UserProfile(props: { const currentUser = useUser() + useTracking('view user profile', { username }) + if (user === 'loading') return <> return user ? ( diff --git a/web/pages/_app.tsx b/web/pages/_app.tsx index a833c933..d081bc9a 100644 --- a/web/pages/_app.tsx +++ b/web/pages/_app.tsx @@ -39,19 +39,6 @@ function MyApp({ Component, pageProps }: AppProps) { gtag('config', 'G-SSFK1Q138D'); `} - {/* Hotjar Tracking Code for https://manifold.markets */} - Manifold Markets — A market for every question diff --git a/web/pages/activity.tsx b/web/pages/activity.tsx deleted file mode 100644 index 5d1a0a18..00000000 --- a/web/pages/activity.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import React, { useEffect, useState } from 'react' -import Router, { useRouter } from 'next/router' -import { Page } from 'web/components/page' -import { ActivityFeed } from 'web/components/feed/activity-feed' -import { Spacer } from 'web/components/layout/spacer' -import { Col } from 'web/components/layout/col' -import { useUser } from 'web/hooks/use-user' -import { LoadingIndicator } from 'web/components/loading-indicator' -import { useAlgoFeed } from 'web/hooks/use-algo-feed' -import { ContractPageContent } from './[username]/[contractSlug]' -import { CategorySelector } from '../components/feed/category-selector' - -export default function Activity() { - const user = useUser() - const [category, setCategory] = useState('all') - - const feed = useAlgoFeed(user, category) - - const router = useRouter() - const { u: username, s: slug } = router.query - const contract = feed?.find( - ({ contract }) => contract.slug === slug - )?.contract - - useEffect(() => { - // If the page initially loads with query params, redirect to the contract page. - if (router.isReady && slug && username) { - Router.replace(`/${username}/${slug}`) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [router.isReady]) - - if (user === null) { - Router.replace('/') - return <> - } - - return ( - <> - - - - - {feed ? ( - - `activity?u=${c.creatorUsername}&s=${c.slug}` - } - /> - ) : ( - - )} - - - - {contract && ( - - )} - - ) -} diff --git a/web/pages/add-funds.tsx b/web/pages/add-funds.tsx index 67881a9f..f680d47b 100644 --- a/web/pages/add-funds.tsx +++ b/web/pages/add-funds.tsx @@ -6,6 +6,8 @@ import { FundsSelector } from 'web/components/yes-no-selector' import { useUser } from 'web/hooks/use-user' import { checkoutURL } from 'web/lib/service/stripe' import { Page } from 'web/components/page' +import { useTracking } from 'web/hooks/use-tracking' +import { trackCallback } from 'web/lib/service/analytics' export default function AddFundsPage() { const user = useUser() @@ -14,6 +16,8 @@ export default function AddFundsPage() { 2500 ) + useTracking('view add funds') + return ( Checkout diff --git a/web/pages/charity/index.tsx b/web/pages/charity/index.tsx index 81d74440..46201c3d 100644 --- a/web/pages/charity/index.tsx +++ b/web/pages/charity/index.tsx @@ -1,4 +1,12 @@ -import { mapValues, groupBy, sumBy, sum, sortBy, debounce } from 'lodash' +import { + mapValues, + groupBy, + sumBy, + sum, + sortBy, + debounce, + uniqBy, +} from 'lodash' import { useState, useMemo } from 'react' import { charities, Charity as CharityType } from 'common/charity' import { CharityCard } from 'web/components/charity/charity-card' @@ -8,7 +16,10 @@ import { Page } from 'web/components/page' import { SiteLink } from 'web/components/site-link' import { Title } from 'web/components/title' import { getAllCharityTxns } from 'web/lib/firebase/txns' -import { formatMoney } from 'common/util/format' +import { manaToUSD } from 'common/util/format' +import { quadraticMatches } from 'common/quadratic-funding' +import { Txn } from 'common/txn' +import { useTracking } from 'web/hooks/use-tracking' export async function getStaticProps() { const txns = await getAllCharityTxns() @@ -20,21 +31,55 @@ export async function getStaticProps() { (charity) => (charity.tags?.includes('Featured') ? 0 : 1), (charity) => -totals[charity.id], ]) + const matches = quadraticMatches(txns, totalRaised) + const numDonors = uniqBy(txns, (txn) => txn.fromId).length return { props: { totalRaised, charities: sortedCharities, + matches, + txns, + numDonors, }, revalidate: 60, } } +type Stat = { + name: string + stat: string +} + +function DonatedStats(props: { stats: Stat[] }) { + const { stats } = props + return ( +
+ {stats.map((item) => ( +
+
+ {item.name} +
+
+ {item.stat} +
+
+ ))} +
+ ) +} + export default function Charity(props: { totalRaised: number charities: CharityType[] + matches: { [charityId: string]: number } + txns: Txn[] + numDonors: number }) { - const { totalRaised, charities } = props + const { totalRaised, charities, matches, numDonors } = props const [query, setQuery] = useState('') const debouncedQuery = debounce(setQuery, 50) @@ -51,29 +96,56 @@ export default function Charity(props: { [charities, query] ) + useTracking('view charity') + return ( - + - <div className="mb-6 text-gray-500"> - Donate your winnings to charity! Every {formatMoney(100)} you give - turns into $1 USD we send to your chosen charity. - <Spacer h={5} /> - Together we've donated over ${Math.floor(totalRaised / 100)} USD so - far! - </div> + <span className="text-gray-600"> + Through July 15, up to $25k of donations will be matched via{' '} + <SiteLink href="https://wtfisqf.com/" className="font-bold"> + quadratic funding + </SiteLink> + , courtesy of{' '} + <SiteLink href="https://ftxfuturefund.org/" className="font-bold"> + the FTX Future Fund + </SiteLink> + ! + </span> + <DonatedStats + stats={[ + { + name: 'Raised by Manifold users', + stat: manaToUSD(totalRaised), + }, + { + name: 'Number of donors', + stat: `${numDonors}`, + }, + { + name: 'Matched via quadratic funding', + stat: manaToUSD(sum(Object.values(matches))), + }, + ]} + /> + <Spacer h={10} /> <input type="text" onChange={(e) => debouncedQuery(e.target.value)} - placeholder="Search charities" + placeholder="Find a charity" className="input input-bordered mb-6 w-full" /> </Col> <div className="grid max-w-xl grid-flow-row grid-cols-1 gap-4 lg:max-w-full lg:grid-cols-2 xl:grid-cols-3"> {filterCharities.map((charity) => ( - <CharityCard charity={charity} key={charity.name} /> + <CharityCard + charity={charity} + key={charity.name} + match={matches[charity.id]} + /> ))} </div> {filterCharities.length === 0 && ( @@ -82,32 +154,28 @@ export default function Charity(props: { </div> )} - <iframe - height="405" - src="https://manifold.markets/embed/ManifoldMarkets/total-donations-for-manifold-for-go" - title="Total donations for Manifold for Charity this May (in USD)" - frameBorder="0" - className="m-10 w-full rounded-xl bg-white p-10" - ></iframe> + <div className="mt-10 w-full rounded-xl bg-gradient-to-r from-pink-300 via-purple-300 to-indigo-400 p-5"> + <iframe + height="405" + src="https://manifold.markets/ManifoldMarkets/how-much-will-be-donated-through-ma" + title="Total donations for Manifold for Charity this May (in USD)" + frameBorder="0" + className="w-full rounded-xl bg-white p-10" + ></iframe> + </div> <div className="mt-10 text-gray-500"> - Don't see your favorite charity? Recommend it{' '} - <SiteLink - href="https://manifold.markets/Sinclair/which-charities-should-manifold-add" - className="text-indigo-700" - > - here - </SiteLink> - ! + <span className="font-semibold">Notes</span> <br /> + - Don't see your favorite charity? Recommend it by emailing + charity@manifold.markets! <br /> - <span className="italic"> - Note: Manifold is not affiliated with non-Featured charities; we're - just fans of their work! - <br /> - As Manifold is a for-profit entity, your contributions will not be - tax deductible. - </span> + - Manifold is not affiliated with non-Featured charities; we're just + fans of their work. + <br /> + - As Manifold itself is a for-profit entity, your contributions will + not be tax deductible. + <br />- Donations + matches are wired once each quarter. </div> </Col> </Page> diff --git a/web/pages/create.tsx b/web/pages/create.tsx index 68393081..97f79295 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -21,10 +21,15 @@ import { useHasCreatedContractToday } from 'web/hooks/use-has-created-contract-t import { removeUndefinedProps } from 'common/util/object' import { CATEGORIES } from 'common/categories' import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' +import { track } from 'web/lib/service/analytics' +import { useTracking } from 'web/hooks/use-tracking' +import { useWarnUnsavedChanges } from 'web/hooks/use-warn-unsaved-changes' export default function Create() { const [question, setQuestion] = useState('') + useTracking('view create page') + return ( <Page> <div className="mx-auto w-full max-w-2xl"> @@ -77,6 +82,9 @@ export function NewContract(props: { question: string }) { const [ante, _setAnte] = useState(FIXED_ANTE) const mustWaitForDailyFreeMarketStatus = useHasCreatedContractToday(creator) + const isFree = + mustWaitForDailyFreeMarketStatus != 'loading' && + !mustWaitForDailyFreeMarketStatus // useEffect(() => { // if (ante === null && creator) { @@ -104,6 +112,9 @@ export function NewContract(props: { question: string }) { // get days from today until the end of this year: const daysLeftInTheYear = dayjs().endOf('year').diff(dayjs(), 'day') + const hasUnsavedChanges = !isSubmitting && Boolean(question || description) + useWarnUnsavedChanges(hasUnsavedChanges) + const isValid = (outcomeType === 'BINARY' ? initialProb >= 5 && initialProb <= 95 : true) && question.length > 0 && @@ -149,6 +160,14 @@ export function NewContract(props: { question: string }) { max, }) ) + + track('create market', { + slug: result.slug, + initialProb, + category, + isFree, + }) + await router.push(contractPath(result as Contract)) } catch (e) { console.log('error creating contract', e) diff --git a/web/pages/home.tsx b/web/pages/home.tsx index ccf9a585..14e6cf2b 100644 --- a/web/pages/home.tsx +++ b/web/pages/home.tsx @@ -9,6 +9,8 @@ import { ContractSearch } from 'web/components/contract-search' import { Contract } from 'common/contract' import { ContractPageContent } from './[username]/[contractSlug]' import { getContractFromSlug } from 'web/lib/firebase/contracts' +import { useTracking } from 'web/hooks/use-tracking' +import { track } from 'web/lib/service/analytics' const Home = () => { const user = useUser() @@ -16,6 +18,8 @@ const Home = () => { const router = useRouter() + useTracking('view home') + if (user === null) { Router.replace('/') return <></> @@ -42,7 +46,10 @@ const Home = () => { <button type="button" className="fixed bottom-[70px] right-3 inline-flex items-center rounded-full border border-transparent bg-indigo-600 p-3 text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 lg:hidden" - onClick={() => router.push('/create')} + onClick={() => { + router.push('/create') + track('mobile create button') + }} > <PlusSmIcon className="h-8 w-8" aria-hidden="true" /> </button> @@ -68,7 +75,7 @@ const useContractPage = () => { const [contract, setContract] = useState<Contract | undefined>() useEffect(() => { - const onBack = () => { + const updateContract = () => { const path = location.pathname.split('/').slice(1) if (path[0] === 'home') setContract(undefined) else { @@ -80,23 +87,24 @@ const useContractPage = () => { } } } - window.addEventListener('popstate', onBack) - // Hack. Listen to changes in href to clear contract on navigate home. - let href = document.location.href - const observer = new MutationObserver(function (_mutations) { - if (href != document.location.href) { - href = document.location.href + const { pushState, replaceState } = window.history - const path = location.pathname.split('/').slice(1) - if (path[0] === 'home') setContract(undefined) - } - }) - observer.observe(document, { subtree: true, childList: true }) + window.history.pushState = function () { + // eslint-disable-next-line prefer-rest-params + pushState.apply(history, arguments as any) + updateContract() + } + + window.history.replaceState = function () { + // eslint-disable-next-line prefer-rest-params + replaceState.apply(history, arguments as any) + updateContract() + } return () => { - window.removeEventListener('popstate', onBack) - observer.disconnect() + window.history.pushState = pushState + window.history.replaceState = replaceState } }, []) diff --git a/web/pages/index.tsx b/web/pages/index.tsx index 886ccd7c..904fc014 100644 --- a/web/pages/index.tsx +++ b/web/pages/index.tsx @@ -11,16 +11,16 @@ import { ManifoldLogo } from 'web/components/nav/manifold-logo' export async function getStaticProps() { // These hardcoded markets will be shown in the frontpage for signed-out users: const hotContracts = await getContractsBySlugs([ - 'if-boris-johnson-is-leader-of-the-c', - 'will-ethereum-merge-to-proofofstake', + 'will-max-go-to-prom-with-a-girl', + 'will-ethereum-switch-to-proof-of-st', 'will-russia-control-the-majority-of', 'will-elon-musk-buy-twitter-this-yea', - 'will-an-ai-get-gold-on-any-internat', - 'how-many-us-supreme-court-justices', + 'will-trump-be-charged-by-the-grand', + 'will-spacex-launch-a-starship-into', 'who-will-win-the-nba-finals-champio', - 'what-database-will-manifold-be-prim', - 'will-the-supreme-court-leakers-iden', - 'will-over-25-of-participants-in-the-163d54309e43', + 'who-will-be-time-magazine-person-of', + 'will-congress-hold-any-hearings-abo-e21f987033b3', + 'will-at-least-10-world-cities-have', ]) return { diff --git a/web/pages/leaderboards.tsx b/web/pages/leaderboards.tsx index 1d76ec45..735a00f9 100644 --- a/web/pages/leaderboards.tsx +++ b/web/pages/leaderboards.tsx @@ -4,6 +4,7 @@ import { Page } from 'web/components/page' import { getTopCreators, getTopTraders, User } from 'web/lib/firebase/users' import { formatMoney } from 'common/util/format' import { fromPropz, usePropz } from 'web/hooks/use-propz' +import { useTracking } from 'web/hooks/use-tracking' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz() { @@ -32,6 +33,8 @@ export default function Leaderboards(props: { } const { topTraders, topCreators } = props + useTracking('view leaderboards') + return ( <Page margin> <Col className="items-center gap-10 lg:flex-row"> diff --git a/web/pages/stats.tsx b/web/pages/stats.tsx index e7590e98..401749b6 100644 --- a/web/pages/stats.tsx +++ b/web/pages/stats.tsx @@ -16,6 +16,7 @@ import { getDailyContracts } from 'web/lib/firebase/contracts' import { getDailyNewUsers } from 'web/lib/firebase/users' import { SiteLink } from 'web/components/site-link' import { Linkify } from 'web/components/linkify' +import { average } from 'common/util/math' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz() { @@ -61,7 +62,7 @@ export async function getStaticPropz() { }) const monthlyActiveUsers = dailyUserIds.map((_, i) => { - const start = Math.max(0, i - 30) + const start = Math.max(0, i - 29) const end = i const uniques = new Set<string>() for (let j = start; j <= end; j++) @@ -166,16 +167,12 @@ export async function getStaticPropz() { const weeklyTopTenthActions = dailyTopTenthActions.map((_, i) => { const start = Math.max(0, i - 6) const end = i - const total = sum(dailyTopTenthActions.slice(start, end)) - if (end - start < 7) return (total * 7) / (end - start) - return total + return average(dailyTopTenthActions.slice(start, end)) }) const monthlyTopTenthActions = dailyTopTenthActions.map((_, i) => { const start = Math.max(0, i - 29) const end = i - const total = sum(dailyTopTenthActions.slice(start, end)) - if (end - start < 30) return (total * 30) / (end - start) - return total + return average(dailyTopTenthActions.slice(start, end)) }) // Total mana divided by 100. @@ -193,7 +190,8 @@ export async function getStaticPropz() { const start = Math.max(0, i - 29) const end = i const total = sum(dailyManaBet.slice(start, end)) - if (end - start < 30) return (total * 30) / (end - start) + const range = end - start + 1 + if (range < 30) return (total * 30) / range return total }) diff --git a/web/public/flappy-logo.gif b/web/public/flappy-logo.gif index 0ef936a4..7d0529ec 100644 Binary files a/web/public/flappy-logo.gif and b/web/public/flappy-logo.gif differ diff --git a/yarn.lock b/yarn.lock index 17e11464..abf8f12a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -227,6 +227,66 @@ "@algolia/logger-common" "4.13.1" "@algolia/requester-common" "4.13.1" +"@amplitude/analytics-browser@0.4.1": + version "0.4.1" + resolved "https://registry.yarnpkg.com/@amplitude/analytics-browser/-/analytics-browser-0.4.1.tgz#d686ab89fb12cdb3ba6aaade87b8d4bb8f72e86f" + integrity sha512-omiUvv2v+sznKjFj5s4vBoVkfqsEAFqW1FQUpZuWpaekOb4/n5zhTAzs2NQMq1hFxmIh9DxqM4wY0y347CFBHg== + dependencies: + "@amplitude/analytics-core" "^0.3.1" + "@amplitude/analytics-types" "^0.2.1" + "@amplitude/ua-parser-js" "^0.7.26" + tslib "^2.3.1" + +"@amplitude/analytics-core@^0.3.1": + version "0.3.1" + resolved "https://registry.yarnpkg.com/@amplitude/analytics-core/-/analytics-core-0.3.1.tgz#e86eb8e5cbab06063f04b7cf9f41a238d9a124b8" + integrity sha512-BgfSE49GXyYQtqL0E6xQhw9VcaYaAOgqAedyHB1VsvgVQUXEv8z1GM6GhGZVqCA5afwtq6fu80p2iqGTGuSP+g== + dependencies: + "@amplitude/analytics-types" "^0.2.1" + tslib "^2.3.1" + +"@amplitude/analytics-types@^0.2.1": + version "0.2.1" + resolved "https://registry.yarnpkg.com/@amplitude/analytics-types/-/analytics-types-0.2.1.tgz#ab4f9b4bbec8afa768a27af78f75bb69dd2a3fc7" + integrity sha512-+FfXlCjHysYWliRBjD2wQ2gZ4V6jGKskdt9j8npv9Rmzdehj04OAHudZ/UrgH5++88As9ww1wyaS7y4sm4x8vA== + +"@amplitude/identify@^1.10.0": + version "1.10.0" + resolved "https://registry.yarnpkg.com/@amplitude/identify/-/identify-1.10.0.tgz#d62b8b6785c29350c368810475a6fc7b04985210" + integrity sha512-BshMDcZX9qO4mgGBR45HmiHxfcPCDY/eBOE/MTUZBW+y9+N61aKmNY3YJsAUfRPzieDiyfqs8rNm7quVkaNzJQ== + dependencies: + "@amplitude/types" "^1.10.0" + "@amplitude/utils" "^1.10.0" + tslib "^1.9.3" + +"@amplitude/node@^1.10.0": + version "1.10.0" + resolved "https://registry.yarnpkg.com/@amplitude/node/-/node-1.10.0.tgz#33f84ddf82b31471fce53e6fa60b688d4bc62ee4" + integrity sha512-Jh8w1UpxhonWe0kCALVvqiBE3vo5NYmbNZbZrrI9Lfa/1HbGboZlGdg0I7/WtihbZvEjpfcfTOf8OkmtZh6vsQ== + dependencies: + "@amplitude/identify" "^1.10.0" + "@amplitude/types" "^1.10.0" + "@amplitude/utils" "^1.10.0" + tslib "^1.9.3" + +"@amplitude/types@^1.10.0": + version "1.10.0" + resolved "https://registry.yarnpkg.com/@amplitude/types/-/types-1.10.0.tgz#dfaf7cc25f533a1e2b0ef0ad675371b396733c0f" + integrity sha512-xN0gnhutztv6kqHaZ2bre18anQV5GDmMXOeipTvI670g2VjNbPfOzMwu1LN4p1NadYq+GqYI223UcZrXR+R4Pw== + +"@amplitude/ua-parser-js@^0.7.26": + version "0.7.31" + resolved "https://registry.yarnpkg.com/@amplitude/ua-parser-js/-/ua-parser-js-0.7.31.tgz#749bf7cb633cfcc7ff3c10805bad7c5f6fbdbc61" + integrity sha512-+z8UGRaj13Pt5NDzOnkTBy49HE2CX64jeL0ArB86HAtilpnfkPB7oqkigN7Lf2LxscMg4QhFD7mmCfedh3rqTg== + +"@amplitude/utils@^1.10.0": + version "1.10.0" + resolved "https://registry.yarnpkg.com/@amplitude/utils/-/utils-1.10.0.tgz#138b0ba4e5755540a9e4abf426b7a25d045418a9" + integrity sha512-/R8j8IzFH0GYfA6ehQDm5IEzt71gIeMdiYYFIzZp6grERQlgJcwNJMAiza0o2JwwTDIruzqdB3c/vLVjuakp+w== + dependencies: + "@amplitude/types" "^1.10.0" + tslib "^1.9.3" + "@ampproject/remapping@^2.1.0": version "2.2.0" resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.2.0.tgz#56c133824780de3174aed5ab6834f3026790154d" @@ -9294,7 +9354,7 @@ react-helmet-async@*, react-helmet-async@^1.2.3: react-fast-compare "^3.2.0" shallowequal "^1.1.0" -react-hot-toast@^2.2.0: +react-hot-toast@2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/react-hot-toast/-/react-hot-toast-2.2.0.tgz#ab6f4caed4214b9534f94bb8cfaaf21b051e62b9" integrity sha512-248rXw13uhf/6TNDVzagX+y7R8J183rp7MwUMNkcrBRyHj/jWOggfXTGlM8zAOuh701WyVW+eUaWG2LeSufX9g== @@ -10617,7 +10677,7 @@ tsconfig-paths@^3.14.1: minimist "^1.2.6" strip-bom "^3.0.0" -tslib@^1.8.1, tslib@^1.9.0: +tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==