Merge branch 'main' into limit-orders

This commit is contained in:
James Grugett 2022-06-28 11:02:12 -05:00
commit 53e2ff7327
87 changed files with 2968 additions and 1190 deletions

View File

@ -52,4 +52,4 @@ jobs:
- name: Run Typescript checker on cloud functions
if: ${{ success() || failure() }}
working-directory: functions
run: tsc --pretty --project tsconfig.json --noEmit
run: tsc -b -v --pretty

2
.gitignore vendored
View File

@ -3,3 +3,5 @@
.vercel
node_modules
yarn-error.log
firebase-debug.log

View File

@ -6,7 +6,8 @@
"recommendations": [
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"toba.vsfire"
"toba.vsfire",
"bradlc.vscode-tailwindcss"
],
// List of extensions recommended by VS Code that should not be recommended for users of this workspace.
"unwantedRecommendations": []

5
common/.gitignore vendored
View File

@ -1,6 +1,5 @@
# Compiled JavaScript files
lib/**/*.js
lib/**/*.js.map
lib/
# TypeScript v1 declaration files
typings/
@ -10,4 +9,4 @@ node_modules/
package-lock.json
ui-debug.log
firebase-debug.log
firebase-debug.log

View File

@ -1,6 +1,5 @@
import { Answer } from './answer'
import { Fees } from './fees'
import { GroupDetails } from 'common/group'
export type AnyMechanism = DPM | CPMM
export type AnyOutcomeType = Binary | FreeResponse | Numeric
@ -25,8 +24,6 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
lowercaseTags: string[]
visibility: 'public' | 'unlisted'
groupDetails?: GroupDetails[] // Starting with one group per contract
createdTime: number // Milliseconds since epoch
lastUpdatedTime?: number // Updated on new bet or comment
lastBetTime?: number
@ -36,6 +33,7 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
isResolved: boolean
resolutionTime?: number // When the contract creator resolved the market
resolution?: string
resolutionProbability?: number,
closeEmailsSent?: number

View File

@ -12,12 +12,7 @@ export const DEV_CONFIG: EnvConfig = {
appId: '1:134303100058:web:27f9ea8b83347251f80323',
measurementId: 'G-YJC9E37P37',
},
functionEndpoints: {
placebet: 'https://placebet-w3txbmd3ba-uc.a.run.app',
sellshares: 'https://sellshares-w3txbmd3ba-uc.a.run.app',
sellbet: 'https://sellbet-w3txbmd3ba-uc.a.run.app',
createmarket: 'https://createmarket-w3txbmd3ba-uc.a.run.app',
creategroup: 'https://creategroup-w3txbmd3ba-uc.a.run.app',
},
cloudRunId: 'w3txbmd3ba',
cloudRunRegion: 'uc',
amplitudeApiKey: 'fd8cbfd964b9a205b8678a39faae71b3',
}

View File

@ -1,16 +1,13 @@
export type V2CloudFunction =
| 'placebet'
| 'sellbet'
| 'sellshares'
| 'createmarket'
| 'creategroup'
export type EnvConfig = {
domain: string
firebaseConfig: FirebaseConfig
functionEndpoints: Record<V2CloudFunction, string>
amplitudeApiKey?: string
// IDs for v2 cloud functions -- find these by deploying a cloud function and
// examining the URL, https://[name]-[cloudRunId]-[cloudRunRegion].a.run.app
cloudRunId: string
cloudRunRegion: string
// Access controls
adminEmails: string[]
whitelistEmail?: string // e.g. '@theoremone.co'. If not provided, all emails are whitelisted
@ -48,13 +45,8 @@ export const PROD_CONFIG: EnvConfig = {
appId: '1:128925704902:web:f61f86944d8ffa2a642dc7',
measurementId: 'G-SSFK1Q138D',
},
functionEndpoints: {
placebet: 'https://placebet-nggbo3neva-uc.a.run.app',
sellshares: 'https://sellshares-nggbo3neva-uc.a.run.app',
sellbet: 'https://sellbet-nggbo3neva-uc.a.run.app',
createmarket: 'https://createmarket-nggbo3neva-uc.a.run.app',
creategroup: 'https://creategroup-nggbo3neva-uc.a.run.app',
},
cloudRunId: 'nggbo3neva',
cloudRunRegion: 'uc',
adminEmails: [
'akrolsmir@gmail.com', // Austin
'jahooma@gmail.com', // James

View File

@ -12,14 +12,8 @@ export const THEOREMONE_CONFIG: EnvConfig = {
appId: '1:698012149198:web:b342af75662831aa84b79f',
measurementId: 'G-Y3EZ1WNT6E',
},
// TODO: fill in real endpoints for T1
functionEndpoints: {
placebet: 'https://placebet-nggbo3neva-uc.a.run.app',
sellshares: 'https://sellshares-nggbo3neva-uc.a.run.app',
sellbet: 'https://sellbet-nggbo3neva-uc.a.run.app',
createmarket: 'https://createmarket-nggbo3neva-uc.a.run.app',
creategroup: 'https://creategroup-nggbo3neva-uc.a.run.app',
},
cloudRunId: 'nggbo3neva', // TODO: fill in real ID for T1
cloudRunRegion: 'uc',
adminEmails: [...PROD_CONFIG.adminEmails, 'david.glidden@theoremone.co'],
whitelistEmail: '@theoremone.co',
moneyMoniker: 'T$',

View File

@ -13,9 +13,3 @@ export type Group = {
export const MAX_GROUP_NAME_LENGTH = 75
export const MAX_ABOUT_LENGTH = 140
export const MAX_ID_LENGTH = 60
export type GroupDetails = {
groupId: string
groupSlug: string
groupName: string
}

35
common/manalink.ts Normal file
View File

@ -0,0 +1,35 @@
export type Manalink = {
// The link to send: https://manifold.markets/send/{slug}
// Also functions as the unique id for the link.
slug: string
// Note: we assume both fromId and toId are of SourceType 'USER'
fromId: string
// Displayed to people claiming the link
message: string
// How much to send with the link
amount: number
token: 'M$' // TODO: could send eg YES shares too??
createdTime: number
// If null, the link is valid forever
expiresTime: number | null
// If null, the link can be used infinitely
maxUses: number | null
// Used for simpler caching
claimedUserIds: string[]
// Successful redemptions of the link
claims: Claim[]
}
export type Claim = {
toId: string
// The ID of the successful txn that tracks the money moved
txnId: string
claimedTime: number
}

View File

@ -11,7 +11,6 @@ import {
import { User } from './user'
import { parseTags } from './util/parse'
import { removeUndefinedProps } from './util/object'
import { GroupDetails } from 'common/group'
export function getNewContract(
id: string,
@ -28,8 +27,7 @@ export function getNewContract(
// used for numeric markets
bucketCount: number,
min: number,
max: number,
groupDetails?: GroupDetails
max: number
) {
const tags = parseTags(
`${question} ${description} ${extraTags.map((tag) => `#${tag}`).join(' ')}`
@ -71,7 +69,6 @@ export function getNewContract(
liquidityFee: 0,
platformFee: 0,
},
groupDetails: groupDetails ? [groupDetails] : undefined,
})
return contract as Contract

23
common/stats.ts Normal file
View File

@ -0,0 +1,23 @@
export type Stats = {
startDate: number
dailyActiveUsers: number[]
weeklyActiveUsers: number[]
monthlyActiveUsers: number[]
dailyBetCounts: number[]
dailyContractCounts: number[]
dailyCommentCounts: number[]
dailySignups: number[]
weekOnWeekRetention: number[]
monthlyRetention: number[]
weeklyActivationRate: number[]
topTenthActions: {
daily: number[]
weekly: number[]
monthly: number[]
}
manaBet: {
daily: number[]
weekly: number[]
monthly: number[]
}
}

View File

@ -1,6 +1,8 @@
{
"compilerOptions": {
"baseUrl": "../",
"composite": true,
"module": "commonjs",
"moduleResolution": "node",
"noImplicitReturns": true,
"outDir": "lib",

View File

@ -1,6 +1,6 @@
// A txn (pronounced "texan") respresents a payment between two ids on Manifold
// Shortened from "transaction" to distinguish from Firebase transactions (and save chars)
type AnyTxnType = Donation | Tip
type AnyTxnType = Donation | Tip | Manalink
type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK'
export type Txn<T extends AnyTxnType = AnyTxnType> = {
@ -16,6 +16,7 @@ export type Txn<T extends AnyTxnType = AnyTxnType> = {
amount: number
token: 'M$' // | 'USD' | MarketOutcome
category: 'CHARITY' | 'MANALINK' | 'TIP' // | 'BET'
// Any extra data
data?: { [key: string]: any }
@ -39,5 +40,12 @@ type Tip = {
}
}
type Manalink = {
fromType: 'USER'
toType: 'USER'
category: 'MANALINK'
}
export type DonationTxn = Txn & Donation
export type TipTxn = Txn & Tip
export type ManalinkTxn = Txn & Manalink

View File

@ -106,7 +106,7 @@ Requires no authorization.
// A list of tags on each market. Any user can add tags to any market.
// This list also includes the predefined categories shown as filters on the home page.
tags: string[]
// Note: This url always points to https://manifold.markets, regardless of what instance the api is running on.
// This url includes the creator's username, but this doesn't need to be correct when constructing valid URLs.
// i.e. https://manifold.markets/Austin/test-market is the same as https://manifold.markets/foo/test-market
@ -115,10 +115,10 @@ Requires no authorization.
outcomeType: string // BINARY, FREE_RESPONSE, or NUMERIC
mechanism: string // dpm-2 or cpmm-1
pool: number // sum of YES and NO shares in liquidity pool for CPMM, null for DPM
probability: number
p?: number // probability constant in y^p * n^(1-p) = k
totalLiquidity?: number
pool: { outcome: number } // For CPMM markets, the number of shares in the liquidity pool. For DPM markets, the amount of mana invested in each answer.
p?: number // CPMM markets only, probability constant in y^p * n^(1-p) = k
totalLiquidity?: number // CPMM markets only, the amount of mana deposited into the liquidity pool
volume: number
volume7Days: number
@ -127,6 +127,7 @@ Requires no authorization.
isResolved: boolean
resolutionTime?: number
resolution?: string
resolutionProbability?: number // Used for BINARY markets resolved to MKT
}
```
@ -146,202 +147,204 @@ Requires no authorization.
```json
{
"id":"lEoqtnDgJzft6apSKzYK",
"creatorUsername":"Angela",
"creatorName":"Angela",
"createdTime":1655258914863,
"creatorAvatarUrl":"https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476",
"closeTime":1655265001448,
"question":"What is good?",
"description":"Resolves proportionally to the answer(s) which I find most compelling. (Obviously Ill refrain from giving my own answers)\n\n(Please have at it with philosophy, ethics, etc etc)\n\n\nContract resolved automatically.",
"tags":[],
"url":"https://manifold.markets/Angela/what-is-good",
"pool":null,
"outcomeType":"FREE_RESPONSE",
"mechanism":"dpm-2",
"volume":112,
"volume7Days":212,
"volume24Hours":0,
"isResolved":true,
"resolution":"MKT",
"resolutionTime":1655265001448,
"answers":[
"id": "lEoqtnDgJzft6apSKzYK",
"creatorUsername": "Angela",
"creatorName": "Angela",
"createdTime": 1655258914863,
"creatorAvatarUrl": "https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476",
"closeTime": 1655265001448,
"question": "What is good?",
"description": "Resolves proportionally to the answer(s) which I find most compelling. (Obviously Ill refrain from giving my own answers)\n\n(Please have at it with philosophy, ethics, etc etc)\n\n\nContract resolved automatically.",
"tags": [],
"url": "https://manifold.markets/Angela/what-is-good",
"pool": null,
"outcomeType": "FREE_RESPONSE",
"mechanism": "dpm-2",
"volume": 112,
"volume7Days": 212,
"volume24Hours": 0,
"isResolved": true,
"resolution": "MKT",
"resolutionTime": 1655265001448,
"answers": [
{
"createdTime":1655258941573,
"avatarUrl":"https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476",
"id":"1",
"username":"Angela",
"number":1,
"name":"Angela",
"contractId":"lEoqtnDgJzft6apSKzYK",
"text":"ANTE",
"userId":"qe2QqIlOkeWsbljfeF3MsxpSJ9i2",
"probability":0.66749733001068
"createdTime": 1655258941573,
"avatarUrl": "https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476",
"id": "1",
"username": "Angela",
"number": 1,
"name": "Angela",
"contractId": "lEoqtnDgJzft6apSKzYK",
"text": "ANTE",
"userId": "qe2QqIlOkeWsbljfeF3MsxpSJ9i2",
"probability": 0.66749733001068
},
{
"name":"Isaac King",
"username":"IsaacKing",
"text":"This answer",
"userId":"y1hb6k7txdZPV5mgyxPFApZ7nQl2",
"id":"2",
"number":2,
"avatarUrl":"https://lh3.googleusercontent.com/a-/AOh14GhNVriOvxK2VUAmE-jvYZwC-XIymatzVirT0Bqb2g=s96-c",
"contractId":"lEoqtnDgJzft6apSKzYK",
"createdTime":1655261198074,
"probability":0.008922214311142757
"name": "Isaac King",
"username": "IsaacKing",
"text": "This answer",
"userId": "y1hb6k7txdZPV5mgyxPFApZ7nQl2",
"id": "2",
"number": 2,
"avatarUrl": "https://lh3.googleusercontent.com/a-/AOh14GhNVriOvxK2VUAmE-jvYZwC-XIymatzVirT0Bqb2g=s96-c",
"contractId": "lEoqtnDgJzft6apSKzYK",
"createdTime": 1655261198074,
"probability": 0.008922214311142757
},
{
"createdTime":1655263226587,
"userId":"jbgplxty4kUKIa1MmgZk22byJq03",
"id":"3",
"avatarUrl":"https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FMartin%2Fgiphy.gif?alt=media&token=422ef610-553f-47e3-bf6f-c0c5cc16c70a",
"text":"Toyota Camry",
"contractId":"lEoqtnDgJzft6apSKzYK",
"name":"Undox",
"username":"Undox",
"number":3,
"probability":0.008966714133143469
"createdTime": 1655263226587,
"userId": "jbgplxty4kUKIa1MmgZk22byJq03",
"id": "3",
"avatarUrl": "https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FMartin%2Fgiphy.gif?alt=media&token=422ef610-553f-47e3-bf6f-c0c5cc16c70a",
"text": "Toyota Camry",
"contractId": "lEoqtnDgJzft6apSKzYK",
"name": "Undox",
"username": "Undox",
"number": 3,
"probability": 0.008966714133143469
},
{
"number":4,
"name":"James Grugett",
"userId":"5LZ4LgYuySdL1huCWe7bti02ghx2",
"text":"Utility (Defined by your personal utility function.)",
"createdTime":1655264793224,
"contractId":"lEoqtnDgJzft6apSKzYK",
"username":"JamesGrugett",
"id":"4",
"avatarUrl":"https://lh3.googleusercontent.com/a-/AOh14GjC83uMe-fEfzd6QvxiK6ZqZdlMytuHxevgMYIkpAI=s96-c",
"probability":0.09211463154147384
"number": 4,
"name": "James Grugett",
"userId": "5LZ4LgYuySdL1huCWe7bti02ghx2",
"text": "Utility (Defined by your personal utility function.)",
"createdTime": 1655264793224,
"contractId": "lEoqtnDgJzft6apSKzYK",
"username": "JamesGrugett",
"id": "4",
"avatarUrl": "https://lh3.googleusercontent.com/a-/AOh14GjC83uMe-fEfzd6QvxiK6ZqZdlMytuHxevgMYIkpAI=s96-c",
"probability": 0.09211463154147384
}
],
"comments":[
"comments": [
{
"id":"ZdHIyfQazHyl8nI0ENS7",
"userId":"qe2QqIlOkeWsbljfeF3MsxpSJ9i2",
"createdTime":1655265807433,
"text":"ok what\ni did not resolve this intentionally",
"contractId":"lEoqtnDgJzft6apSKzYK",
"userName":"Angela",
"userAvatarUrl":"https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476",
"userUsername":"Angela"
"id": "ZdHIyfQazHyl8nI0ENS7",
"userId": "qe2QqIlOkeWsbljfeF3MsxpSJ9i2",
"createdTime": 1655265807433,
"text": "ok what\ni did not resolve this intentionally",
"contractId": "lEoqtnDgJzft6apSKzYK",
"userName": "Angela",
"userAvatarUrl": "https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476",
"userUsername": "Angela"
},
{
"userName":"James Grugett",
"userUsername":"JamesGrugett",
"id":"F7fvHGhTiFal8uTsUc9P",
"userAvatarUrl":"https://lh3.googleusercontent.com/a-/AOh14GjC83uMe-fEfzd6QvxiK6ZqZdlMytuHxevgMYIkpAI=s96-c","replyToCommentId":"ZdHIyfQazHyl8nI0ENS7",
"text":"@Angela Sorry! There was an error that automatically resolved several markets that were created in the last few hours.",
"createdTime":1655266286514,
"userId":"5LZ4LgYuySdL1huCWe7bti02ghx2",
"contractId":"lEoqtnDgJzft6apSKzYK"
"userName": "James Grugett",
"userUsername": "JamesGrugett",
"id": "F7fvHGhTiFal8uTsUc9P",
"userAvatarUrl": "https://lh3.googleusercontent.com/a-/AOh14GjC83uMe-fEfzd6QvxiK6ZqZdlMytuHxevgMYIkpAI=s96-c",
"replyToCommentId": "ZdHIyfQazHyl8nI0ENS7",
"text": "@Angela Sorry! There was an error that automatically resolved several markets that were created in the last few hours.",
"createdTime": 1655266286514,
"userId": "5LZ4LgYuySdL1huCWe7bti02ghx2",
"contractId": "lEoqtnDgJzft6apSKzYK"
},
{
"userId":"qe2QqIlOkeWsbljfeF3MsxpSJ9i2",
"contractId":"lEoqtnDgJzft6apSKzYK",
"id":"PIHhXy5hLHSgW8uoUD0Q",
"userName":"Angela",
"text":"lmk if anyone lost manna from this situation and i'll try to fix it",
"userUsername":"Angela",
"createdTime":1655277581308,
"userAvatarUrl":"https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476"
},{
"userAvatarUrl":"https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476",
"userName":"Angela",
"text":"from my end it looks like no one did",
"replyToCommentId":"PIHhXy5hLHSgW8uoUD0Q",
"createdTime":1655287149528,
"userUsername":"Angela",
"id":"5slnWEQWwm6dHjDi6oiH",
"contractId":"lEoqtnDgJzft6apSKzYK",
"userId":"qe2QqIlOkeWsbljfeF3MsxpSJ9i2"
"userId": "qe2QqIlOkeWsbljfeF3MsxpSJ9i2",
"contractId": "lEoqtnDgJzft6apSKzYK",
"id": "PIHhXy5hLHSgW8uoUD0Q",
"userName": "Angela",
"text": "lmk if anyone lost manna from this situation and i'll try to fix it",
"userUsername": "Angela",
"createdTime": 1655277581308,
"userAvatarUrl": "https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476"
},
{
"userAvatarUrl": "https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FAngela%2F50463444807_edfd4598d6_o.jpeg?alt=media&token=ef44e13b-2e6c-4498-b9c4-8e38bdaf1476",
"userName": "Angela",
"text": "from my end it looks like no one did",
"replyToCommentId": "PIHhXy5hLHSgW8uoUD0Q",
"createdTime": 1655287149528,
"userUsername": "Angela",
"id": "5slnWEQWwm6dHjDi6oiH",
"contractId": "lEoqtnDgJzft6apSKzYK",
"userId": "qe2QqIlOkeWsbljfeF3MsxpSJ9i2"
}
],
"bets":[
"bets": [
{
"outcome":"0",
"contractId":"lEoqtnDgJzft6apSKzYK",
"fees":{
"liquidityFee":0,
"creatorFee":0,
"platformFee":0
"outcome": "0",
"contractId": "lEoqtnDgJzft6apSKzYK",
"fees": {
"liquidityFee": 0,
"creatorFee": 0,
"platformFee": 0
},
"isAnte":true,
"shares":100,
"probAfter":1,
"amount":100,
"userId":"IPTOzEqrpkWmEzh6hwvAyY9PqFb2",
"createdTime":1655258914863,
"probBefore":0,
"id":"2jNZqnwoEQL7WDTTAWDP"
"isAnte": true,
"shares": 100,
"probAfter": 1,
"amount": 100,
"userId": "IPTOzEqrpkWmEzh6hwvAyY9PqFb2",
"createdTime": 1655258914863,
"probBefore": 0,
"id": "2jNZqnwoEQL7WDTTAWDP"
},
{
"shares":173.20508075688772,
"fees":{
"platformFee":0,
"liquidityFee":0,
"creatorFee":0
"shares": 173.20508075688772,
"fees": {
"platformFee": 0,
"liquidityFee": 0,
"creatorFee": 0
},
"contractId":"lEoqtnDgJzft6apSKzYK",
"probBefore":0,
"createdTime":1655258941573,
"loanAmount":0,
"userId":"qe2QqIlOkeWsbljfeF3MsxpSJ9i2",
"amount":100,
"outcome":"1",
"probAfter":0.75,
"id":"xuc3JoiNkE8lXPh15mUb"
"contractId": "lEoqtnDgJzft6apSKzYK",
"probBefore": 0,
"createdTime": 1655258941573,
"loanAmount": 0,
"userId": "qe2QqIlOkeWsbljfeF3MsxpSJ9i2",
"amount": 100,
"outcome": "1",
"probAfter": 0.75,
"id": "xuc3JoiNkE8lXPh15mUb"
},
{
"userId":"y1hb6k7txdZPV5mgyxPFApZ7nQl2",
"contractId":"lEoqtnDgJzft6apSKzYK",
"loanAmount":0,
"probAfter":0.009925496893641248,
"id":"8TBlzPtOdO0q5BgSyRbi",
"createdTime":1655261198074,
"shares":20.024984394500787,
"amount":1,
"outcome":"2",
"probBefore":0,
"fees":{
"liquidityFee":0,
"creatorFee":0,
"platformFee":0
"userId": "y1hb6k7txdZPV5mgyxPFApZ7nQl2",
"contractId": "lEoqtnDgJzft6apSKzYK",
"loanAmount": 0,
"probAfter": 0.009925496893641248,
"id": "8TBlzPtOdO0q5BgSyRbi",
"createdTime": 1655261198074,
"shares": 20.024984394500787,
"amount": 1,
"outcome": "2",
"probBefore": 0,
"fees": {
"liquidityFee": 0,
"creatorFee": 0,
"platformFee": 0
}
},
{
"probAfter":0.00987648269777473,
"outcome":"3",
"id":"9vdwes6s9QxbYZUBhHs4",
"createdTime":1655263226587,
"shares":20.074859899884732,
"amount":1,
"loanAmount":0,
"fees":{
"liquidityFee":0,
"platformFee":0,
"creatorFee":0
"probAfter": 0.00987648269777473,
"outcome": "3",
"id": "9vdwes6s9QxbYZUBhHs4",
"createdTime": 1655263226587,
"shares": 20.074859899884732,
"amount": 1,
"loanAmount": 0,
"fees": {
"liquidityFee": 0,
"platformFee": 0,
"creatorFee": 0
},
"userId":"jbgplxty4kUKIa1MmgZk22byJq03",
"contractId":"lEoqtnDgJzft6apSKzYK",
"probBefore":0
"userId": "jbgplxty4kUKIa1MmgZk22byJq03",
"contractId": "lEoqtnDgJzft6apSKzYK",
"probBefore": 0
},
{
"createdTime":1655264793224,
"fees":{
"creatorFee":0,
"liquidityFee":0,
"platformFee":0
"createdTime": 1655264793224,
"fees": {
"creatorFee": 0,
"liquidityFee": 0,
"platformFee": 0
},
"probAfter":0.09211463154147384,
"amount":10,
"id":"BehiSGgk1wAkIWz1a8L4",
"userId":"5LZ4LgYuySdL1huCWe7bti02ghx2",
"contractId":"lEoqtnDgJzft6apSKzYK",
"loanAmount":0,
"probBefore":0,
"outcome":"4",
"shares":64.34283176858165
"probAfter": 0.09211463154147384,
"amount": 10,
"id": "BehiSGgk1wAkIWz1a8L4",
"userId": "5LZ4LgYuySdL1huCWe7bti02ghx2",
"contractId": "lEoqtnDgJzft6apSKzYK",
"loanAmount": 0,
"probBefore": 0,
"outcome": "4",
"shares": 64.34283176858165
}
]
}
@ -395,6 +398,65 @@ Requires no authorization.
```
- Response type: A `FullMarket` ; same as above.
### `GET /v0/users`
Lists all users.
Requires no authorization.
- Example request
```
https://manifold.markets/api/v0/users
```
- Example response
```json
[
{
"id":"igi2zGXsfxYPgB0DJTXVJVmwCOr2",
"createdTime":1639011767273,
"name":"Austin",
"username":"Austin",
"url":"https://manifold.markets/Austin",
"avatarUrl":"https://lh3.googleusercontent.com/a-/AOh14GiZyl1lBehuBMGyJYJhZd-N-mstaUtgE4xdI22lLw=s96-c",
"bio":"I build Manifold! Always happy to chat; reach out on Discord or find a time on https://calendly.com/austinchen/manifold!",
"bannerUrl":"https://images.unsplash.com/photo-1501523460185-2aa5d2a0f981?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1531&q=80",
"website":"https://blog.austn.io",
"twitterHandle":"akrolsmir",
"discordHandle":"akrolsmir#4125",
"balance":9122.607163564959,
"totalDeposits":10339.004780544328,
"totalPnLCached":9376.601262721899,
"creatorVolumeCached":76078.46984199001
}
```
- Response type: Array of `LiteUser`
```tsx
// Basic information about a user
type LiteUser = {
id: string // user's unique id
createdTime: number
name: string // display name, may contain spaces
username: string // username, used in urls
url: string // link to user's profile
avatarUrl?: string
bio?: string
bannerUrl?: string
website?: string
twitterHandle?: string
discordHandle?: string
// Note: the following are here for convenience only and may be removed in the future.
balance: number
totalDeposits: number
totalPnLCached: number
creatorVolumeCached: number
}
```
### `POST /v0/bet`
Places a new bet on behalf of the authorized user.

View File

@ -1,8 +1,8 @@
{
"functions": {
"predeploy": "npm --prefix \"$RESOURCE_DIR\" run build",
"runtime": "nodejs12",
"source": "functions"
"predeploy": "cd functions && yarn build",
"runtime": "nodejs16",
"source": "functions/dist"
},
"firestore": {
"rules": "firestore.rules",

View File

@ -1,5 +1,69 @@
{
"indexes": [
{
"collectionGroup": "bets",
"queryScope": "COLLECTION_GROUP",
"fields": [
{
"fieldPath": "isAnte",
"order": "ASCENDING"
},
{
"fieldPath": "isRedemption",
"order": "ASCENDING"
},
{
"fieldPath": "userId",
"order": "ASCENDING"
},
{
"fieldPath": "createdTime",
"order": "ASCENDING"
}
]
},
{
"collectionGroup": "bets",
"queryScope": "COLLECTION_GROUP",
"fields": [
{
"fieldPath": "userId",
"order": "ASCENDING"
},
{
"fieldPath": "createdTime",
"order": "DESCENDING"
}
]
},
{
"collectionGroup": "comments",
"queryScope": "COLLECTION_GROUP",
"fields": [
{
"fieldPath": "userId",
"order": "ASCENDING"
},
{
"fieldPath": "createdTime",
"order": "DESCENDING"
}
]
},
{
"collectionGroup": "contracts",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "creatorId",
"order": "ASCENDING"
},
{
"fieldPath": "createdTime",
"order": "ASCENDING"
}
]
},
{
"collectionGroup": "contracts",
"queryScope": "COLLECTION",
@ -14,6 +78,20 @@
}
]
},
{
"collectionGroup": "contracts",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "isResolved",
"order": "ASCENDING"
},
{
"fieldPath": "autoResolutionTime",
"order": "ASCENDING"
}
]
},
{
"collectionGroup": "contracts",
"queryScope": "COLLECTION",
@ -104,6 +182,24 @@
}
]
},
{
"collectionGroup": "contracts",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "isResolved",
"order": "ASCENDING"
},
{
"fieldPath": "visibility",
"order": "ASCENDING"
},
{
"fieldPath": "volume7Days",
"order": "ASCENDING"
}
]
},
{
"collectionGroup": "contracts",
"queryScope": "COLLECTION",
@ -118,6 +214,84 @@
}
]
},
{
"collectionGroup": "contracts",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "isResolved",
"order": "ASCENDING"
},
{
"fieldPath": "volume7Days",
"order": "ASCENDING"
}
]
},
{
"collectionGroup": "contracts",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "isResolved",
"order": "ASCENDING"
},
{
"fieldPath": "volume7Days",
"order": "ASCENDING"
},
{
"fieldPath": "closeTime",
"order": "ASCENDING"
}
]
},
{
"collectionGroup": "contracts",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "isResolved",
"order": "ASCENDING"
},
{
"fieldPath": "volume7Days",
"order": "ASCENDING"
},
{
"fieldPath": "createdTime",
"order": "ASCENDING"
}
]
},
{
"collectionGroup": "contracts",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "isResolved",
"order": "ASCENDING"
},
{
"fieldPath": "volume7Days",
"order": "DESCENDING"
}
]
},
{
"collectionGroup": "contracts",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "lowercaseTags",
"arrayConfig": "CONTAINS"
},
{
"fieldPath": "createdTime",
"order": "DESCENDING"
}
]
},
{
"collectionGroup": "contracts",
"queryScope": "COLLECTION",
@ -131,6 +305,38 @@
"order": "DESCENDING"
}
]
},
{
"collectionGroup": "txns",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "toId",
"order": "ASCENDING"
},
{
"fieldPath": "toType",
"order": "ASCENDING"
},
{
"fieldPath": "createdTime",
"order": "DESCENDING"
}
]
},
{
"collectionGroup": "manalinks",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "fromId",
"order": "ASCENDING"
},
{
"fieldPath": "createdTime",
"order": "DESCENDING"
}
]
}
],
"fieldOverrides": [
@ -295,6 +501,50 @@
"queryScope": "COLLECTION_GROUP"
}
]
},
{
"collectionGroup": "follows",
"fieldPath": "userId",
"indexes": [
{
"order": "ASCENDING",
"queryScope": "COLLECTION"
},
{
"order": "DESCENDING",
"queryScope": "COLLECTION"
},
{
"arrayConfig": "CONTAINS",
"queryScope": "COLLECTION"
},
{
"order": "ASCENDING",
"queryScope": "COLLECTION_GROUP"
}
]
},
{
"collectionGroup": "portfolioHistory",
"fieldPath": "timestamp",
"indexes": [
{
"order": "ASCENDING",
"queryScope": "COLLECTION"
},
{
"order": "DESCENDING",
"queryScope": "COLLECTION"
},
{
"arrayConfig": "CONTAINS",
"queryScope": "COLLECTION"
},
{
"order": "ASCENDING",
"queryScope": "COLLECTION_GROUP"
}
]
}
]
}

View File

@ -12,13 +12,17 @@ service cloud.firestore {
|| request.auth.uid == 'IPTOzEqrpkWmEzh6hwvAyY9PqFb2' // Manifold
}
match /stats/stats {
allow read;
}
match /users/{userId} {
allow read;
allow update: if resource.data.id == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories']);
}
match /{somePath=**}/portfolioHistory/{portfolioHistoryId} {
allow read;
}
@ -104,6 +108,16 @@ service cloud.firestore {
allow read;
}
// Note: `resource` = existing doc, `request.resource` = incoming doc
match /manalinks/{slug} {
// Anyone can view any manalink
allow get;
// Only you can create a manalink with your fromId
allow create: if request.auth.uid == request.resource.data.fromId;
// Only you can list and change your own manalinks
allow list, update: if request.auth.uid == resource.data.fromId;
}
match /users/{userId}/notifications/{notificationId} {
allow read;
allow update: if resource.data.userId == request.auth.uid

View File

@ -2,9 +2,11 @@
.env*
.runtimeconfig.json
# GCP deployment artifact
dist/
# Compiled JavaScript files
lib/**/*.js
lib/**/*.js.map
lib/
# TypeScript v1 declaration files
typings/

View File

@ -52,7 +52,7 @@ Adapted from https://firebase.google.com/docs/functions/get-started
## Deploying
0. `$ firebase use prod` to switch to prod
1. `$ yarn deploy` to push your changes live!
1. `$ firebase deploy --only functions` to push your changes live!
(Future TODO: auto-deploy functions on Git push)
## Secrets management

View File

@ -5,7 +5,8 @@
"firestore": "dev-mantic-markets.appspot.com"
},
"scripts": {
"build": "tsc",
"build": "yarn compile && rm -rf dist && mkdir -p dist/functions && cp -R ../common/lib dist/common && cp -R lib/src dist/functions/src && cp ../yarn.lock dist && cp package.json dist",
"compile": "tsc -b",
"watch": "tsc -w",
"shell": "yarn build && firebase functions:shell",
"start": "yarn shell",
@ -18,7 +19,7 @@
"db:backup-remote": "yarn db:rename-remote-backup-folder && gcloud firestore export gs://$npm_package_config_firestore/firestore_export/",
"verify": "(cd .. && yarn verify)"
},
"main": "lib/functions/src/index.js",
"main": "functions/src/index.js",
"dependencies": {
"@amplitude/node": "1.10.0",
"fetch": "1.1.0",

View File

@ -110,6 +110,9 @@ export const validate = <T extends z.ZodTypeAny>(schema: T, val: unknown) => {
const DEFAULT_OPTS: HttpsOptions = {
minInstances: 1,
concurrency: 100,
memory: '2GiB',
cpu: 1,
cors: [CORS_ORIGIN_MANIFOLD, CORS_ORIGIN_LOCALHOST],
}

View File

@ -0,0 +1,102 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { User } from 'common/user'
import { Manalink } from 'common/manalink'
import { runTxn, TxnData } from './transact'
export const claimManalink = functions
.runWith({ minInstances: 1 })
.https.onCall(async (slug: string, context) => {
const userId = context?.auth?.uid
if (!userId) return { status: 'error', message: 'Not authorized' }
// Run as transaction to prevent race conditions.
return await firestore.runTransaction(async (transaction) => {
// Look up the manalink
const manalinkDoc = firestore.doc(`manalinks/${slug}`)
const manalinkSnap = await transaction.get(manalinkDoc)
if (!manalinkSnap.exists) {
return { status: 'error', message: 'Manalink not found' }
}
const manalink = manalinkSnap.data() as Manalink
const { amount, fromId, claimedUserIds } = manalink
if (amount <= 0 || isNaN(amount) || !isFinite(amount))
return { status: 'error', message: 'Invalid amount' }
const fromDoc = firestore.doc(`users/${fromId}`)
const fromSnap = await transaction.get(fromDoc)
if (!fromSnap.exists) {
return { status: 'error', message: `User ${fromId} not found` }
}
const fromUser = fromSnap.data() as User
// Only permit one redemption per user per link
if (claimedUserIds.includes(userId)) {
return {
status: 'error',
message: `${fromUser.name} already redeemed manalink ${slug}`,
}
}
// Disallow expired or maxed out links
if (manalink.expiresTime != null && manalink.expiresTime < Date.now()) {
return {
status: 'error',
message: `Manalink ${slug} expired on ${new Date(
manalink.expiresTime
).toLocaleString()}`,
}
}
if (
manalink.maxUses != null &&
manalink.maxUses <= manalink.claims.length
) {
return {
status: 'error',
message: `Manalink ${slug} has reached its max uses of ${manalink.maxUses}`,
}
}
if (fromUser.balance < amount) {
return {
status: 'error',
message: `Insufficient balance: ${fromUser.name} needed ${amount} for this manalink but only had ${fromUser.balance} `,
}
}
// Actually execute the txn
const data: TxnData = {
fromId,
fromType: 'USER',
toId: userId,
toType: 'USER',
amount,
token: 'M$',
category: 'MANALINK',
description: `Manalink ${slug} claimed: ${amount} from ${fromUser.username} to ${userId}`,
}
const result = await runTxn(transaction, data)
const txnId = result.txn?.id
if (!txnId) {
return { status: 'error', message: result.message }
}
// Update the manalink object with this info
const claim = {
toId: userId,
txnId,
claimedTime: Date.now(),
}
transaction.update(manalinkDoc, {
claimedUserIds: [...claimedUserIds, userId],
claims: [...manalink.claims, claim],
})
return { status: 'success', message: 'Manalink claimed' }
})
})
const firestore = admin.firestore()

View File

@ -22,7 +22,6 @@ import {
getCpmmInitialLiquidity,
getFreeAnswerAnte,
getNumericAnte,
HOUSE_LIQUIDITY_PROVIDER_ID,
} from '../../common/antes'
import { getNoneAnswer } from '../../common/answer'
import { getNewContract } from '../../common/new-contract'
@ -64,46 +63,42 @@ export const createmarket = newEndpoint(['POST'], async (req, auth) => {
;({ initialProb } = validate(binarySchema, req.body))
}
// Uses utc time on server:
const today = new Date()
let freeMarketResetTime = new Date().setUTCHours(16, 0, 0, 0)
if (today.getTime() < freeMarketResetTime) {
freeMarketResetTime = freeMarketResetTime - 24 * 60 * 60 * 1000
}
const userDoc = await firestore.collection('users').doc(auth.uid).get()
if (!userDoc.exists) {
throw new APIError(400, 'No user exists with the authenticated user ID.')
}
const user = userDoc.data() as User
const ante = FIXED_ANTE
// TODO: this is broken because it's not in a transaction
if (ante > user.balance)
throw new APIError(400, `Balance must be at least ${ante}.`)
const slug = await getSlug(question)
const contractRef = firestore.collection('contracts').doc()
let group = null
if (groupId) {
const groupDoc = await firestore.collection('groups').doc(groupId).get()
const groupDocRef = await firestore.collection('groups').doc(groupId)
const groupDoc = await groupDocRef.get()
if (!groupDoc.exists) {
throw new APIError(400, 'No group exists with the given group ID.')
}
group = groupDoc.data() as Group
if (!group.memberIds.includes(user.id)) {
throw new APIError(400, 'User is not a member of the group.')
throw new APIError(
400,
'User must be a member of the group to add markets to it.'
)
}
if (!group.contractIds.includes(contractRef.id))
await groupDocRef.update({
contractIds: [...group.contractIds, contractRef.id],
})
}
const userContractsCreatedTodaySnapshot = await firestore
.collection(`contracts`)
.where('creatorId', '==', auth.uid)
.where('createdTime', '>=', freeMarketResetTime)
.get()
console.log('free market reset time: ', freeMarketResetTime)
const isFree = userContractsCreatedTodaySnapshot.size === 0
const ante = FIXED_ANTE
// TODO: this is broken because it's not in a transaction
if (ante > user.balance && !isFree)
throw new APIError(400, `Balance must be at least ${ante}.`)
console.log(
'creating contract for',
user.username,
@ -113,8 +108,6 @@ export const createmarket = newEndpoint(['POST'], async (req, auth) => {
ante || 0
)
const slug = await getSlug(question)
const contractRef = firestore.collection('contracts').doc()
const contract = getNewContract(
contractRef.id,
slug,
@ -128,21 +121,14 @@ export const createmarket = newEndpoint(['POST'], async (req, auth) => {
tags ?? [],
NUMERIC_BUCKET_COUNT,
min ?? 0,
max ?? 0,
group
? {
groupId: group.id,
groupName: group.name,
groupSlug: group.slug,
}
: undefined
max ?? 0
)
if (!isFree && ante) await chargeUser(user.id, ante, true)
if (ante) await chargeUser(user.id, ante, true)
await contractRef.create(contract)
const providerId = isFree ? HOUSE_LIQUIDITY_PROVIDER_ID : user.id
const providerId = user.id
if (outcomeType === 'BINARY') {
const liquidityDoc = firestore

View File

@ -4,6 +4,7 @@ admin.initializeApp()
// v1
// export * from './keep-awake'
export * from './claim-manalink'
export * from './transact'
export * from './resolve-market'
export * from './stripe'
@ -14,6 +15,7 @@ export * from './on-create-comment'
export * from './on-view'
export * from './unsubscribe'
export * from './update-metrics'
export * from './update-stats'
export * from './backup-db'
export * from './change-user-info'
export * from './market-close-notifications'

View File

@ -0,0 +1,15 @@
import { initAdmin } from './script-init'
initAdmin()
import { log, logMemory } from '../utils'
import { updateStatsCore } from '../update-stats'
async function updateStats() {
logMemory()
log('Updating stats...')
await updateStatsCore()
}
if (require.main === module) {
updateStats().then(() => process.exit())
}

View File

@ -5,23 +5,15 @@ import { User } from '../../common/user'
import { Txn } from '../../common/txn'
import { removeUndefinedProps } from '../../common/util/object'
export type TxnData = Omit<Txn, 'id' | 'createdTime'>
export const transact = functions
.runWith({ minInstances: 1 })
.https.onCall(async (data: Omit<Txn, 'id' | 'createdTime'>, context) => {
.https.onCall(async (data: TxnData, context) => {
const userId = context?.auth?.uid
if (!userId) return { status: 'error', message: 'Not authorized' }
const {
amount,
fromType,
fromId,
toId,
toType,
category,
token,
data: innerData,
description,
} = data
const { amount, fromType, fromId } = data
if (fromType !== 'USER')
return {
@ -40,69 +32,53 @@ export const transact = functions
// Run as transaction to prevent race conditions.
return await firestore.runTransaction(async (transaction) => {
const fromDoc = firestore.doc(`users/${userId}`)
const fromSnap = await transaction.get(fromDoc)
if (!fromSnap.exists) {
return { status: 'error', message: 'User not found' }
}
const fromUser = fromSnap.data() as User
if (amount > 0 && fromUser.balance < amount) {
return {
status: 'error',
message: `Insufficient balance: ${fromUser.username} needed ${amount} but only had ${fromUser.balance} `,
}
}
if (toType === 'USER') {
const toDoc = firestore.doc(`users/${toId}`)
const toSnap = await transaction.get(toDoc)
if (!toSnap.exists) {
return { status: 'error', message: 'User not found' }
}
const toUser = toSnap.data() as User
if (amount < 0 && toUser.balance < -amount) {
return {
status: 'error',
message: `Insufficient balance: ${
toUser.username
} needed ${-amount} but only had ${toUser.balance} `,
}
}
transaction.update(toDoc, {
balance: toUser.balance + amount,
totalDeposits: toUser.totalDeposits + amount,
})
}
const newTxnDoc = firestore.collection(`txns/`).doc()
const txn = removeUndefinedProps({
id: newTxnDoc.id,
createdTime: Date.now(),
fromId,
fromType,
toId,
toType,
amount,
category,
data: innerData,
token,
description,
})
transaction.create(newTxnDoc, txn)
transaction.update(fromDoc, {
balance: fromUser.balance - amount,
totalDeposits: fromUser.totalDeposits - amount,
})
return { status: 'success', txn }
await runTxn(transaction, data)
})
})
export async function runTxn(
fbTransaction: admin.firestore.Transaction,
data: TxnData
) {
const { amount, fromId, toId, toType } = data
const fromDoc = firestore.doc(`users/${fromId}`)
const fromSnap = await fbTransaction.get(fromDoc)
if (!fromSnap.exists) {
return { status: 'error', message: 'User not found' }
}
const fromUser = fromSnap.data() as User
if (fromUser.balance < amount) {
return {
status: 'error',
message: `Insufficient balance: ${fromUser.username} needed ${amount} but only had ${fromUser.balance} `,
}
}
// TODO: Track payments received by charities, bank, contracts too.
if (toType === 'USER') {
const toDoc = firestore.doc(`users/${toId}`)
const toSnap = await fbTransaction.get(toDoc)
if (!toSnap.exists) {
return { status: 'error', message: 'User not found' }
}
const toUser = toSnap.data() as User
fbTransaction.update(toDoc, {
balance: toUser.balance + amount,
totalDeposits: toUser.totalDeposits + amount,
})
}
const newTxnDoc = firestore.collection(`txns/`).doc()
const txn = { id: newTxnDoc.id, createdTime: Date.now(), ...data }
fbTransaction.create(newTxnDoc, removeUndefinedProps(txn))
fbTransaction.update(fromDoc, {
balance: fromUser.balance - amount,
totalDeposits: fromUser.totalDeposits - amount,
})
return { status: 'success', txn }
}
const firestore = admin.firestore()

View File

@ -0,0 +1,316 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { concat, countBy, sortBy, range, zip, uniq, sum, sumBy } from 'lodash'
import { getValues, log, logMemory } from './utils'
import { Bet } from '../../common/bet'
import { Contract } from '../../common/contract'
import { Comment } from '../../common/comment'
import { User } from '../../common/user'
import { DAY_MS } from '../../common/util/time'
import { average } from '../../common/util/math'
const firestore = admin.firestore()
const numberOfDays = 90
const getBetsQuery = (startTime: number, endTime: number) =>
firestore
.collectionGroup('bets')
.where('createdTime', '>=', startTime)
.where('createdTime', '<', endTime)
.orderBy('createdTime', 'asc')
export async function getDailyBets(startTime: number, numberOfDays: number) {
const query = getBetsQuery(startTime, startTime + DAY_MS * numberOfDays)
const bets = await getValues<Bet>(query)
const betsByDay = range(0, numberOfDays).map(() => [] as Bet[])
for (const bet of bets) {
const dayIndex = Math.floor((bet.createdTime - startTime) / DAY_MS)
betsByDay[dayIndex].push(bet)
}
return betsByDay
}
const getCommentsQuery = (startTime: number, endTime: number) =>
firestore
.collectionGroup('comments')
.where('createdTime', '>=', startTime)
.where('createdTime', '<', endTime)
.orderBy('createdTime', 'asc')
export async function getDailyComments(
startTime: number,
numberOfDays: number
) {
const query = getCommentsQuery(startTime, startTime + DAY_MS * numberOfDays)
const comments = await getValues<Comment>(query)
const commentsByDay = range(0, numberOfDays).map(() => [] as Comment[])
for (const comment of comments) {
const dayIndex = Math.floor((comment.createdTime - startTime) / DAY_MS)
commentsByDay[dayIndex].push(comment)
}
return commentsByDay
}
const getContractsQuery = (startTime: number, endTime: number) =>
firestore
.collection('contracts')
.where('createdTime', '>=', startTime)
.where('createdTime', '<', endTime)
.orderBy('createdTime', 'asc')
export async function getDailyContracts(
startTime: number,
numberOfDays: number
) {
const query = getContractsQuery(startTime, startTime + DAY_MS * numberOfDays)
const contracts = await getValues<Contract>(query)
const contractsByDay = range(0, numberOfDays).map(() => [] as Contract[])
for (const contract of contracts) {
const dayIndex = Math.floor((contract.createdTime - startTime) / DAY_MS)
contractsByDay[dayIndex].push(contract)
}
return contractsByDay
}
const getUsersQuery = (startTime: number, endTime: number) =>
firestore
.collection('users')
.where('createdTime', '>=', startTime)
.where('createdTime', '<', endTime)
.orderBy('createdTime', 'asc')
export async function getDailyNewUsers(
startTime: number,
numberOfDays: number
) {
const query = getUsersQuery(startTime, startTime + DAY_MS * numberOfDays)
const users = await getValues<User>(query)
const usersByDay = range(0, numberOfDays).map(() => [] as User[])
for (const user of users) {
const dayIndex = Math.floor((user.createdTime - startTime) / DAY_MS)
usersByDay[dayIndex].push(user)
}
return usersByDay
}
export const updateStatsCore = async () => {
const today = Date.now()
const startDate = today - numberOfDays * DAY_MS
log('Fetching data for stats update...')
const [dailyBets, dailyContracts, dailyComments, dailyNewUsers] =
await Promise.all([
getDailyBets(startDate.valueOf(), numberOfDays),
getDailyContracts(startDate.valueOf(), numberOfDays),
getDailyComments(startDate.valueOf(), numberOfDays),
getDailyNewUsers(startDate.valueOf(), numberOfDays),
])
logMemory()
const dailyBetCounts = dailyBets.map((bets) => bets.length)
const dailyContractCounts = dailyContracts.map(
(contracts) => contracts.length
)
const dailyCommentCounts = dailyComments.map((comments) => comments.length)
const dailyUserIds = zip(dailyContracts, dailyBets, dailyComments).map(
([contracts, bets, comments]) => {
const creatorIds = (contracts ?? []).map((c) => c.creatorId)
const betUserIds = (bets ?? []).map((bet) => bet.userId)
const commentUserIds = (comments ?? []).map((comment) => comment.userId)
return uniq([...creatorIds, ...betUserIds, ...commentUserIds])
}
)
log(
`Fetched ${sum(dailyBetCounts)} bets, ${sum(
dailyContractCounts
)} contracts, ${sum(dailyComments)} comments, from ${sum(
dailyNewUsers
)} unique users.`
)
const dailyActiveUsers = dailyUserIds.map((userIds) => userIds.length)
const weeklyActiveUsers = dailyUserIds.map((_, i) => {
const start = Math.max(0, i - 6)
const end = i
const uniques = new Set<string>()
for (let j = start; j <= end; j++)
dailyUserIds[j].forEach((userId) => uniques.add(userId))
return uniques.size
})
const monthlyActiveUsers = dailyUserIds.map((_, i) => {
const start = Math.max(0, i - 29)
const end = i
const uniques = new Set<string>()
for (let j = start; j <= end; j++)
dailyUserIds[j].forEach((userId) => uniques.add(userId))
return uniques.size
})
const weekOnWeekRetention = dailyUserIds.map((_userId, i) => {
const twoWeeksAgo = {
start: Math.max(0, i - 13),
end: Math.max(0, i - 7),
}
const lastWeek = {
start: Math.max(0, i - 6),
end: i,
}
const activeTwoWeeksAgo = new Set<string>()
for (let j = twoWeeksAgo.start; j <= twoWeeksAgo.end; j++) {
dailyUserIds[j].forEach((userId) => activeTwoWeeksAgo.add(userId))
}
const activeLastWeek = new Set<string>()
for (let j = lastWeek.start; j <= lastWeek.end; j++) {
dailyUserIds[j].forEach((userId) => activeLastWeek.add(userId))
}
const retainedCount = sumBy(Array.from(activeTwoWeeksAgo), (userId) =>
activeLastWeek.has(userId) ? 1 : 0
)
const retainedFrac = retainedCount / activeTwoWeeksAgo.size
return Math.round(retainedFrac * 100 * 100) / 100
})
const monthlyRetention = dailyUserIds.map((_userId, i) => {
const twoMonthsAgo = {
start: Math.max(0, i - 60),
end: Math.max(0, i - 30),
}
const lastMonth = {
start: Math.max(0, i - 30),
end: i,
}
const activeTwoMonthsAgo = new Set<string>()
for (let j = twoMonthsAgo.start; j <= twoMonthsAgo.end; j++) {
dailyUserIds[j].forEach((userId) => activeTwoMonthsAgo.add(userId))
}
const activeLastMonth = new Set<string>()
for (let j = lastMonth.start; j <= lastMonth.end; j++) {
dailyUserIds[j].forEach((userId) => activeLastMonth.add(userId))
}
const retainedCount = sumBy(Array.from(activeTwoMonthsAgo), (userId) =>
activeLastMonth.has(userId) ? 1 : 0
)
const retainedFrac = retainedCount / activeTwoMonthsAgo.size
return Math.round(retainedFrac * 100 * 100) / 100
})
const firstBetDict: { [userId: string]: number } = {}
for (let i = 0; i < dailyBets.length; i++) {
const bets = dailyBets[i]
for (const bet of bets) {
if (bet.userId in firstBetDict) continue
firstBetDict[bet.userId] = i
}
}
const weeklyActivationRate = dailyNewUsers.map((_, i) => {
const start = Math.max(0, i - 6)
const end = i
let activatedCount = 0
let newUsers = 0
for (let j = start; j <= end; j++) {
const userIds = dailyNewUsers[j].map((user) => user.id)
newUsers += userIds.length
for (const userId of userIds) {
const dayIndex = firstBetDict[userId]
if (dayIndex !== undefined && dayIndex <= end) {
activatedCount++
}
}
}
const frac = activatedCount / (newUsers || 1)
return Math.round(frac * 100 * 100) / 100
})
const dailySignups = dailyNewUsers.map((users) => users.length)
const dailyTopTenthActions = zip(
dailyContracts,
dailyBets,
dailyComments
).map(([contracts, bets, comments]) => {
const userIds = concat(
contracts?.map((c) => c.creatorId) ?? [],
bets?.map((b) => b.userId) ?? [],
comments?.map((c) => c.userId) ?? []
)
const counts = Object.values(countBy(userIds))
const sortedCounts = sortBy(counts, (count) => count).reverse()
if (sortedCounts.length === 0) return 0
const tenthPercentile = sortedCounts[Math.floor(sortedCounts.length * 0.1)]
return tenthPercentile
})
const weeklyTopTenthActions = dailyTopTenthActions.map((_, i) => {
const start = Math.max(0, i - 6)
const end = i
return average(dailyTopTenthActions.slice(start, end))
})
const monthlyTopTenthActions = dailyTopTenthActions.map((_, i) => {
const start = Math.max(0, i - 29)
const end = i
return average(dailyTopTenthActions.slice(start, end))
})
// Total mana divided by 100.
const dailyManaBet = dailyBets.map((bets) => {
return Math.round(sumBy(bets, (bet) => bet.amount) / 100)
})
const weeklyManaBet = dailyManaBet.map((_, i) => {
const start = Math.max(0, i - 6)
const end = i
const total = sum(dailyManaBet.slice(start, end))
if (end - start < 7) return (total * 7) / (end - start)
return total
})
const monthlyManaBet = dailyManaBet.map((_, i) => {
const start = Math.max(0, i - 29)
const end = i
const total = sum(dailyManaBet.slice(start, end))
const range = end - start + 1
if (range < 30) return (total * 30) / range
return total
})
const statsData = {
startDate: startDate.valueOf(),
dailyActiveUsers,
weeklyActiveUsers,
monthlyActiveUsers,
dailyBetCounts,
dailyContractCounts,
dailyCommentCounts,
dailySignups,
weekOnWeekRetention,
weeklyActivationRate,
monthlyRetention,
topTenthActions: {
daily: dailyTopTenthActions,
weekly: weeklyTopTenthActions,
monthly: monthlyTopTenthActions,
},
manaBet: {
daily: dailyManaBet,
weekly: weeklyManaBet,
monthly: monthlyManaBet,
},
}
log('Computed stats: ', statsData)
await firestore.doc('stats/stats').set(statsData)
}
export const updateStats = functions
.runWith({ memory: '1GB', timeoutSeconds: 540 })
.pubsub.schedule('every 60 minutes')
.onRun(updateStatsCore)

View File

@ -1,6 +1,7 @@
{
"compilerOptions": {
"baseUrl": "../",
"composite": true,
"module": "commonjs",
"noImplicitReturns": true,
"outDir": "lib",
@ -8,6 +9,11 @@
"strict": true,
"target": "es2017"
},
"references": [
{
"path": "../common"
}
],
"compileOnSave": true,
"include": ["src", "../common/**/*.ts"]
"include": ["src"]
}

View File

@ -1,6 +1,7 @@
import clsx from 'clsx'
import { useState } from 'react'
import Textarea from 'react-expanding-textarea'
import { findBestMatch } from 'string-similarity'
import { FreeResponseContract } from 'common/contract'
import { BuyAmountInput } from '../amount-input'
@ -23,6 +24,7 @@ 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'
import { lowerCase } from 'lodash'
export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
const { contract } = props
@ -30,9 +32,15 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
const [text, setText] = useState('')
const [betAmount, setBetAmount] = useState<number | undefined>(10)
const [amountError, setAmountError] = useState<string | undefined>()
const [answerError, setAnswerError] = useState<string | undefined>()
const [possibleDuplicateAnswer, setPossibleDuplicateAnswer] = useState<
string | undefined
>()
const [isSubmitting, setIsSubmitting] = useState(false)
const { answers } = contract
const canSubmit = text && betAmount && !amountError && !isSubmitting
const canSubmit =
text && betAmount && !amountError && !isSubmitting && !answerError
const submitAnswer = async () => {
if (canSubmit) {
@ -54,6 +62,36 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
}
}
const changeAnswer = (text: string) => {
setText(text)
const existingAnswer = answers.find(
(a) => lowerCase(a.text) === lowerCase(text)
)
if (existingAnswer) {
setAnswerError(
existingAnswer
? `"${existingAnswer.text}" already exists as an answer`
: ''
)
return
} else {
setAnswerError('')
}
if (answers.length && text) {
const matches = findBestMatch(
lowerCase(text),
answers.map((a) => lowerCase(a.text))
)
setPossibleDuplicateAnswer(
matches.bestMatch.rating > 0.8
? answers[matches.bestMatchIndex].text
: ''
)
}
}
const resultProb = getDpmOutcomeProbabilityAfterBet(
contract.totalShares,
'new',
@ -79,12 +117,21 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
<div className="mb-1">Add your answer</div>
<Textarea
value={text}
onChange={(e) => setText(e.target.value)}
onChange={(e) => changeAnswer(e.target.value)}
className="textarea textarea-bordered w-full resize-none"
placeholder="Type your answer..."
rows={1}
maxLength={MAX_ANSWER_LENGTH}
/>
{answerError ? (
<AnswerError key={1} level="error" text={answerError} />
) : possibleDuplicateAnswer ? (
<AnswerError
key={2}
level="warning"
text={`Did you mean to bet on "${possibleDuplicateAnswer}"?`}
/>
) : undefined}
<div />
<Col
className={clsx(
@ -163,3 +210,21 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
</Col>
)
}
type answerErrorLevel = 'warning' | 'error'
const AnswerError = (props: { text: string; level: answerErrorLevel }) => {
const { text, level } = props
const colorClass =
{
error: 'text-red-500',
warning: 'text-orange-500',
}[level] ?? ''
return (
<div
className={`${colorClass} mb-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide`}
>
{text}
</div>
)
}

View File

@ -1,7 +1,7 @@
import Router from 'next/router'
import clsx from 'clsx'
import { MouseEvent } from 'react'
import { UserCircleIcon } from '@heroicons/react/solid'
import { UserCircleIcon, UserIcon, UsersIcon } from '@heroicons/react/solid'
export function Avatar(props: {
username?: string
@ -45,3 +45,17 @@ export function Avatar(props: {
/>
)
}
export function EmptyAvatar(props: { size?: number; multi?: boolean }) {
const { size = 8, multi } = props
const insize = size - 3
const Icon = multi ? UsersIcon : UserIcon
return (
<div
className={`flex h-${size} w-${size} items-center justify-center rounded-full bg-gray-200`}
>
<Icon className={`h-${insize} w-${insize} text-gray-500`} aria-hidden />
</div>
)
}

View File

@ -19,7 +19,7 @@ import { ContractsGrid } from './contract/contracts-list'
import { Row } from './layout/row'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Spacer } from './layout/spacer'
import { ENV } from 'common/envs/constants'
import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
import { useUser } from 'web/hooks/use-user'
import { useFollows } from 'web/hooks/use-follows'
import { EditCategoriesButton } from './feed/category-selector'
@ -28,6 +28,7 @@ import { Tabs } from './layout/tabs'
import { EditFollowingButton } from './following-button'
import { track } from '@amplitude/analytics-browser'
import { trackCallback } from 'web/lib/service/analytics'
import ContractSearchFirestore from 'web/pages/contract-search-firestore'
const searchClient = algoliasearch(
'GJQPAYENIF',
@ -115,12 +116,21 @@ export function ContractSearch(props: {
showCategorySelector,
mode,
Object.values(additionalFilter ?? {}).join(','),
followedCategories?.join(','),
follows?.join(','),
(followedCategories ?? []).join(','),
(follows ?? []).join(','),
])
const indexName = `${indexPrefix}contracts-${sort}`
if (IS_PRIVATE_MANIFOLD) {
return (
<ContractSearchFirestore
querySortOptions={querySortOptions}
additionalFilter={additionalFilter}
/>
)
}
return (
<InstantSearch searchClient={searchClient} indexName={indexName}>
<Row className="gap-1 sm:gap-2">
@ -233,12 +243,18 @@ export function ContractSearchInner(props: {
if (isInitialLoad && contracts.length === 0) return <></>
const showTime = index.endsWith('close-date')
? 'close-date'
: index.endsWith('resolve-date')
? 'resolve-date'
: undefined
return (
<ContractsGrid
contracts={contracts}
loadMore={showMore}
hasMore={!isLastPage}
showCloseTime={index.endsWith('close-date')}
showTime={showTime}
onContractClick={onContractClick}
/>
)

View File

@ -17,7 +17,7 @@ import {
FreeResponseOutcomeLabel,
} from '../outcome-label'
import { getOutcomeProbability, getTopAnswer } from 'common/calculate'
import { AvatarDetails, MiscDetails } from './contract-details'
import { AvatarDetails, MiscDetails, ShowTime } from './contract-details'
import { getExpectedValue, getValueFromBucket } from 'common/calculate-dpm'
import { QuickBet, ProbBar, getColor } from './quick-bet'
import { useContractWithPreload } from 'web/hooks/use-contract'
@ -28,13 +28,12 @@ import { trackCallback } from 'web/lib/service/analytics'
export function ContractCard(props: {
contract: Contract
showHotVolume?: boolean
showCloseTime?: boolean
showTime?: ShowTime
className?: string
onClick?: () => void
hideQuickBet?: boolean
}) {
const { showHotVolume, showCloseTime, className, onClick, hideQuickBet } =
props
const { showHotVolume, showTime, className, onClick, hideQuickBet } = props
const contract = useContractWithPreload(props.contract) ?? props.contract
const { question, outcomeType } = contract
const { resolution } = contract
@ -118,7 +117,7 @@ export function ContractCard(props: {
<MiscDetails
contract={contract}
showHotVolume={showHotVolume}
showCloseTime={showCloseTime}
showTime={showTime}
/>
</Col>
{showQuickBet ? (

View File

@ -28,15 +28,25 @@ import { UserFollowButton } from '../follow-button'
import { groupPath } from 'web/lib/firebase/groups'
import { SiteLink } from 'web/components/site-link'
import { DAY_MS } from 'common/util/time'
import { useGroupsWithContract } from 'web/hooks/use-group'
export type ShowTime = 'resolve-date' | 'close-date'
export function MiscDetails(props: {
contract: Contract
showHotVolume?: boolean
showCloseTime?: boolean
showTime?: ShowTime
}) {
const { contract, showHotVolume, showCloseTime } = props
const { volume, volume24Hours, closeTime, tags, isResolved, createdTime } =
contract
const { contract, showHotVolume, showTime } = props
const {
volume,
volume24Hours,
closeTime,
tags,
isResolved,
createdTime,
resolutionTime,
} = contract
// Show at most one category that this contract is tagged by
const categories = CATEGORY_LIST.filter((category) =>
tags.map((t) => t.toLowerCase()).includes(category)
@ -49,12 +59,18 @@ export function MiscDetails(props: {
<Row className="gap-0.5">
<TrendingUpIcon className="h-5 w-5" /> {formatMoney(volume24Hours)}
</Row>
) : showCloseTime ? (
) : showTime === 'close-date' ? (
<Row className="gap-0.5">
<ClockIcon className="h-5 w-5" />
{(closeTime || 0) < Date.now() ? 'Closed' : 'Closes'}{' '}
{fromNow(closeTime || 0)}
</Row>
) : showTime === 'resolve-date' && resolutionTime !== undefined ? (
<Row className="gap-0.5">
<ClockIcon className="h-5 w-5" />
{'Resolved '}
{fromNow(resolutionTime || 0)}
</Row>
) : volume > 0 || !isNew ? (
<Row>{contractPool(contract)} pool</Row>
) : (
@ -87,9 +103,9 @@ export function AvatarDetails(props: { contract: Contract }) {
export function AbbrContractDetails(props: {
contract: Contract
showHotVolume?: boolean
showCloseTime?: boolean
showTime?: ShowTime
}) {
const { contract, showHotVolume, showCloseTime } = props
const { contract, showHotVolume, showTime } = props
return (
<Row className="items-center justify-between">
<AvatarDetails contract={contract} />
@ -97,7 +113,7 @@ export function AbbrContractDetails(props: {
<MiscDetails
contract={contract}
showHotVolume={showHotVolume}
showCloseTime={showCloseTime}
showTime={showTime}
/>
</Row>
)
@ -110,10 +126,10 @@ export function ContractDetails(props: {
disabled?: boolean
}) {
const { contract, bets, isCreator, disabled } = props
const { closeTime, creatorName, creatorUsername, creatorId, groupDetails } =
contract
const { closeTime, creatorName, creatorUsername, creatorId } = contract
const { volumeLabel, resolvedDate } = contractMetrics(contract)
// Find a group that this contract id is in
const groups = useGroupsWithContract(contract.id)
return (
<Row className="flex-1 flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-500">
<Row className="items-center gap-2">
@ -134,11 +150,12 @@ export function ContractDetails(props: {
)}
{!disabled && <UserFollowButton userId={creatorId} small />}
</Row>
{groupDetails && (
{/*// TODO: we can add contracts to multiple groups but only show the first it was added to*/}
{groups && groups.length > 0 && (
<Row className={'line-clamp-1 mt-1 max-w-[200px]'}>
<SiteLink href={`${groupPath(groupDetails[0].groupSlug)}`}>
<SiteLink href={`${groupPath(groups[0].slug)}`}>
<UserGroupIcon className="mx-1 mb-1 inline h-5 w-5" />
<span>{groupDetails[0].groupName}</span>
<span>{groups[0].name}</span>
</SiteLink>
</Row>
)}

View File

@ -56,7 +56,7 @@ export const ContractProbGraph = memo(function ContractProbGraph(props: {
const timeStep: number = latestTime.diff(startDate, 'ms') / totalPoints
const points: { x: Date; y: number }[] = []
for (let i = 0; i < times.length; i++) {
for (let i = 0; i < times.length - 1; i++) {
points[points.length] = { x: times[i], y: probs[i] * 100 }
const numPoints: number = Math.floor(
dayjs(times[i + 1]).diff(dayjs(times[i]), 'ms') / timeStep

View File

@ -8,15 +8,17 @@ import { Spacer } from '../layout/spacer'
import { Tabs } from '../layout/tabs'
import { Col } from '../layout/col'
import { CommentTipMap } from 'web/hooks/use-tip-txns'
import { LiquidityProvision } from 'common/liquidity-provision'
export function ContractTabs(props: {
contract: Contract
user: User | null | undefined
bets: Bet[]
liquidityProvisions: LiquidityProvision[]
comments: Comment[]
tips: CommentTipMap
}) {
const { contract, user, bets, comments, tips } = props
const { contract, user, bets, comments, tips, liquidityProvisions } = props
const { outcomeType } = contract
const userBets = user && bets.filter((bet) => bet.userId === user.id)
@ -25,6 +27,7 @@ export function ContractTabs(props: {
<ContractActivity
contract={contract}
bets={bets}
liquidityProvisions={liquidityProvisions}
comments={comments}
tips={tips}
user={user}
@ -38,6 +41,7 @@ export function ContractTabs(props: {
<ContractActivity
contract={contract}
bets={bets}
liquidityProvisions={liquidityProvisions}
comments={comments}
tips={tips}
user={user}
@ -55,6 +59,7 @@ export function ContractTabs(props: {
<ContractActivity
contract={contract}
bets={bets}
liquidityProvisions={liquidityProvisions}
comments={comments}
tips={tips}
user={user}

View File

@ -3,6 +3,7 @@ import { User } from '../../lib/firebase/users'
import { Col } from '../layout/col'
import { SiteLink } from '../site-link'
import { ContractCard } from './contract-card'
import { ShowTime } from './contract-details'
import { ContractSearch } from '../contract-search'
import { useIsVisible } from 'web/hooks/use-is-visible'
import { useEffect, useState } from 'react'
@ -12,14 +13,14 @@ export function ContractsGrid(props: {
contracts: Contract[]
loadMore: () => void
hasMore: boolean
showCloseTime?: boolean
showTime?: ShowTime
onContractClick?: (contract: Contract) => void
overrideGridClassName?: string
hideQuickBet?: boolean
}) {
const {
contracts,
showCloseTime,
showTime,
hasMore,
loadMore,
onContractClick,
@ -60,7 +61,7 @@ export function ContractsGrid(props: {
<ContractCard
contract={contract}
key={contract.id}
showCloseTime={showCloseTime}
showTime={showTime}
onClick={
onContractClick ? () => onContractClick(contract) : undefined
}

View File

@ -325,14 +325,6 @@ export function getColor(contract: Contract) {
)
}
if (contract.outcomeType === 'NUMERIC') {
return 'blue-400'
}
if (contract.outcomeType === 'FREE_RESPONSE') {
return 'blue-400'
}
if ((contract.closeTime ?? Infinity) < Date.now()) {
return 'gray-400'
}

View File

@ -7,6 +7,7 @@ import { Comment } from 'common/comment'
import { Contract, FreeResponseContract } from 'common/contract'
import { User } from 'common/user'
import { CommentTipMap } from 'web/hooks/use-tip-txns'
import { LiquidityProvision } from 'common/liquidity-provision'
export type ActivityItem =
| DescriptionItem
@ -17,6 +18,7 @@ export type ActivityItem =
| ResolveItem
| CommentInputItem
| CommentThreadItem
| LiquidityItem
type BaseActivityItem = {
id: string
@ -72,6 +74,14 @@ export type ResolveItem = BaseActivityItem & {
type: 'resolve'
}
export type LiquidityItem = BaseActivityItem & {
type: 'liquidity'
liquidity: LiquidityProvision
hideOutcome: boolean
smallAvatar: boolean
hideComment?: boolean
}
function getAnswerAndCommentInputGroups(
contract: FreeResponseContract,
bets: Bet[],
@ -139,6 +149,7 @@ export function getSpecificContractActivityItems(
contract: Contract,
bets: Bet[],
comments: Comment[],
liquidityProvisions: LiquidityProvision[],
tips: CommentTipMap,
user: User | null | undefined,
options: {
@ -146,7 +157,7 @@ export function getSpecificContractActivityItems(
}
) {
const { mode } = options
const items = [] as ActivityItem[]
let items = [] as ActivityItem[]
switch (mode) {
case 'bets':
@ -163,6 +174,23 @@ export function getSpecificContractActivityItems(
hideComment: true,
}))
)
items.push(
...liquidityProvisions.map((liquidity) => ({
type: 'liquidity' as const,
id: liquidity.id,
contract,
liquidity,
hideOutcome: false,
smallAvatar: false,
}))
)
items = sortBy(items, (item) =>
item.type === 'bet'
? item.bet.createdTime
: item.type === 'liquidity'
? item.liquidity.createdTime
: undefined
)
break
case 'comments': {

View File

@ -8,11 +8,13 @@ import { FeedItems } from './feed-items'
import { User } from 'common/user'
import { useContractWithPreload } from 'web/hooks/use-contract'
import { CommentTipMap } from 'web/hooks/use-tip-txns'
import { LiquidityProvision } from 'common/liquidity-provision'
export function ContractActivity(props: {
contract: Contract
bets: Bet[]
comments: Comment[]
liquidityProvisions: LiquidityProvision[]
tips: CommentTipMap
user: User | null | undefined
mode: 'comments' | 'bets' | 'free-response-comment-answer-groups'
@ -20,7 +22,8 @@ export function ContractActivity(props: {
className?: string
betRowClassName?: string
}) {
const { user, mode, tips, className, betRowClassName } = props
const { user, mode, tips, className, betRowClassName, liquidityProvisions } =
props
const contract = useContractWithPreload(props.contract) ?? props.contract
@ -33,6 +36,7 @@ export function ContractActivity(props: {
contract,
bets,
comments,
liquidityProvisions,
tips,
user,
{ mode }

View File

@ -88,26 +88,11 @@ export function FeedAnswerCommentGroup(props: {
}
)
useEffect(() => {
if (
mostRecentCommentableBet &&
usersMostRecentBetTimeAtLoad !== undefined &&
mostRecentCommentableBet.createdTime > usersMostRecentBetTimeAtLoad &&
!showReply
)
scrollAndOpenReplyInput(undefined, answer)
}, [
answer,
usersMostRecentBetTimeAtLoad,
mostRecentCommentableBet,
scrollAndOpenReplyInput,
showReply,
])
useEffect(() => {
// Only show one comment input for a bet at a time
if (
betsByCurrentUser.length > 1 &&
inputRef?.textContent?.length === 0 &&
betsByCurrentUser.sort((a, b) => b.createdTime - a.createdTime)[0]
?.outcome !== answer.number.toString()
)

View File

@ -4,9 +4,9 @@ import { Bet } from 'common/bet'
import { User } from 'common/user'
import { useUser, useUserById } from 'web/hooks/use-user'
import { Row } from 'web/components/layout/row'
import { Avatar } from 'web/components/avatar'
import { Avatar, EmptyAvatar } from 'web/components/avatar'
import clsx from 'clsx'
import { UserIcon, UsersIcon } from '@heroicons/react/solid'
import { UsersIcon } from '@heroicons/react/solid'
import { formatMoney } from 'common/util/format'
import { OutcomeLabel } from 'web/components/outcome-label'
import { RelativeTimestamp } from 'web/components/relative-timestamp'
@ -50,9 +50,7 @@ export function FeedBet(props: {
/>
) : (
<div className="relative px-1">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-200">
<UserIcon className="h-5 w-5 text-gray-500" aria-hidden="true" />
</div>
<EmptyAvatar />
</div>
)}
<div className={'min-w-0 flex-1 py-1.5'}>

View File

@ -530,8 +530,7 @@ export function CommentInputTextArea(props: {
{user && !isSubmitting && (
<button
className={clsx(
'btn btn-ghost btn-sm absolute right-2 flex-row pl-2 capitalize',
isReply ? ' bottom-4' : ' bottom-2',
'btn btn-ghost btn-sm absolute right-2 bottom-2 flex-row pl-2 capitalize',
!commentText && 'pointer-events-none text-gray-500'
)}
onClick={() => {

View File

@ -35,6 +35,7 @@ import {
} from 'web/components/feed/feed-comments'
import { FeedBet } from 'web/components/feed/feed-bets'
import { CPMMBinaryContract, NumericContract } from 'common/contract'
import { FeedLiquidity } from './feed-liquidity'
export function FeedItems(props: {
contract: Contract
@ -86,6 +87,8 @@ export function FeedItem(props: { item: ActivityItem }) {
return <FeedDescription {...item} />
case 'bet':
return <FeedBet {...item} />
case 'liquidity':
return <FeedLiquidity {...item} />
case 'answergroup':
return <FeedAnswerCommentGroup {...item} />
case 'close':

View File

@ -0,0 +1,85 @@
import dayjs from 'dayjs'
import { User } from 'common/user'
import { useUser, useUserById } from 'web/hooks/use-user'
import { Row } from 'web/components/layout/row'
import { Avatar, EmptyAvatar } from 'web/components/avatar'
import clsx from 'clsx'
import { formatMoney } from 'common/util/format'
import { RelativeTimestamp } from 'web/components/relative-timestamp'
import React from 'react'
import { UserLink } from '../user-page'
import { LiquidityProvision } from 'common/liquidity-provision'
export function FeedLiquidity(props: {
liquidity: LiquidityProvision
smallAvatar: boolean
}) {
const { liquidity, smallAvatar } = props
const { userId, createdTime } = liquidity
const isBeforeJune2022 = dayjs(createdTime).isBefore('2022-06-01')
// eslint-disable-next-line react-hooks/rules-of-hooks
const bettor = isBeforeJune2022 ? undefined : useUserById(userId)
const user = useUser()
const isSelf = user?.id === userId
return (
<>
<Row className={'flex w-full gap-2 pt-3'}>
{isSelf ? (
<Avatar
className={clsx(smallAvatar && 'ml-1')}
size={smallAvatar ? 'sm' : undefined}
avatarUrl={user.avatarUrl}
username={user.username}
/>
) : bettor ? (
<Avatar
className={clsx(smallAvatar && 'ml-1')}
size={smallAvatar ? 'sm' : undefined}
avatarUrl={bettor.avatarUrl}
username={bettor.username}
/>
) : (
<div className="relative px-1">
<EmptyAvatar />
</div>
)}
<div className={'min-w-0 flex-1 py-1.5'}>
<LiquidityStatusText
liquidity={liquidity}
isSelf={isSelf}
bettor={bettor}
/>
</div>
</Row>
</>
)
}
export function LiquidityStatusText(props: {
liquidity: LiquidityProvision
isSelf: boolean
bettor?: User
}) {
const { liquidity, bettor, isSelf } = props
const { amount, createdTime } = liquidity
// TODO: Withdrawn liquidity will never be shown, since liquidity amounts currently are zeroed out upon withdrawal.
const bought = amount >= 0 ? 'added' : 'withdrew'
const money = formatMoney(Math.abs(amount))
return (
<div className="text-sm text-gray-500">
{bettor ? (
<UserLink name={bettor.name} username={bettor.username} />
) : (
<span>{isSelf ? 'You' : 'A trader'}</span>
)}{' '}
{bought} {money}
{' of liquidity'}
<RelativeTimestamp time={createdTime} />
</div>
)
}

View File

@ -1,12 +1,11 @@
import { UserIcon } from '@heroicons/react/outline'
import { useUsers } from 'web/hooks/use-users'
import { User } from 'common/user'
import { Fragment, useState } from 'react'
import { Fragment, useMemo, useState } from 'react'
import clsx from 'clsx'
import { Menu, Transition } from '@headlessui/react'
import { Avatar } from 'web/components/avatar'
import { Row } from 'web/components/layout/row'
import { debounce } from 'lodash'
export function FilterSelectUsers(props: {
setSelectedUsers: (users: User[]) => void
@ -16,18 +15,20 @@ export function FilterSelectUsers(props: {
const { ignoreUserIds, selectedUsers, setSelectedUsers } = props
const users = useUsers()
const [query, setQuery] = useState('')
const filteredUsers =
query === ''
? users
: users.filter((user: User) => {
const [filteredUsers, setFilteredUsers] = useState<User[]>([])
const beginQuerying = query.length > 2
useMemo(() => {
if (beginQuerying)
setFilteredUsers(
users.filter((user: User) => {
return (
!selectedUsers.map((user) => user.name).includes(user.name) &&
!ignoreUserIds.includes(user.id) &&
user.name.toLowerCase().includes(query.toLowerCase())
)
})
const debouncedQuery = debounce(setQuery, 50)
)
}, [beginQuerying, users, selectedUsers, ignoreUserIds, query])
return (
<div>
@ -40,7 +41,7 @@ export function FilterSelectUsers(props: {
name="user name"
id="user name"
value={query}
onChange={(e) => debouncedQuery(e.target.value)}
onChange={(e) => setQuery(e.target.value)}
className="input input-bordered block w-full pl-10 focus:border-gray-300 "
placeholder="Austin Chen"
/>
@ -48,13 +49,13 @@ export function FilterSelectUsers(props: {
<Menu
as="div"
className={clsx(
'relative inline-block w-full text-right',
query !== '' && 'h-36'
'relative inline-block w-full overflow-y-scroll text-right',
beginQuerying && 'h-36'
)}
>
{({}) => (
<Transition
show={query !== ''}
show={beginQuerying}
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"

View File

@ -1,7 +1,7 @@
import { Row } from 'web/components/layout/row'
import { Col } from 'web/components/layout/col'
import { User } from 'common/user'
import React, { useEffect, memo, useState } from 'react'
import React, { useEffect, memo, useState, useMemo } from 'react'
import { Avatar } from 'web/components/avatar'
import { Group } from 'common/group'
import { Comment, createCommentOnGroup } from 'web/lib/firebase/comments'
@ -19,7 +19,7 @@ import { UserLink } from 'web/components/user-page'
import { groupPath } from 'web/lib/firebase/groups'
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
export function Discussion(props: {
export function GroupChat(props: {
messages: Comment[]
user: User | null | undefined
group: Group
@ -33,15 +33,39 @@ export function Discussion(props: {
const [scrollToMessageRef, setScrollToMessageRef] =
useState<HTMLDivElement | null>(null)
const [replyToUsername, setReplyToUsername] = useState('')
const [inputRef, setInputRef] = useState<HTMLTextAreaElement | null>(null)
const [groupedMessages, setGroupedMessages] = useState<Comment[]>([])
const router = useRouter()
useMemo(() => {
// Group messages with createdTime within 2 minutes of each other.
const tempMessages = []
for (let i = 0; i < messages.length; i++) {
const message = messages[i]
if (i === 0) tempMessages.push({ ...message })
else {
const prevMessage = messages[i - 1]
const diff = message.createdTime - prevMessage.createdTime
const creatorsMatch = message.userId === prevMessage.userId
if (diff < 2 * 60 * 1000 && creatorsMatch) {
tempMessages[tempMessages.length - 1].text += `\n${message.text}`
} else {
tempMessages.push({ ...message })
}
}
}
setGroupedMessages(tempMessages)
}, [messages])
useEffect(() => {
scrollToMessageRef?.scrollIntoView()
}, [scrollToMessageRef])
useEffect(() => {
scrollToBottomRef?.scrollIntoView()
}, [isSubmitting, scrollToBottomRef])
if (!isSubmitting)
scrollToBottomRef?.scrollTo({ top: scrollToBottomRef?.scrollHeight || 0 })
}, [scrollToBottomRef, isSubmitting])
useEffect(() => {
const elementInUrl = router.asPath.split('#')[1]
@ -65,6 +89,7 @@ export function Discussion(props: {
setMessageText('')
setIsSubmitting(false)
setReplyToUsername('')
inputRef?.focus()
}
return (
@ -73,8 +98,9 @@ export function Discussion(props: {
className={
'max-h-[65vh] w-full space-y-2 overflow-x-hidden overflow-y-scroll'
}
ref={setScrollToBottomRef}
>
{messages.map((message, i) => (
{groupedMessages.map((message) => (
<GroupMessage
user={user}
key={message.id}
@ -85,8 +111,6 @@ export function Discussion(props: {
setRef={
scrollToMessageId === message.id
? setScrollToMessageRef
: i === messages.length - 1
? setScrollToBottomRef
: undefined
}
/>
@ -116,6 +140,7 @@ export function Discussion(props: {
submitComment={submitMessage}
isSubmitting={isSubmitting}
enterToSubmit={true}
setRef={setInputRef}
/>
</div>
</div>
@ -128,58 +153,62 @@ const GroupMessage = memo(function GroupMessage_(props: {
user: User | null | undefined
comment: Comment
group: Group
truncate?: boolean
smallAvatar?: boolean
onReplyClick?: (comment: Comment) => void
setRef?: (ref: HTMLDivElement) => void
highlight?: boolean
}) {
const { comment, truncate, onReplyClick, group, setRef, highlight, user } =
props
const { comment, onReplyClick, group, setRef, highlight, user } = props
const { text, userUsername, userName, userAvatarUrl, createdTime } = comment
const isCreatorsComment = user && comment.userId === user.id
return (
<Row
<Col
ref={setRef}
className={clsx(
comment.userId === user?.id ? 'mr-2 self-end' : ' ml-2',
'w-fit space-x-1.5 rounded-md bg-white p-2 px-4 transition-all duration-1000 sm:space-x-3',
isCreatorsComment ? 'mr-2 self-end' : '',
'w-fit max-w-sm gap-1 space-x-3 rounded-md bg-white p-1 text-sm text-gray-500 transition-colors duration-1000 sm:max-w-md sm:p-3 sm:leading-[1.3rem]',
highlight ? `-m-1 bg-indigo-500/[0.2] p-2` : ''
)}
>
<Avatar
className={'ml-1'}
size={'sm'}
username={userUsername}
avatarUrl={userAvatarUrl}
/>
<div className="w-full">
<div className="mt-0.5 pl-0.5 text-sm text-gray-500">
<UserLink
className="text-gray-500"
username={userUsername}
name={userName}
/>{' '}
<CopyLinkDateTimeComponent
prefix={'group'}
slug={group.slug}
createdTime={createdTime}
elementId={comment.id}
/>
</div>
<Row className={'items-center'}>
{!isCreatorsComment && (
<Col>
<Avatar
className={'mx-2 ml-2.5'}
size={'xs'}
username={userUsername}
avatarUrl={userAvatarUrl}
/>
</Col>
)}
{!isCreatorsComment ? (
<UserLink username={userUsername} name={userName} />
) : (
<span className={'ml-2.5'}>{'You'}</span>
)}
<CopyLinkDateTimeComponent
prefix={'group'}
slug={group.slug}
createdTime={createdTime}
elementId={comment.id}
/>
</Row>
<Row className={'text-black'}>
<TruncatedComment
comment={text}
moreHref={groupPath(group.slug)}
shouldTruncate={truncate}
shouldTruncate={false}
/>
{onReplyClick && (
<button
className={'text-xs font-bold text-gray-500 hover:underline'}
onClick={() => onReplyClick(comment)}
>
Reply
</button>
)}
</div>
</Row>
</Row>
{!isCreatorsComment && onReplyClick && (
<button
className={
'self-start py-1 text-xs font-bold text-gray-500 hover:underline'
}
onClick={() => onReplyClick(comment)}
>
Reply
</button>
)}
</Col>
)
})

View File

@ -28,6 +28,7 @@ export function Tabs(props: {
{tabs.map((tab, i) => (
<Link href={tab.href ?? '#'} key={tab.title} shallow={!!tab.href}>
<a
id={`tab-${i}`}
key={tab.title}
onClick={(e) => {
if (!tab.href) {

View File

@ -0,0 +1,69 @@
import clsx from 'clsx'
import { formatMoney } from 'common/util/format'
import { fromNow } from 'web/lib/util/time'
import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row'
export type ManalinkInfo = {
expiresTime: number | null
maxUses: number | null
uses: number
amount: number
message: string
}
export function ManalinkCard(props: {
className?: string
info: ManalinkInfo
defaultMessage: string
isClaiming: boolean
onClaim?: () => void
}) {
const { className, defaultMessage, isClaiming, info, onClaim } = props
const { expiresTime, maxUses, uses, amount, message } = info
return (
<div
className={clsx(
className,
'min-h-20 group flex flex-col rounded-xl bg-gradient-to-br from-indigo-200 via-indigo-400 to-indigo-800 shadow-lg transition-all'
)}
>
<Col className="mx-4 mt-2 -mb-4 text-right text-sm text-gray-100">
<div>
{maxUses != null
? `${maxUses - uses}/${maxUses} uses left`
: `Unlimited use`}
</div>
<div>
{expiresTime != null
? `Expires ${fromNow(expiresTime)}`
: 'Never expires'}
</div>
</Col>
<img
className="mb-6 block self-center transition-all group-hover:rotate-12"
src="/logo-white.svg"
width={200}
height={200}
/>
<Row className="justify-end rounded-b-xl bg-white p-4">
<Col>
<div className="mb-1 text-xl text-indigo-500">
{formatMoney(amount)}
</div>
<div>{message || defaultMessage}</div>
</Col>
<div className="ml-auto">
<button
className={clsx('btn', isClaiming ? 'loading disabled' : '')}
onClick={onClaim}
>
{isClaiming ? '' : 'Claim'}
</button>
</div>
</Row>
</div>
)
}

View File

@ -6,9 +6,6 @@ import {
CashIcon,
HeartIcon,
PresentationChartLineIcon,
PresentationChartBarIcon,
SparklesIcon,
NewspaperIcon,
UserGroupIcon,
ChevronDownIcon,
TrendingUpIcon,
@ -21,13 +18,8 @@ import { firebaseLogout, User } from 'web/lib/firebase/users'
import { ManifoldLogo } from './manifold-logo'
import { MenuButton } from './menu'
import { ProfileSummary } from './profile-menu'
import {
getUtcFreeMarketResetTime,
useHasCreatedContractToday,
} from 'web/hooks/use-has-created-contract-today'
import { Row } from '../layout/row'
import NotificationsIcon from 'web/components/notifications-icon'
import React, { useEffect, useState } from 'react'
import React from 'react'
import { IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
import { CreateQuestionButton } from 'web/components/create-question-button'
import { useMemberGroups } from 'web/hooks/use-group'
@ -35,13 +27,6 @@ import { groupPath } from 'web/lib/firebase/groups'
import { trackCallback, withTracking } from 'web/lib/service/analytics'
import { Group } from 'common/group'
// Create an icon from the url of an image
function IconFromUrl(url: string): React.ComponentType<{ className?: string }> {
return function Icon(props) {
return <img src={url} className={clsx(props.className, 'h-6 w-6')} />
}
}
function getNavigation(username: string) {
return [
{ name: 'Home', href: '/home', icon: HomeIcon },
@ -56,7 +41,9 @@ function getNavigation(username: string) {
icon: NotificationsIcon,
},
{ name: 'Get M$', href: '/add-funds', icon: CashIcon },
...(IS_PRIVATE_MANIFOLD
? []
: [{ name: 'Get M$', href: '/add-funds', icon: CashIcon }]),
]
}
@ -69,6 +56,7 @@ function getMoreNavigation(user?: User | null) {
return [
{ name: 'Leaderboards', href: '/leaderboards' },
{ name: 'Charity', href: '/charity' },
{ name: 'Blog', href: 'https://news.manifold.markets' },
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
{ name: 'Twitter', href: 'https://twitter.com/ManifoldMarkets' },
]
@ -82,7 +70,11 @@ function getMoreNavigation(user?: User | null) {
{ name: 'Twitter', href: 'https://twitter.com/ManifoldMarkets' },
{ name: 'Statistics', href: '/stats' },
{ name: 'About', href: 'https://docs.manifold.markets/$how-to' },
{ name: 'Sign out', href: '#', onClick: () => firebaseLogout() },
{
name: 'Sign out',
href: '#',
onClick: withTracking(firebaseLogout, 'sign out'),
},
]
}
@ -100,22 +92,6 @@ const signedOutNavigation = [
const signedOutMobileNavigation = [
{ name: 'Charity', href: '/charity', icon: HeartIcon },
{ name: 'Leaderboards', href: '/leaderboards', icon: TrendingUpIcon },
{ name: 'Blog', href: 'https://news.manifold.markets', icon: NewspaperIcon },
{
name: 'Discord',
href: 'https://discord.gg/eHQBNBqXuh',
icon: IconFromUrl('/discord-logo.svg'),
},
{
name: 'Twitter',
href: 'https://twitter.com/ManifoldMarkets',
icon: IconFromUrl('/twitter-logo.svg'),
},
{
name: 'Statistics',
href: '/stats',
icon: PresentationChartBarIcon,
},
{
name: 'About',
href: 'https://docs.manifold.markets/$how-to',
@ -124,7 +100,9 @@ const signedOutMobileNavigation = [
]
const signedInMobileNavigation = [
{ name: 'Get M$', href: '/add-funds', icon: CashIcon },
...(IS_PRIVATE_MANIFOLD
? []
: [{ name: 'Get M$', href: '/add-funds', icon: CashIcon }]),
...signedOutMobileNavigation,
]
@ -197,28 +175,8 @@ export default function Sidebar(props: { className?: string }) {
const { className } = props
const router = useRouter()
const currentPage = router.pathname
const [countdown, setCountdown] = useState('...')
useEffect(() => {
const nextUtcResetTime = getUtcFreeMarketResetTime({ previousTime: false })
const interval = setInterval(() => {
const now = new Date().getTime()
const timeUntil = nextUtcResetTime - now
const hoursUntil = timeUntil / 1000 / 60 / 60
const minutesUntil = (hoursUntil * 60) % 60
const secondsUntil = Math.round((hoursUntil * 60 * 60) % 60)
const timeString =
hoursUntil < 1 && minutesUntil < 1
? `${secondsUntil}s`
: hoursUntil < 1
? `${Math.round(minutesUntil)}m`
: `${Math.floor(hoursUntil)}h`
setCountdown(timeString)
}, 1000)
return () => clearInterval(interval)
}, [])
const user = useUser()
const mustWaitForFreeMarketStatus = useHasCreatedContractToday(user)
const navigationOptions = !user
? signedOutNavigation
: getNavigation(user?.username || 'error')
@ -239,6 +197,7 @@ export default function Sidebar(props: { className?: string }) {
</div>
)}
{/* Mobile navigation */}
<div className="space-y-1 lg:hidden">
{user && (
<MenuButton
@ -261,6 +220,22 @@ export default function Sidebar(props: { className?: string }) {
{user && (
<MenuButton
menuItems={[
{
name: 'Blog',
href: 'https://news.manifold.markets',
},
{
name: 'Discord',
href: 'https://discord.gg/eHQBNBqXuh',
},
{
name: 'Twitter',
href: 'https://twitter.com/ManifoldMarkets',
},
{
name: 'Statistics',
href: '/stats',
},
{
name: 'Sign out',
href: '#',
@ -272,6 +247,7 @@ export default function Sidebar(props: { className?: string }) {
)}
</div>
{/* Desktop navigation */}
<div className="hidden space-y-1 lg:block">
{navigationOptions.map((item) =>
item.name === 'Notifications' ? (
@ -304,27 +280,6 @@ export default function Sidebar(props: { className?: string }) {
/>
</div>
<CreateQuestionButton user={user} />
{user &&
mustWaitForFreeMarketStatus != 'loading' &&
mustWaitForFreeMarketStatus ? (
<Row className="mt-2 justify-center">
<Row className="gap-1 text-sm text-gray-400">
Next free question in {countdown}
</Row>
</Row>
) : (
user &&
mustWaitForFreeMarketStatus != 'loading' &&
!mustWaitForFreeMarketStatus && (
<Row className="mt-2 justify-center">
<Row className="gap-1 text-sm text-indigo-400">
Daily free question
<SparklesIcon className="mt-0.5 h-4 w-4" aria-hidden="true" />
</Row>
</Row>
)
)}
</nav>
)
}

View File

@ -0,0 +1,83 @@
import { ResponsiveLine } from '@nivo/line'
import { PortfolioMetrics } from 'common/user'
import { formatMoney } from 'common/util/format'
import { DAY_MS } from 'common/util/time'
import { last } from 'lodash'
import { memo } from 'react'
import { useWindowSize } from 'web/hooks/use-window-size'
import { formatTime } from 'web/lib/util/time'
export const PortfolioValueGraph = memo(function PortfolioValueGraph(props: {
portfolioHistory: PortfolioMetrics[]
height?: number
period?: string
}) {
const { portfolioHistory, height, period } = props
const { width } = useWindowSize()
const portfolioHistoryFiltered = portfolioHistory.filter((p) => {
switch (period) {
case 'daily':
return p.timestamp > Date.now() - 1 * DAY_MS
case 'weekly':
return p.timestamp > Date.now() - 7 * DAY_MS
case 'monthly':
return p.timestamp > Date.now() - 30 * DAY_MS
case 'allTime':
return true
default:
return true
}
})
const points = portfolioHistoryFiltered.map((p) => {
return {
x: new Date(p.timestamp),
y: p.balance + p.investmentValue,
}
})
const data = [{ id: 'Value', data: points, color: '#11b981' }]
const numXTickValues = !width || width < 800 ? 2 : 5
const numYTickValues = 4
const endDate = last(points)?.x
const includeTime = period === 'daily'
return (
<div
className="w-full overflow-hidden"
style={{ height: height ?? (!width || width >= 800 ? 350 : 250) }}
>
<ResponsiveLine
data={data}
margin={{ top: 20, right: 28, bottom: 22, left: 60 }}
xScale={{
type: 'time',
min: points[0].x,
max: endDate,
}}
yScale={{
type: 'linear',
stacked: false,
min: Math.min(...points.map((p) => p.y)),
}}
gridYValues={numYTickValues}
curve="monotoneX"
colors={{ datum: 'color' }}
axisBottom={{
tickValues: numXTickValues,
format: (time) => formatTime(+time, includeTime),
}}
pointBorderColor="#fff"
pointSize={points.length > 100 ? 0 : 6}
axisLeft={{
tickValues: numYTickValues,
format: (value) => formatMoney(value),
}}
enableGridX={!!width && width >= 800}
enableGridY={true}
enableSlices="x"
animate={false}
></ResponsiveLine>
</div>
)
})

View File

@ -0,0 +1,47 @@
import { PortfolioMetrics } from 'common/user'
import { formatMoney } from 'common/util/format'
import { last } from 'lodash'
import { memo, useState } from 'react'
import { Period } from 'web/lib/firebase/users'
import { Col } from '../layout/col'
import { Row } from '../layout/row'
import { PortfolioValueGraph } from './portfolio-value-graph'
export const PortfolioValueSection = memo(
function PortfolioValueSection(props: {
portfolioHistory: PortfolioMetrics[]
}) {
const { portfolioHistory } = props
const lastPortfolioMetrics = last(portfolioHistory)
const [portfolioPeriod] = useState<Period>('allTime')
if (portfolioHistory.length === 0 || !lastPortfolioMetrics) {
return <div> No portfolio history data yet </div>
}
return (
<div>
<Row className="gap-8">
<div className="mb-4 w-full">
<Col>
<div className="text-sm text-gray-500">Portfolio value</div>
<div className="text-lg">
{formatMoney(
lastPortfolioMetrics.balance +
lastPortfolioMetrics.investmentValue
)}
</div>
</Col>
</div>
{
//TODO: enable day/week/monthly as data becomes available
}
</Row>
<PortfolioValueGraph
portfolioHistory={portfolioHistory}
period={portfolioPeriod}
/>
</div>
)
}
)

View File

@ -7,8 +7,8 @@ import clsx from 'clsx'
import { Comment } from 'common/comment'
import { User } from 'common/user'
import { formatMoney } from 'common/util/format'
import { debounce, sumBy } from 'lodash'
import { useEffect, useMemo, useRef, useState } from 'react'
import { debounce, sum } from 'lodash'
import { useEffect, useRef, useState } from 'react'
import { CommentTips } from 'web/hooks/use-tip-txns'
import { useUser } from 'web/hooks/use-user'
import { transact } from 'web/lib/firebase/fn-call'
@ -16,33 +16,24 @@ import { track } from 'web/lib/service/analytics'
import { Row } from './layout/row'
import { Tooltip } from './tooltip'
// xth triangle number * 5 = 5 + 10 + 15 + ... + (x * 5)
const quad = (x: number) => (5 / 2) * x * (x + 1)
// inverse (see https://math.stackexchange.com/questions/2041988/how-to-get-inverse-of-formula-for-sum-of-integers-from-1-to-nsee )
const invQuad = (y: number) => Math.sqrt((2 / 5) * y + 1 / 4) - 1 / 2
export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
const { comment, tips } = prop
const me = useUser()
const myId = me?.id ?? ''
const savedTip = tips[myId] as number | undefined
const savedTip = tips[myId] ?? 0
// optimistically increase the tip count, but debounce the update
const [localTip, setLocalTip] = useState(savedTip ?? 0)
const [localTip, setLocalTip] = useState(savedTip)
// listen for user being set
const initialized = useRef(false)
useEffect(() => {
if (savedTip && !initialized.current) {
setLocalTip(savedTip)
if (tips[myId] && !initialized.current) {
setLocalTip(tips[myId])
initialized.current = true
}
}, [savedTip])
}, [tips, myId])
const score = useMemo(() => {
const tipVals = Object.values({ ...tips, [myId]: localTip })
return sumBy(tipVals, invQuad)
}, [localTip, tips, myId])
const total = sum(Object.values(tips)) - savedTip + localTip
// declare debounced function only on first render
const [saveTip] = useState(() =>
@ -80,7 +71,7 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
const changeTip = (tip: number) => {
setLocalTip(tip)
me && saveTip(me, tip - (savedTip ?? 0))
me && saveTip(me, tip - savedTip)
}
return (
@ -88,13 +79,13 @@ export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
<DownTip
value={localTip}
onChange={changeTip}
disabled={!me || localTip <= 0}
disabled={!me || localTip <= savedTip}
/>
<span className="font-bold">{Math.floor(score)} </span>
<span className="font-bold">{Math.floor(total)}</span>
<UpTip
value={localTip}
onChange={changeTip}
disabled={!me || me.id === comment.userId}
disabled={!me || me.id === comment.userId || me.balance < localTip + 5}
/>
{localTip === 0 ? (
''
@ -118,16 +109,15 @@ function DownTip(prop: {
disabled?: boolean
}) {
const { onChange, value, disabled } = prop
const marginal = 5 * invQuad(value)
return (
<Tooltip
className="tooltip-bottom"
text={!disabled && `Refund ${formatMoney(marginal)}`}
text={!disabled && `-${formatMoney(5)}`}
>
<button
className="flex h-max items-center hover:text-red-600 disabled:text-gray-300"
disabled={disabled}
onClick={() => onChange(value - marginal)}
onClick={() => onChange(value - 5)}
>
<ChevronLeftIcon className="h-6 w-6" />
</button>
@ -141,19 +131,18 @@ function UpTip(prop: {
disabled?: boolean
}) {
const { onChange, value, disabled } = prop
const marginal = 5 * invQuad(value) + 5
return (
<Tooltip
className="tooltip-bottom"
text={!disabled && `Tip ${formatMoney(marginal)}`}
text={!disabled && `Tip ${formatMoney(5)}`}
>
<button
className="hover:text-primary flex h-max items-center disabled:text-gray-300"
disabled={disabled}
onClick={() => onChange(value + marginal)}
onClick={() => onChange(value + 5)}
>
{value >= quad(2) ? (
{value >= 10 ? (
<ChevronDoubleRightIcon className="text-primary mx-1 h-6 w-6" />
) : value > 0 ? (
<ChevronRightIcon className="text-primary h-6 w-6" />

View File

@ -1,9 +1,17 @@
import clsx from 'clsx'
import { uniq } from 'lodash'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import { LinkIcon } from '@heroicons/react/solid'
import { PencilIcon } from '@heroicons/react/outline'
import Confetti from 'react-confetti'
import { follow, unfollow, User } from 'web/lib/firebase/users'
import {
follow,
unfollow,
User,
getPortfolioHistory,
} from 'web/lib/firebase/users'
import { CreatorContractsList } from './contract/contracts-list'
import { SEO } from './SEO'
import { Page } from './page'
@ -16,7 +24,7 @@ import { Row } from './layout/row'
import { genHash } from 'common/util/random'
import { Tabs } from './layout/tabs'
import { UserCommentsList } from './comments-list'
import { useEffect, useState } from 'react'
import { useWindowSize } from 'web/hooks/use-window-size'
import { Comment, getUsersComments } from 'web/lib/firebase/comments'
import { Contract } from 'common/contract'
import { getContractFromId, listContracts } from 'web/lib/firebase/contracts'
@ -27,7 +35,7 @@ import { getUserBets } from 'web/lib/firebase/bets'
import { FollowersButton, FollowingButton } from './following-button'
import { useFollows } from 'web/hooks/use-follows'
import { FollowButton } from './follow-button'
import { useRouter } from 'next/router'
import { PortfolioMetrics } from 'common/user'
export function UserLink(props: {
name: string
@ -57,6 +65,7 @@ export function UserPage(props: {
defaultTabTitle?: string | undefined
}) {
const { user, currentUser, defaultTabTitle } = props
const router = useRouter()
const isCurrentUser = user.id === currentUser?.id
const bannerUrl = user.bannerUrl ?? defaultBannerUrl(user.id)
const [usersComments, setUsersComments] = useState<Comment[]>([] as Comment[])
@ -64,16 +73,24 @@ export function UserPage(props: {
'loading'
)
const [usersBets, setUsersBets] = useState<Bet[] | 'loading'>('loading')
const [, setUsersPortfolioHistory] = useState<PortfolioMetrics[]>([])
const [commentsByContract, setCommentsByContract] = useState<
Map<Contract, Comment[]> | 'loading'
>('loading')
const router = useRouter()
const [showConfetti, setShowConfetti] = useState(false)
const { width, height } = useWindowSize()
useEffect(() => {
const claimedMana = router.query['claimed-mana'] === 'yes'
setShowConfetti(claimedMana)
}, [router])
useEffect(() => {
if (!user) return
getUsersComments(user.id).then(setUsersComments)
listContracts(user.id).then(setUsersContracts)
getUserBets(user.id, { includeRedemptions: false }).then(setUsersBets)
getPortfolioHistory(user.id).then(setUsersPortfolioHistory)
}, [user])
// TODO: display comments on groups
@ -117,7 +134,14 @@ export function UserPage(props: {
description={user.bio ?? ''}
url={`/${user.username}`}
/>
{showConfetti && (
<Confetti
width={width ? width : 500}
height={height ? height : 500}
recycle={false}
numberOfPieces={300}
/>
)}
{/* Banner image up top, with an circle avatar overlaid */}
<div
className="h-32 w-full bg-cover bg-center sm:h-40"
@ -227,6 +251,7 @@ export function UserPage(props: {
</Col>
<Spacer h={10} />
{usersContracts !== 'loading' && commentsByContract != 'loading' ? (
<Tabs
className={'pb-2 pt-1 '}
@ -268,6 +293,9 @@ export function UserPage(props: {
title: 'Bets',
content: (
<div>
{
// TODO: add portfolio-value-section here
}
<BetsList
user={user}
hideBetsBefore={isCurrentUser ? 0 : JUNE_1_2022}

View File

@ -0,0 +1,38 @@
/* This example requires Tailwind CSS v2.0+ */
import { Switch } from '@headlessui/react'
import clsx from 'clsx'
export default function ShortToggle(props: {
enabled: boolean
setEnabled: (enabled: boolean) => void
}) {
const { enabled, setEnabled } = props
return (
<Switch
checked={enabled}
onChange={setEnabled}
className="group relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
<span className="sr-only">Use setting</span>
<span
aria-hidden="true"
className="pointer-events-none absolute h-full w-full rounded-md bg-white"
/>
<span
aria-hidden="true"
className={clsx(
enabled ? 'bg-indigo-600' : 'bg-gray-200',
'pointer-events-none absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out'
)}
/>
<span
aria-hidden="true"
className={clsx(
enabled ? 'translate-x-5' : 'translate-x-0',
'pointer-events-none absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow ring-0 transition-transform duration-200 ease-in-out'
)}
/>
</Switch>
)
}

View File

@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import {
Comment,
listenForCommentsOnContract,
listenForCommentsOnGroup,
listenForRecentComments,
} from 'web/lib/firebase/comments'
@ -14,6 +15,15 @@ export const useComments = (contractId: string) => {
return comments
}
export const useCommentsOnGroup = (groupId: string | undefined) => {
const [comments, setComments] = useState<Comment[] | undefined>()
useEffect(() => {
if (groupId) return listenForCommentsOnGroup(groupId, setComments)
}, [groupId])
return comments
}
export const useRecentComments = () => {
const [recentComments, setRecentComments] = useState<Comment[] | undefined>()

View File

@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { Group } from 'common/group'
import { User } from 'common/user'
import {
getGroupsWithContractId,
listenForGroup,
listenForGroups,
listenForMemberGroups,
@ -64,15 +65,24 @@ export const useMemberGroupIds = (user: User | null | undefined) => {
export function useMembers(group: Group) {
const [members, setMembers] = useState<User[]>([])
useEffect(() => {
const { memberIds, creatorId } = group
if (memberIds.length > 1)
// get users via their user ids:
Promise.all(
memberIds.filter((mId) => mId !== creatorId).map(getUser)
).then((users) => {
const members = users.filter((user) => user)
setMembers(members)
})
const { memberIds } = group
if (memberIds.length > 0) {
listMembers(group).then((members) => setMembers(members))
}
}, [group])
return members
}
export async function listMembers(group: Group) {
return await Promise.all(group.memberIds.map(getUser))
}
export const useGroupsWithContract = (contractId: string | undefined) => {
const [groups, setGroups] = useState<Group[] | null | undefined>()
useEffect(() => {
if (contractId) getGroupsWithContractId(contractId, setGroups)
}, [contractId])
return groups
}

View File

@ -20,6 +20,16 @@ export function checkAgainstQuery(query: string, corpus: string) {
return queryWords.every((word) => corpus.toLowerCase().includes(word))
}
export function getSavedSort() {
// TODO: this obviously doesn't work with SSR, common sense would suggest
// that we should save things like this in cookies so the server has them
if (typeof window !== 'undefined') {
return localStorage.getItem(MARKETS_SORT) as Sort | null
} else {
return null
}
}
export function useInitialQueryAndSort(options?: {
defaultSort: Sort
shouldLoadFromStorage?: boolean
@ -45,7 +55,7 @@ export function useInitialQueryAndSort(options?: {
if (!sort && shouldLoadFromStorage) {
console.log('ready loading from storage ', sort ?? defaultSort)
const localSort = localStorage.getItem(MARKETS_SORT) as Sort
const localSort = getSavedSort()
if (localSort) {
router.query.s = localSort
// Use replace to not break navigating back.

View File

@ -2,7 +2,6 @@ import { NextApiRequest, NextApiResponse } from 'next'
import { promisify } from 'util'
import { pipeline } from 'stream'
import { getFunctionUrl } from 'web/lib/firebase/api-call'
import { V2CloudFunction } from 'common/envs/prod'
import fetch, { Headers, Response } from 'node-fetch'
function getProxiedRequestHeaders(req: NextApiRequest, whitelist: string[]) {
@ -33,7 +32,7 @@ function getProxiedResponseHeaders(res: Response, whitelist: string[]) {
return result
}
export const fetchBackend = (req: NextApiRequest, name: V2CloudFunction) => {
export const fetchBackend = (req: NextApiRequest, name: string) => {
const url = getFunctionUrl(name)
const headers = getProxiedRequestHeaders(req, [
'Authorization',

View File

@ -1,6 +1,5 @@
import { auth } from './users'
import { ENV_CONFIG } from 'common/envs/constants'
import { V2CloudFunction } from 'common/envs/prod'
export class APIError extends Error {
code: number
@ -41,8 +40,9 @@ export async function call(url: string, method: string, params: any) {
// app just hit the cloud functions directly -- there's no difference and it's
// one less hop
export function getFunctionUrl(name: V2CloudFunction) {
return ENV_CONFIG.functionEndpoints[name]
export function getFunctionUrl(name: string) {
const { cloudRunId, cloudRunRegion } = ENV_CONFIG
return `https://${name}-${cloudRunId}-${cloudRunRegion}.a.run.app`
}
export function createMarket(params: any) {

View File

@ -5,7 +5,7 @@ import {
where,
orderBy,
} from 'firebase/firestore'
import { range, uniq } from 'lodash'
import { uniq } from 'lodash'
import { db } from './init'
import { Bet } from 'common/bet'
@ -136,24 +136,3 @@ export function withoutAnteBets(contract: Contract, bets?: Bet[]) {
return bets?.filter((bet) => !bet.isAnte) ?? []
}
const getBetsQuery = (startTime: number, endTime: number) =>
query(
collectionGroup(db, 'bets'),
where('createdTime', '>=', startTime),
where('createdTime', '<', endTime),
orderBy('createdTime', 'asc')
)
export async function getDailyBets(startTime: number, numberOfDays: number) {
const query = getBetsQuery(startTime, startTime + DAY_IN_MS * numberOfDays)
const bets = await getValues<Bet>(query)
const betsByDay = range(0, numberOfDays).map(() => [] as Bet[])
for (const bet of bets) {
const dayIndex = Math.floor((bet.createdTime - startTime) / DAY_IN_MS)
betsByDay[dayIndex].push(bet)
}
return betsByDay
}

View File

@ -7,7 +7,6 @@ import {
setDoc,
where,
} from 'firebase/firestore'
import { range } from 'lodash'
import { getValues, listenForValues } from './utils'
import { db } from './init'
@ -136,33 +135,6 @@ export function listenForRecentComments(
return listenForValues<Comment>(recentCommentsQuery, setComments)
}
const getCommentsQuery = (startTime: number, endTime: number) =>
query(
collectionGroup(db, 'comments'),
where('createdTime', '>=', startTime),
where('createdTime', '<', endTime),
orderBy('createdTime', 'asc')
)
export async function getDailyComments(
startTime: number,
numberOfDays: number
) {
const query = getCommentsQuery(
startTime,
startTime + DAY_IN_MS * numberOfDays
)
const comments = await getValues<Comment>(query)
const commentsByDay = range(0, numberOfDays).map(() => [] as Comment[])
for (const comment of comments) {
const dayIndex = Math.floor((comment.createdTime - startTime) / DAY_IN_MS)
commentsByDay[dayIndex].push(comment)
}
return commentsByDay
}
const getUsersCommentsQuery = (userId: string) =>
query(
collectionGroup(db, 'comments'),

View File

@ -14,7 +14,7 @@ import {
limit,
startAfter,
} from 'firebase/firestore'
import { range, sortBy, sum } from 'lodash'
import { sortBy, sum } from 'lodash'
import { app } from './init'
import { getValues, listenForValue, listenForValues } from './utils'
@ -303,35 +303,6 @@ export async function getClosingSoonContracts() {
)
}
const getContractsQuery = (startTime: number, endTime: number) =>
query(
collection(db, 'contracts'),
where('createdTime', '>=', startTime),
where('createdTime', '<', endTime),
orderBy('createdTime', 'asc')
)
const DAY_IN_MS = 24 * 60 * 60 * 1000
export async function getDailyContracts(
startTime: number,
numberOfDays: number
) {
const query = getContractsQuery(
startTime,
startTime + DAY_IN_MS * numberOfDays
)
const contracts = await getValues<Contract>(query)
const contractsByDay = range(0, numberOfDays).map(() => [] as Contract[])
for (const contract of contracts) {
const dayIndex = Math.floor((contract.createdTime - startTime) / DAY_IN_MS)
contractsByDay[dayIndex].push(contract)
}
return contractsByDay
}
export async function getRecentBetsAndComments(contract: Contract) {
const contractDoc = doc(db, 'contracts', contract.id)

View File

@ -68,3 +68,8 @@ export const addLiquidity = (data: { amount: number; contractId: string }) => {
.then((r) => r.data as { status: string })
.catch((e) => ({ status: 'error', message: e.message }))
}
export const claimManalink = cloudFunction<
string,
{ status: 'error' | 'success'; message?: string }
>('claimManalink')

View File

@ -17,7 +17,7 @@ const groupCollection = collection(db, 'groups')
export function groupPath(
groupSlug: string,
subpath?: 'edit' | 'questions' | 'details' | 'discussion'
subpath?: 'edit' | 'questions' | 'about' | 'chat'
) {
return `/group/${groupSlug}${subpath ? `/${subpath}` : ''}`
}
@ -82,3 +82,15 @@ export function listenForMemberGroups(
setGroups(sorted)
})
}
export async function getGroupsWithContractId(
contractId: string,
setGroups: (groups: Group[]) => void
) {
const q = query(
groupCollection,
where('contractIds', 'array-contains', contractId)
)
const groups = await getValues<Group>(q)
setGroups(groups)
}

View File

@ -0,0 +1,94 @@
import {
collection,
getDoc,
orderBy,
query,
setDoc,
where,
} from 'firebase/firestore'
import { doc } from 'firebase/firestore'
import { Manalink } from '../../../common/manalink'
import { db } from './init'
import { customAlphabet } from 'nanoid'
import { listenForValues } from './utils'
import { useEffect, useState } from 'react'
export async function createManalink(data: {
fromId: string
amount: number
expiresTime: number | null
maxUses: number | null
message: string
}) {
const { fromId, amount, expiresTime, maxUses, message } = data
// At 100 IDs per hour, using this alphabet and 8 chars, there's a 1% chance of collision in 2 years
// See https://zelark.github.io/nano-id-cc/
const nanoid = customAlphabet(
'0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ',
8
)
const slug = nanoid()
if (amount <= 0 || isNaN(amount) || !isFinite(amount)) return null
const manalink: Manalink = {
slug,
fromId,
amount,
token: 'M$',
createdTime: Date.now(),
expiresTime,
maxUses,
claimedUserIds: [],
claims: [],
message,
}
const ref = doc(db, 'manalinks', slug)
await setDoc(ref, manalink)
return slug
}
const manalinkCol = collection(db, 'manalinks')
// TODO: This required an index, make sure to also set up in prod
function listUserManalinks(fromId?: string) {
return query(
manalinkCol,
where('fromId', '==', fromId),
orderBy('createdTime', 'desc')
)
}
export async function getManalink(slug: string) {
const docSnap = await getDoc(doc(db, 'manalinks', slug))
return docSnap.data() as Manalink
}
export function useManalink(slug: string) {
const [manalink, setManalink] = useState<Manalink | null>(null)
useEffect(() => {
if (slug) {
getManalink(slug).then(setManalink)
}
}, [slug])
return manalink
}
export function listenForUserManalinks(
fromId: string | undefined,
setLinks: (links: Manalink[]) => void
) {
return listenForValues<Manalink>(listUserManalinks(fromId), setLinks)
}
export const useUserManalinks = (fromId: string) => {
const [links, setLinks] = useState<Manalink[]>([])
useEffect(() => {
return listenForUserManalinks(fromId, setLinks)
}, [fromId])
return links
}

15
web/lib/firebase/stats.ts Normal file
View File

@ -0,0 +1,15 @@
import {
CollectionReference,
doc,
collection,
getDoc,
} from 'firebase/firestore'
import { db } from 'web/lib/firebase/init'
import { Stats } from 'common/stats'
const statsCollection = collection(db, 'stats') as CollectionReference<Stats>
const statsDoc = doc(statsCollection, 'stats')
export const getStats = async () => {
return (await getDoc(statsDoc)).data()
}

View File

@ -1,7 +1,9 @@
import { DonationTxn, TipTxn } from 'common/txn'
import { ManalinkTxn, DonationTxn, TipTxn } from 'common/txn'
import { collection, orderBy, query, where } from 'firebase/firestore'
import { db } from './init'
import { getValues, listenForValues } from './utils'
import { useState, useEffect } from 'react'
import { orderBy as _orderBy } from 'lodash'
const txnCollection = collection(db, 'txns')
@ -39,3 +41,29 @@ export function listenForTipTxns(
) {
return listenForValues<TipTxn>(getTipsQuery(contractId), setTxns)
}
// Find all manalink Txns that are from or to this user
export function useManalinkTxns(userId: string) {
const [fromTxns, setFromTxns] = useState<ManalinkTxn[]>([])
const [toTxns, setToTxns] = useState<ManalinkTxn[]>([])
useEffect(() => {
// TODO: Need to instantiate these indexes too
const fromQuery = query(
txnCollection,
where('fromId', '==', userId),
where('category', '==', 'MANALINK'),
orderBy('createdTime', 'desc')
)
const toQuery = query(
txnCollection,
where('toId', '==', userId),
where('category', '==', 'MANALINK'),
orderBy('createdTime', 'desc')
)
listenForValues(fromQuery, setFromTxns)
listenForValues(toQuery, setToTxns)
}, [userId])
return _orderBy([...fromTxns, ...toTxns], ['createdTime'], ['desc'])
}

View File

@ -21,13 +21,12 @@ import {
GoogleAuthProvider,
signInWithPopup,
} from 'firebase/auth'
import { range, throttle, zip } from 'lodash'
import { throttle, zip } from 'lodash'
import { app } from './init'
import { PrivateUser, User } from 'common/user'
import { PortfolioMetrics, PrivateUser, User } from 'common/user'
import { createUser } from './fn-call'
import { getValue, getValues, listenForValue, listenForValues } from './utils'
import { DAY_MS } from 'common/util/time'
import { feed } from 'common/feed'
import { CATEGORY_LIST } from 'common/categories'
import { safeLocalStorage } from '../util/local'
@ -35,7 +34,7 @@ import { filterDefined } from 'common/util/array'
export type { User }
export type LeaderboardPeriod = 'daily' | 'weekly' | 'monthly' | 'allTime'
export type Period = 'daily' | 'weekly' | 'monthly' | 'allTime'
const db = getFirestore(app)
export const auth = getAuth(app)
@ -128,7 +127,7 @@ export function listenForLogin(onUser: (user: User | null) => void) {
export async function firebaseLogin() {
const provider = new GoogleAuthProvider()
signInWithPopup(auth, provider)
return signInWithPopup(auth, provider)
}
export async function firebaseLogout() {
@ -180,7 +179,7 @@ export function listenForPrivateUsers(
listenForValues(q, setUsers)
}
export function getTopTraders(period: LeaderboardPeriod) {
export function getTopTraders(period: Period) {
const topTraders = query(
collection(db, 'users'),
orderBy('profitCached.' + period, 'desc'),
@ -190,7 +189,7 @@ export function getTopTraders(period: LeaderboardPeriod) {
return getValues(topTraders)
}
export function getTopCreators(period: LeaderboardPeriod) {
export function getTopCreators(period: Period) {
const topCreators = query(
collection(db, 'users'),
orderBy('creatorVolumeCached.' + period, 'desc'),
@ -214,30 +213,6 @@ export function getUsers() {
return getValues<User>(collection(db, 'users'))
}
const getUsersQuery = (startTime: number, endTime: number) =>
query(
collection(db, 'users'),
where('createdTime', '>=', startTime),
where('createdTime', '<', endTime),
orderBy('createdTime', 'asc')
)
export async function getDailyNewUsers(
startTime: number,
numberOfDays: number
) {
const query = getUsersQuery(startTime, startTime + DAY_MS * numberOfDays)
const users = await getValues<User>(query)
const usersByDay = range(0, numberOfDays).map(() => [] as User[])
for (const user of users) {
const dayIndex = Math.floor((user.createdTime - startTime) / DAY_MS)
usersByDay[dayIndex].push(user)
}
return usersByDay
}
export async function getUserFeed(userId: string) {
const feedDoc = doc(db, 'private-users', userId, 'cache', 'feed')
const userFeed = await getValue<{
@ -270,6 +245,16 @@ export async function unfollow(userId: string, unfollowedUserId: string) {
await deleteDoc(followDoc)
}
export async function getPortfolioHistory(userId: string) {
return getValues<PortfolioMetrics>(
query(
collectionGroup(db, 'portfolioHistory'),
where('userId', '==', userId),
orderBy('timestamp', 'asc')
)
)
}
export function listenForFollows(
userId: string,
setFollowIds: (followIds: string[]) => void

View File

@ -5,3 +5,13 @@ dayjs.extend(relativeTime)
export function fromNow(time: number) {
return dayjs(time).fromNow()
}
export function formatTime(time: number, includeTime: boolean) {
const d = dayjs(time)
if (d.isSame(Date.now(), 'day')) return d.format('ha')
if (includeTime) return dayjs(time).format('MMM D, ha')
return dayjs(time).format('MMM D')
}

View File

@ -2,6 +2,7 @@ const API_DOCS_URL = 'https://docs.manifold.markets/api'
/** @type {import('next').NextConfig} */
module.exports = {
staticPageGenerationTimeout: 600, // e.g. stats page
reactStrictMode: true,
experimental: {
externalDir: true,

View File

@ -32,6 +32,7 @@
"gridjs": "5.0.2",
"gridjs-react": "5.0.2",
"lodash": "4.17.21",
"nanoid": "^3.3.4",
"next": "12.1.2",
"node-fetch": "3.2.4",
"react": "17.0.2",
@ -40,7 +41,8 @@
"react-expanding-textarea": "2.3.5",
"react-hot-toast": "2.2.0",
"react-instantsearch-hooks-web": "6.24.1",
"react-query": "3.39.0"
"react-query": "3.39.0",
"string-similarity": "^4.0.4"
},
"devDependencies": {
"@tailwindcss/forms": "0.4.0",
@ -49,6 +51,7 @@
"@types/lodash": "4.14.178",
"@types/node": "16.11.11",
"@types/react": "17.0.43",
"@types/string-similarity": "^4.0.0",
"autoprefixer": "10.2.6",
"concurrently": "6.5.1",
"critters": "0.0.16",

View File

@ -43,6 +43,7 @@ import { CPMMBinaryContract } from 'common/contract'
import { AlertBox } from 'web/components/alert-box'
import { useTracking } from 'web/hooks/use-tracking'
import { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns'
import { useLiquidity } from 'web/hooks/use-liquidity'
export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: {
@ -118,6 +119,8 @@ export function ContractPageContent(
})
const bets = useBets(contract.id) ?? props.bets
const liquidityProvisions =
useLiquidity(contract.id)?.filter((l) => !l.isAnte && l.amount > 0) ?? []
// Sort for now to see if bug is fixed.
comments.sort((c1, c2) => c1.createdTime - c2.createdTime)
@ -237,6 +240,7 @@ export function ContractPageContent(
<ContractTabs
contract={contract}
user={user}
liquidityProvisions={liquidityProvisions}
bets={bets}
tips={tips}
comments={comments}

View File

@ -3,7 +3,9 @@ import { Answer } from 'common/answer'
import { getOutcomeProbability, getProbability } from 'common/calculate'
import { Comment } from 'common/comment'
import { Contract } from 'common/contract'
import { User } from 'common/user'
import { removeUndefinedProps } from 'common/util/object'
import { ENV_CONFIG } from 'common/envs/constants'
export type LiteMarket = {
// Unique identifer for this market
@ -24,7 +26,7 @@ export type LiteMarket = {
outcomeType: string
mechanism: string
pool: number
pool: { [outcome: string]: number }
probability?: number
p?: number
totalLiquidity?: number
@ -36,6 +38,7 @@ export type LiteMarket = {
isResolved: boolean
resolution?: string
resolutionTime?: number
resolutionProbability?: number
}
export type ApiAnswer = Answer & {
@ -73,6 +76,7 @@ export function toLiteMarket(contract: Contract): LiteMarket {
isResolved,
resolution,
resolutionTime,
resolutionProbability,
} = contract
const { p, totalLiquidity } = contract as any
@ -94,7 +98,7 @@ export function toLiteMarket(contract: Contract): LiteMarket {
description,
tags,
url: `https://manifold.markets/${creatorUsername}/${slug}`,
pool: pool.YES + pool.NO,
pool,
probability,
p,
totalLiquidity,
@ -106,6 +110,7 @@ export function toLiteMarket(contract: Contract): LiteMarket {
isResolved,
resolution,
resolutionTime,
resolutionProbability,
})
}
@ -140,3 +145,73 @@ function augmentAnswerWithProbability(
probability,
}
}
export type LiteUser = {
id: string
createdTime: number
name: string
username: string
url: string
avatarUrl?: string
bio?: string
bannerUrl?: string
website?: string
twitterHandle?: string
discordHandle?: string
balance: number
totalDeposits: number
profitCached: {
daily: number
weekly: number
monthly: number
allTime: number
}
creatorVolumeCached: {
daily: number
weekly: number
monthly: number
allTime: number
}
}
export function toLiteUser(user: User): LiteUser {
const {
id,
createdTime,
name,
username,
avatarUrl,
bio,
bannerUrl,
website,
twitterHandle,
discordHandle,
balance,
totalDeposits,
profitCached,
creatorVolumeCached,
} = user
return removeUndefinedProps({
id,
createdTime,
name,
username,
url: `https://${ENV_CONFIG.domain}/${username}`,
avatarUrl,
bio,
bannerUrl,
website,
twitterHandle,
discordHandle,
balance,
totalDeposits,
profitCached,
creatorVolumeCached,
})
}

17
web/pages/api/v0/users.ts Normal file
View File

@ -0,0 +1,17 @@
// Next.js API route support: https://vercel.com/docs/concepts/functions/serverless-functions
import type { NextApiRequest, NextApiResponse } from 'next'
import { listAllUsers } from 'web/lib/firebase/users'
import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors'
import { toLiteUser } from './_types'
type Data = any[]
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<Data>
) {
await applyCorsHeaders(req, res, CORS_UNRESTRICTED)
const users = await listAllUsers()
res.setHeader('Cache-Control', 'max-age=0')
res.status(200).json(users.map(toLiteUser))
}

View File

@ -0,0 +1,124 @@
import { Answer } from 'common/answer'
import { sortBy } from 'lodash'
import { useState } from 'react'
import { ContractsGrid } from 'web/components/contract/contracts-list'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { useContracts } from 'web/hooks/use-contracts'
import {
Sort,
useInitialQueryAndSort,
} from 'web/hooks/use-sort-and-query-params'
export default function ContractSearchFirestore(props: {
querySortOptions?: {
defaultSort: Sort
shouldLoadFromStorage?: boolean
}
additionalFilter?: {
creatorId?: string
tag?: string
}
}) {
const contracts = useContracts()
const { querySortOptions, additionalFilter } = props
const { initialSort, initialQuery } = useInitialQueryAndSort(querySortOptions)
const [sort, setSort] = useState(initialSort || 'newest')
const [query, setQuery] = useState(initialQuery)
const queryWords = query.toLowerCase().split(' ')
function check(corpus: string) {
return queryWords.every((word) => corpus.toLowerCase().includes(word))
}
let matches = (contracts ?? []).filter(
(c) =>
check(c.question) ||
check(c.description) ||
check(c.creatorName) ||
check(c.creatorUsername) ||
check(c.lowercaseTags.map((tag) => `#${tag}`).join(' ')) ||
check(
((c as any).answers ?? [])
.map((answer: Answer) => answer.text)
.join(' ')
)
)
if (sort === 'newest') {
matches.sort((a, b) => b.createdTime - a.createdTime)
} else if (sort === 'resolve-date') {
matches = sortBy(matches, (contract) => -1 * (contract.resolutionTime ?? 0))
} else if (sort === 'oldest') {
matches.sort((a, b) => a.createdTime - b.createdTime)
} else if (sort === 'close-date') {
matches = sortBy(matches, ({ volume24Hours }) => -1 * volume24Hours)
matches = sortBy(
matches,
(contract) =>
(sort === 'close-date' ? -1 : 1) * (contract.closeTime ?? Infinity)
)
} else if (sort === 'most-traded') {
matches.sort((a, b) => b.volume - a.volume)
} else if (sort === '24-hour-vol') {
// Use lodash for stable sort, so previous sort breaks all ties.
matches = sortBy(matches, ({ volume7Days }) => -1 * volume7Days)
matches = sortBy(matches, ({ volume24Hours }) => -1 * volume24Hours)
}
if (additionalFilter) {
const { creatorId, tag } = additionalFilter
if (creatorId) {
matches = matches.filter((c) => c.creatorId === creatorId)
}
if (tag) {
matches = matches.filter((c) =>
c.lowercaseTags.includes(tag.toLowerCase())
)
}
}
const showTime = ['close-date', 'closed'].includes(sort)
? 'close-date'
: sort === 'resolve-date'
? 'resolve-date'
: undefined
return (
<div>
{/* Show a search input next to a sort dropdown */}
<div className="mt-2 mb-8 flex justify-between gap-2">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search markets"
className="input input-bordered w-full"
/>
<select
className="select select-bordered"
value={sort}
onChange={(e) => setSort(e.target.value as Sort)}
>
<option value="newest">Newest</option>
<option value="oldest">Oldest</option>
<option value="most-traded">Most traded</option>
<option value="24-hour-vol">24h volume</option>
<option value="close-date">Closing soon</option>
</select>
</div>
{contracts === undefined ? (
<LoadingIndicator />
) : (
<ContractsGrid
contracts={matches}
loadMore={() => {}}
hasMore={false}
showTime={showTime}
/>
)}
</div>
)
}

View File

@ -17,7 +17,6 @@ import {
outcomeType,
} from 'common/contract'
import { formatMoney } from 'common/util/format'
import { useHasCreatedContractToday } from 'web/hooks/use-has-created-contract-today'
import { removeUndefinedProps } from 'common/util/object'
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
import { getGroup, updateGroup } from 'web/lib/firebase/groups'
@ -27,6 +26,7 @@ import { useWarnUnsavedChanges } from 'web/hooks/use-warn-unsaved-changes'
import { track } from 'web/lib/service/analytics'
import { GroupSelector } from 'web/components/groups/group-selector'
import { CATEGORIES } from 'common/categories'
import { User } from 'common/user'
export default function Create() {
const [question, setQuestion] = useState('')
@ -34,7 +34,13 @@ export default function Create() {
const router = useRouter()
const { groupId } = router.query as { groupId: string }
useTracking('view create page')
if (!router.isReady) return <div />
const creator = useUser()
useEffect(() => {
if (creator === null) router.push('/')
}, [creator, router])
if (!router.isReady || !creator) return <div />
return (
<Page>
@ -59,7 +65,11 @@ export default function Create() {
</div>
</form>
<Spacer h={6} />
<NewContract question={question} groupId={groupId} />
<NewContract
question={question}
groupId={groupId}
creator={creator}
/>
</div>
</div>
</Page>
@ -67,14 +77,12 @@ export default function Create() {
}
// Allow user to create a new contract
export function NewContract(props: { question: string; groupId?: string }) {
const { question, groupId } = props
const creator = useUser()
useEffect(() => {
if (creator === null) router.push('/')
}, [creator])
export function NewContract(props: {
creator: User
question: string
groupId?: string
}) {
const { creator, question, groupId } = props
const [outcomeType, setOutcomeType] = useState<outcomeType>('BINARY')
const [initialProb] = useState(50)
const [minString, setMinString] = useState('')
@ -93,11 +101,6 @@ export function NewContract(props: { question: string; groupId?: string }) {
}, [creator, groupId])
const [ante, _setAnte] = useState(FIXED_ANTE)
const mustWaitForDailyFreeMarketStatus = useHasCreatedContractToday(creator)
const isFree =
mustWaitForDailyFreeMarketStatus != 'loading' &&
!mustWaitForDailyFreeMarketStatus
// useEffect(() => {
// if (ante === null && creator) {
// const initialAnte = creator.balance < 100 ? MINIMUM_ANTE : 100
@ -138,9 +141,7 @@ export function NewContract(props: { question: string; groupId?: string }) {
ante !== undefined &&
ante !== null &&
ante >= MINIMUM_ANTE &&
(ante <= balance ||
(mustWaitForDailyFreeMarketStatus != 'loading' &&
!mustWaitForDailyFreeMarketStatus)) &&
ante <= balance &&
// closeTime must be in the future
closeTime &&
closeTime > Date.now() &&
@ -175,13 +176,14 @@ export function NewContract(props: { question: string; groupId?: string }) {
min,
max,
groupId: selectedGroup?.id,
tags: category ? [category] : undefined,
})
)
track('create market', {
slug: result.slug,
initialProb,
selectedGroup: selectedGroup?.id,
isFree,
isFree: false,
})
if (result && selectedGroup) {
await updateGroup(selectedGroup, {
@ -369,41 +371,26 @@ export function NewContract(props: { question: string; groupId?: string }) {
<div className="form-control mb-1 items-start">
<label className="label mb-1 gap-2">
<span>Cost</span>
{mustWaitForDailyFreeMarketStatus != 'loading' &&
mustWaitForDailyFreeMarketStatus && (
<InfoTooltip
text={`Cost to create your question. This amount is used to subsidize betting.`}
/>
)}
<InfoTooltip
text={`Cost to create your question. This amount is used to subsidize betting.`}
/>
</label>
{mustWaitForDailyFreeMarketStatus != 'loading' &&
!mustWaitForDailyFreeMarketStatus ? (
<div className="label-text text-primary pl-1">
<span className={'label-text text-neutral line-through '}>
{formatMoney(ante)}
</span>{' '}
FREE
<div className="label-text text-neutral pl-1">
{formatMoney(ante)}
</div>
{ante > balance && (
<div className="mb-2 mt-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide">
<span className="mr-2 text-red-500">Insufficient balance</span>
<button
className="btn btn-xs btn-primary"
onClick={() => (window.location.href = '/add-funds')}
>
Get M$
</button>
</div>
) : (
mustWaitForDailyFreeMarketStatus != 'loading' && (
<div className="label-text text-neutral pl-1">
{formatMoney(ante)}
</div>
)
)}
{mustWaitForDailyFreeMarketStatus != 'loading' &&
mustWaitForDailyFreeMarketStatus &&
ante > balance && (
<div className="mb-2 mt-2 mr-auto self-center whitespace-nowrap text-xs font-medium tracking-wide">
<span className="mr-2 text-red-500">Insufficient balance</span>
<button
className="btn btn-xs btn-primary"
onClick={() => (window.location.href = '/add-funds')}
>
Get M$
</button>
</div>
)}
</div>
<button

View File

@ -1,18 +1,21 @@
import { useState, useEffect } from 'react'
import { Col } from 'web/components/layout/col'
import { Spacer } from 'web/components/layout/spacer'
import { fromPropz } from 'web/hooks/use-propz'
import Analytics, {
CustomAnalytics,
FirebaseAnalytics,
getStaticPropz,
} from '../stats'
import { CustomAnalytics, FirebaseAnalytics } from '../stats'
import { getStats } from 'web/lib/firebase/stats'
import { Stats } from 'common/stats'
export const getStaticProps = fromPropz(getStaticPropz)
export default function AnalyticsEmbed(props: Parameters<typeof Analytics>[0]) {
export default function AnalyticsEmbed() {
const [stats, setStats] = useState<Stats | undefined>(undefined)
useEffect(() => {
getStats().then(setStats)
}, [])
if (stats == null) {
return <></>
}
return (
<Col className="w-full bg-white px-2">
<CustomAnalytics {...props} />
<CustomAnalytics {...stats} />
<Spacer h={8} />
<FirebaseAnalytics />
</Col>

View File

@ -1,7 +1,6 @@
import { take, sortBy, debounce } from 'lodash'
import { Group } from 'common/group'
import { Comment } from 'common/comment'
import { Page } from 'web/components/page'
import { Title } from 'web/components/title'
import { listAllBets } from 'web/lib/firebase/bets'
@ -14,11 +13,11 @@ import {
} from 'web/lib/firebase/groups'
import { Row } from 'web/components/layout/row'
import { UserLink } from 'web/components/user-page'
import { getUser, User } from 'web/lib/firebase/users'
import { firebaseLogin, getUser, User } from 'web/lib/firebase/users'
import { Spacer } from 'web/components/layout/spacer'
import { Col } from 'web/components/layout/col'
import { useUser } from 'web/hooks/use-user'
import { useGroup, useMembers } from 'web/hooks/use-group'
import { listMembers, useGroup, useMembers } from 'web/hooks/use-group'
import { useRouter } from 'next/router'
import { scoreCreators, scoreTraders } from 'common/scoring'
import { Leaderboard } from 'web/components/leaderboard'
@ -32,20 +31,22 @@ import { Tabs } from 'web/components/layout/tabs'
import { ContractsGrid } from 'web/components/contract/contracts-list'
import { CreateQuestionButton } from 'web/components/create-question-button'
import React, { useEffect, useState } from 'react'
import { Discussion } from 'web/components/groups/discussion'
import { listenForCommentsOnGroup } from 'web/lib/firebase/comments'
import { GroupChat } from 'web/components/groups/group-chat'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { Modal } from 'web/components/layout/modal'
import { PlusIcon } from '@heroicons/react/outline'
import { checkAgainstQuery } from 'web/hooks/use-sort-and-query-params'
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
import { toast } from 'react-hot-toast'
import { useCommentsOnGroup } from 'web/hooks/use-comments'
import ShortToggle from 'web/components/widgets/short-toggle'
export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: { params: { slugs: string[] } }) {
const { slugs } = props.params
const group = await getGroupBySlug(slugs[0])
const members = group ? await listMembers(group) : []
const creatorPromise = group ? getUser(group.creatorId) : null
const contracts = group ? await getGroupContracts(group).catch((_) => []) : []
@ -66,6 +67,7 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) {
return {
props: {
group,
members,
creator,
traderScores,
topTraders,
@ -92,10 +94,11 @@ async function toTopUsers(userScores: { [userId: string]: number }) {
export async function getStaticPaths() {
return { paths: [], fallback: 'blocking' }
}
const groupSubpages = [undefined, 'discussion', 'questions', 'details'] as const
const groupSubpages = [undefined, 'chat', 'questions', 'about'] as const
export default function GroupPage(props: {
group: Group | null
members: User[]
creator: User
traderScores: { [userId: string]: number }
topTraders: User[]
@ -104,25 +107,42 @@ export default function GroupPage(props: {
}) {
props = usePropz(props, getStaticPropz) ?? {
group: null,
members: [],
creator: null,
traderScores: {},
topTraders: [],
creatorScores: {},
topCreators: [],
}
const { creator, traderScores, topTraders, creatorScores, topCreators } =
props
const {
creator,
members,
traderScores,
topTraders,
creatorScores,
topCreators,
} = props
const router = useRouter()
const { slugs } = router.query as { slugs: string[] }
const page = (slugs?.[1] ?? 'discussion') as typeof groupSubpages[number]
const page = (slugs?.[1] ?? 'chat') as typeof groupSubpages[number]
const group = useGroup(props.group?.id) ?? props.group
const [messages, setMessages] = useState<Comment[] | undefined>(undefined)
const [contracts, setContracts] = useState<Contract[] | undefined>(undefined)
useEffect(() => {
if (group) listenForCommentsOnGroup(group.id, setMessages)
}, [group])
const [query, setQuery] = useState('')
const messages = useCommentsOnGroup(group?.id)
const debouncedQuery = debounce(setQuery, 50)
const filteredContracts =
query != '' && contracts
? contracts.filter(
(c) =>
checkAgainstQuery(query, c.question) ||
checkAgainstQuery(query, c.description || '') ||
checkAgainstQuery(query, c.creatorName) ||
checkAgainstQuery(query, c.creatorUsername)
)
: []
useEffect(() => {
if (group)
@ -139,20 +159,11 @@ export default function GroupPage(props: {
const rightSidebar = (
<Col className="mt-6 hidden xl:block">
<GroupOverview
group={group}
creator={creator}
isCreator={!!isCreator}
user={user}
/>
<YourPerformance
traderScores={traderScores}
creatorScores={creatorScores}
user={user}
/>
<JoinOrCreateButton group={group} user={user} isMember={!!isMember} />
<Spacer h={6} />
{contracts && (
<div className={'mt-2'}>
<div className={'my-2 text-lg text-indigo-700'}>Recent Questions</div>
<div className={'my-2 text-gray-500'}>Recent Questions</div>
<ContractsGrid
contracts={contracts
.sort((a, b) => b.createdTime - a.createdTime)
@ -166,13 +177,22 @@ export default function GroupPage(props: {
</Col>
)
const leaderboardsTab = (
<Col className="mt-4 gap-8 px-4 md:flex-row">
const aboutTab = (
<Col>
<GroupOverview
group={group}
creator={creator}
isCreator={!!isCreator}
user={user}
/>
<Spacer h={8} />
<GroupLeaderboards
traderScores={traderScores}
creatorScores={creatorScores}
topTraders={topTraders}
topCreators={topCreators}
members={members}
user={user}
/>
</Col>
@ -185,49 +205,56 @@ export default function GroupPage(props: {
url={groupPath(group.slug)}
/>
<div className="px-3 lg:px-1">
<Row className={' items-center justify-between gap-4 '}>
<Col className="px-3 lg:px-1">
<Row className={'items-center justify-between gap-4'}>
<div className={'mb-1'}>
<Title className={'line-clamp-2'} text={group.name} />
<span className={'text-gray-700'}>{group.about}</span>
<Linkify text={group.about} />
</div>
{isMember && (
<CreateQuestionButton
<div className="hidden sm:block xl:hidden">
<JoinOrCreateButton
group={group}
user={user}
overrideText={'Add a new question'}
className={'w-48 flex-shrink-0'}
query={`?groupId=${group.id}`}
isMember={!!isMember}
/>
)}
{!isMember && group.anyoneCanJoin && (
<JoinGroupButton group={group} user={user} />
)}
</div>
</Row>
</div>
<div className="block sm:hidden">
<JoinOrCreateButton group={group} user={user} isMember={!!isMember} />
</div>
</Col>
<Tabs
defaultIndex={page === 'details' ? 2 : page === 'questions' ? 1 : 0}
defaultIndex={page === 'about' ? 2 : page === 'questions' ? 1 : 0}
tabs={[
{
title: 'Discussion',
title: 'Chat',
content: messages ? (
<Discussion messages={messages} user={user} group={group} />
<GroupChat messages={messages} user={user} group={group} />
) : (
<LoadingIndicator />
),
href: groupPath(group.slug, 'discussion'),
href: groupPath(group.slug, 'chat'),
},
{
title: 'Questions',
content: (
<div className={'mt-2'}>
<div className={'mt-2 px-1'}>
{contracts ? (
contracts.length > 0 ? (
<ContractsGrid
contracts={contracts}
hasMore={false}
loadMore={() => {}}
/>
<>
<input
type="text"
onChange={(e) => debouncedQuery(e.target.value)}
placeholder="Search the group's questions"
className="input input-bordered mb-4 w-full"
/>
<ContractsGrid
contracts={query != '' ? filteredContracts : contracts}
hasMore={false}
loadMore={() => {}}
/>
</>
) : (
<div className="p-2 text-gray-500">
No questions yet. 🦗... Why not add one?
@ -242,26 +269,9 @@ export default function GroupPage(props: {
href: groupPath(group.slug, 'questions'),
},
{
title: 'Details',
content: (
<>
<div className={'xl:hidden'}>
<GroupOverview
group={group}
creator={creator}
isCreator={!!isCreator}
user={user}
/>
<YourPerformance
traderScores={traderScores}
creatorScores={creatorScores}
user={user}
/>
</div>
{leaderboardsTab}
</>
),
href: groupPath(group.slug, 'details'),
title: 'About',
content: aboutTab,
href: groupPath(group.slug, 'about'),
},
]}
/>
@ -269,6 +279,24 @@ export default function GroupPage(props: {
)
}
function JoinOrCreateButton(props: {
group: Group
user: User | null | undefined
isMember: boolean
}) {
const { group, user, isMember } = props
return isMember ? (
<CreateQuestionButton
user={user}
overrideText={'Add a new question'}
className={'w-48 flex-shrink-0'}
query={`?groupId=${group.id}`}
/>
) : group.anyoneCanJoin ? (
<JoinGroupButton group={group} user={user} />
) : null
}
function GroupOverview(props: {
group: Group
creator: User
@ -276,7 +304,6 @@ function GroupOverview(props: {
isCreator: boolean
}) {
const { group, creator, isCreator, user } = props
const { about } = group
const anyoneCanJoinChoices: { [key: string]: string } = {
Closed: 'false',
Open: 'true',
@ -295,7 +322,7 @@ function GroupOverview(props: {
return (
<Col>
<Row className="items-center justify-end rounded-t bg-indigo-500 px-4 py-3 text-sm text-white">
<Row className="flex-1 justify-start">About group</Row>
<Row className="flex-1 justify-start">About {group.name}</Row>
{isCreator && <EditGroupButton className={'ml-1'} group={group} />}
</Row>
<Col className="gap-2 rounded-b bg-white p-4">
@ -307,7 +334,6 @@ function GroupOverview(props: {
username={creator.username}
/>
</Row>
<GroupMembersList group={group} />
<Row className={'items-center gap-1'}>
<span className={'text-gray-500'}>Membership</span>
{user && user.id === creator.id ? (
@ -326,14 +352,6 @@ function GroupOverview(props: {
</span>
)}
</Row>
{about && (
<>
<Spacer h={2} />
<div className="text-gray-500">
<Linkify text={about} />
</div>
</>
)}
</Col>
</Col>
)
@ -364,40 +382,24 @@ export function GroupMembersList(props: { group: Group }) {
)
}
function YourPerformance(props: {
traderScores: { [userId: string]: number }
creatorScores: { [userId: string]: number }
user: User | null | undefined
function SortedLeaderboard(props: {
users: User[]
scoreFunction: (user: User) => number
title: string
header: string
}) {
const { traderScores, creatorScores, user } = props
const yourTraderScore = user ? traderScores[user.id] : undefined
const yourCreatorScore = user ? creatorScores[user.id] : undefined
return user ? (
<Col>
<div className="rounded bg-indigo-500 px-4 py-3 text-sm text-white">
Your performance
</div>
<div className="bg-white p-2">
<table className="table-compact table w-full text-gray-500">
<tbody>
<tr>
<td>Total profit</td>
<td>{formatMoney(yourTraderScore ?? 0)}</td>
</tr>
{yourCreatorScore && (
<tr>
<td>Total created pool</td>
<td>{formatMoney(yourCreatorScore)}</td>
</tr>
)}
</tbody>
</table>
</div>
</Col>
) : null
const { users, scoreFunction, title, header } = props
const sortedUsers = users.sort((a, b) => scoreFunction(b) - scoreFunction(a))
return (
<Leaderboard
className="max-w-xl"
users={sortedUsers}
title={title}
columns={[
{ header, renderCell: (user) => formatMoney(scoreFunction(user)) },
]}
/>
)
}
function GroupLeaderboards(props: {
@ -405,41 +407,69 @@ function GroupLeaderboards(props: {
creatorScores: { [userId: string]: number }
topTraders: User[]
topCreators: User[]
members: User[]
user: User | null | undefined
}) {
const { traderScores, creatorScores, topTraders, topCreators } = props
const topTraderScores = topTraders.map((user) => traderScores[user.id])
const topCreatorScores = topCreators.map((user) => creatorScores[user.id])
const { traderScores, creatorScores, members, topTraders, topCreators } =
props
const [includeOutsiders, setIncludeOutsiders] = useState(false)
// Consider hiding M$0
return (
<>
<Leaderboard
className="max-w-xl"
title="🏅 Top bettors"
users={topTraders}
columns={[
{
header: 'Profit',
renderCell: (user) =>
formatMoney(topTraderScores[topTraders.indexOf(user)]),
},
]}
/>
<Col>
<Row className="items-center justify-end gap-4 text-gray-500">
Include all users
<ShortToggle
enabled={includeOutsiders}
setEnabled={setIncludeOutsiders}
/>
</Row>
<Leaderboard
className="max-w-xl"
title="🏅 Top creators"
users={topCreators}
columns={[
{
header: 'Market volume',
renderCell: (user) =>
formatMoney(topCreatorScores[topCreators.indexOf(user)]),
},
]}
/>
</>
<div className="mt-4 flex flex-col gap-8 px-4 md:flex-row">
{!includeOutsiders ? (
<>
<SortedLeaderboard
users={members}
scoreFunction={(user) => traderScores[user.id] ?? 0}
title="🏅 Top bettors"
header="Profit"
/>
<SortedLeaderboard
users={members}
scoreFunction={(user) => creatorScores[user.id] ?? 0}
title="🏅 Top creators"
header="Market volume"
/>
</>
) : (
<>
<Leaderboard
className="max-w-xl"
title="🏅 Top bettors"
users={topTraders}
columns={[
{
header: 'Profit',
renderCell: (user) => formatMoney(traderScores[user.id] ?? 0),
},
]}
/>
<Leaderboard
className="max-w-xl"
title="🏅 Top creators"
users={topCreators}
columns={[
{
header: 'Market volume',
renderCell: (user) =>
formatMoney(creatorScores[user.id] ?? 0),
},
]}
/>
</>
)}
</div>
</Col>
)
}
@ -451,15 +481,7 @@ function AddContractButton(props: { group: Group; user: User }) {
useEffect(() => {
return listenForUserContracts(user.id, (contracts) => {
setContracts(
contracts.filter(
(c) =>
!group.contractIds.includes(c.id) &&
// TODO: It'll be easy to allow questions to be in multiple groups as long as we
// have the on-update-group function update the newly added contract's groupDetails (via contractIds)
!c.groupDetails
)
)
setContracts(contracts.filter((c) => !group.contractIds.includes(c.id)))
})
}, [group.contractIds, user.id])
@ -549,8 +571,11 @@ function JoinGroupButton(props: {
}
return (
<div>
<button onClick={joinGroup} className={'btn-md btn-outline btn '}>
Join Group
<button
onClick={user ? joinGroup : firebaseLogin}
className={'btn-md btn-outline btn whitespace-nowrap normal-case'}
>
{user ? 'Join group' : 'Login to join group'}
</button>
</div>
)

View File

@ -5,6 +5,7 @@ import { PlusSmIcon } from '@heroicons/react/solid'
import { Page } from 'web/components/page'
import { Col } from 'web/components/layout/col'
import { useUser } from 'web/hooks/use-user'
import { getSavedSort } from 'web/hooks/use-sort-and-query-params'
import { ContractSearch } from 'web/components/contract-search'
import { Contract } from 'common/contract'
import { ContractPageContent } from './[username]/[contractSlug]'
@ -17,7 +18,6 @@ const Home = () => {
const [contract, setContract] = useContractPage()
const router = useRouter()
useTracking('view home')
if (user === null) {
@ -32,7 +32,7 @@ const Home = () => {
<ContractSearch
querySortOptions={{
shouldLoadFromStorage: true,
defaultSort: '24-hour-vol',
defaultSort: getSavedSort() ?? '24-hour-vol',
}}
showCategorySelector
onContractClick={(c) => {

View File

@ -4,8 +4,8 @@ import { Page } from 'web/components/page'
import {
getTopCreators,
getTopTraders,
LeaderboardPeriod,
getTopFollowed,
Period,
User,
} from 'web/lib/firebase/users'
import { formatMoney } from 'common/util/format'
@ -20,7 +20,7 @@ export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz() {
return queryLeaderboardUsers('allTime')
}
const queryLeaderboardUsers = async (period: LeaderboardPeriod) => {
const queryLeaderboardUsers = async (period: Period) => {
const [topTraders, topCreators, topFollowed] = await Promise.all([
getTopTraders(period).catch(() => {}),
getTopCreators(period).catch(() => {}),
@ -50,7 +50,7 @@ export default function Leaderboards(props: {
const [topTradersState, setTopTraders] = useState(props.topTraders)
const [topCreatorsState, setTopCreators] = useState(props.topCreators)
const [isLoading, setLoading] = useState(false)
const [period, setPeriod] = useState<LeaderboardPeriod>('allTime')
const [period, setPeriod] = useState<Period>('allTime')
useEffect(() => {
setLoading(true)
@ -61,13 +61,13 @@ export default function Leaderboards(props: {
})
}, [period])
const LeaderboardWithPeriod = (period: LeaderboardPeriod) => {
const LeaderboardWithPeriod = (period: Period) => {
return (
<>
<Col className="mx-4 items-center gap-10 lg:flex-row">
{!isLoading ? (
<>
{period === 'allTime' ? ( //TODO: show other periods once they're available
{period === 'allTime' || period === 'daily' ? ( //TODO: show other periods once they're available
<Leaderboard
title="🏅 Top bettors"
users={topTradersState}
@ -127,7 +127,7 @@ export default function Leaderboards(props: {
defaultIndex={0}
onClick={(title, index) => {
const period = ['allTime', 'monthly', 'weekly', 'daily'][index]
setPeriod(period as LeaderboardPeriod)
setPeriod(period as Period)
}}
tabs={[
{

68
web/pages/link/[slug].tsx Normal file
View File

@ -0,0 +1,68 @@
import { useRouter } from 'next/router'
import { useState } from 'react'
import { SEO } from 'web/components/SEO'
import { Title } from 'web/components/title'
import { claimManalink } from 'web/lib/firebase/fn-call'
import { useManalink } from 'web/lib/firebase/manalinks'
import { ManalinkCard } from 'web/components/manalink-card'
import { useUser } from 'web/hooks/use-user'
import { useUserById } from 'web/hooks/use-users'
import { firebaseLogin } from 'web/lib/firebase/users'
export default function ClaimPage() {
const user = useUser()
const router = useRouter()
const { slug } = router.query as { slug: string }
const manalink = useManalink(slug)
const [claiming, setClaiming] = useState(false)
const [error, setError] = useState<string | undefined>(undefined)
const fromUser = useUserById(manalink?.fromId)
if (!manalink) {
return <></>
}
const info = { ...manalink, uses: manalink.claims.length }
return (
<>
<SEO
title="Send Mana"
description="Send mana to anyone via link!"
url="/send"
/>
<div className="mx-auto max-w-xl">
<Title text={`Claim ${manalink.amount} mana`} />
<ManalinkCard
defaultMessage={fromUser?.name || 'Enjoy this mana!'}
info={info}
isClaiming={claiming}
onClaim={async () => {
setClaiming(true)
try {
if (user == null) {
await firebaseLogin()
}
const result = await claimManalink(manalink.slug)
if (result.data.status == 'error') {
throw new Error(result.data.message)
}
router.push('/account?claimed-mana=yes')
} catch (e) {
console.log(e)
const message =
e && e instanceof Object ? e.toString() : 'An error occurred.'
setError(message)
}
setClaiming(false)
}}
/>
{error && (
<section className="my-5 text-red-500">
<p>Failed to claim manalink.</p>
<p>{error}</p>
</section>
)}
</div>
</>
)
}

386
web/pages/links.tsx Normal file
View File

@ -0,0 +1,386 @@
import clsx from 'clsx'
import { useState } from 'react'
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid'
import { Claim, Manalink } from 'common/manalink'
import { formatMoney } from 'common/util/format'
import { Col } from 'web/components/layout/col'
import { Page } from 'web/components/page'
import { SEO } from 'web/components/SEO'
import { Title } from 'web/components/title'
import { useUser } from 'web/hooks/use-user'
import { createManalink, useUserManalinks } from 'web/lib/firebase/manalinks'
import { fromNow } from 'web/lib/util/time'
import { useUserById } from 'web/hooks/use-users'
import { ManalinkTxn } from 'common/txn'
import { User } from 'common/user'
import { Tabs } from 'web/components/layout/tabs'
import { Avatar } from 'web/components/avatar'
import { RelativeTimestamp } from 'web/components/relative-timestamp'
import { UserLink } from 'web/components/user-page'
import { ManalinkCard, ManalinkInfo } from 'web/components/manalink-card'
import Textarea from 'react-expanding-textarea'
import dayjs from 'dayjs'
import customParseFormat from 'dayjs/plugin/customParseFormat'
dayjs.extend(customParseFormat)
function getLinkUrl(slug: string) {
return `${location.protocol}//${location.host}/link/${slug}`
}
// TODO: incredibly gross, but the tab component is wrongly designed and
// keeps the tab state inside of itself, so this seems like the only
// way we can tell it to switch tabs from outside after initial render.
function setTabIndex(tabIndex: number) {
const tabHref = document.getElementById(`tab-${tabIndex}`)
if (tabHref) {
tabHref.click()
}
}
export default function LinkPage() {
const user = useUser()
const links = useUserManalinks(user?.id ?? '')
// const manalinkTxns = useManalinkTxns(user?.id ?? '')
const [highlightedSlug, setHighlightedSlug] = useState('')
const unclaimedLinks = links.filter(
(l) =>
(l.maxUses == null || l.claimedUserIds.length < l.maxUses) &&
(l.expiresTime == null || l.expiresTime > Date.now())
)
if (user == null) {
return null
}
return (
<Page>
<SEO
title="Manalinks"
description="Send mana to anyone via link!"
url="/send"
/>
<Col className="w-full px-8">
<Title text="Manalinks" />
<Tabs
className={'pb-2 pt-1 '}
defaultIndex={0}
tabs={[
{
title: 'Create a link',
content: (
<CreateManalinkForm
user={user}
onCreate={async (newManalink) => {
const slug = await createManalink({
fromId: user.id,
amount: newManalink.amount,
expiresTime: newManalink.expiresTime,
maxUses: newManalink.maxUses,
message: newManalink.message,
})
setTabIndex(1)
setHighlightedSlug(slug || '')
}}
/>
),
},
{
title: 'Unclaimed links',
content: (
<LinksTable
links={unclaimedLinks}
highlightedSlug={highlightedSlug}
/>
),
},
// TODO: we have no use case for this atm and it's also really inefficient
// {
// title: 'Claimed',
// content: <ClaimsList txns={manalinkTxns} />,
// },
]}
/>
</Col>
</Page>
)
}
function CreateManalinkForm(props: {
user: User
onCreate: (m: ManalinkInfo) => Promise<void>
}) {
const { user, onCreate } = props
const [isCreating, setIsCreating] = useState(false)
const [newManalink, setNewManalink] = useState<ManalinkInfo>({
expiresTime: null,
amount: 100,
maxUses: 1,
uses: 0,
message: '',
})
return (
<>
<p>
You can use manalinks to send mana to other people, even if they
don&apos;t yet have a Manifold account.
</p>
<form
className="my-5"
onSubmit={(e) => {
e.preventDefault()
setIsCreating(true)
onCreate(newManalink).finally(() => setIsCreating(false))
}}
>
<div className="flex flex-row flex-wrap gap-x-5 gap-y-2">
<div className="form-control flex-auto">
<label className="label">Amount</label>
<input
className="input"
type="number"
value={newManalink.amount}
onChange={(e) =>
setNewManalink((m) => {
return { ...m, amount: parseInt(e.target.value) }
})
}
></input>
</div>
<div className="form-control flex-auto">
<label className="label">Uses</label>
<input
className="input"
type="number"
value={newManalink.maxUses ?? ''}
onChange={(e) =>
setNewManalink((m) => {
return { ...m, maxUses: parseInt(e.target.value) }
})
}
></input>
</div>
<div className="form-control flex-auto">
<label className="label">Expires at</label>
<input
value={
newManalink.expiresTime != null
? dayjs(newManalink.expiresTime).format('YYYY-MM-DDTHH:mm')
: ''
}
className="input"
type="datetime-local"
onChange={(e) => {
setNewManalink((m) => {
return {
...m,
expiresTime: e.target.value
? dayjs(e.target.value, 'YYYY-MM-DDTHH:mm').valueOf()
: null,
}
})
}}
></input>
</div>
</div>
<div className="form-control w-full">
<label className="label">Message</label>
<Textarea
placeholder={`From ${user.name}`}
className="input input-bordered resize-none"
autoFocus
value={newManalink.message}
onChange={(e) =>
setNewManalink((m) => {
return { ...m, message: e.target.value }
})
}
/>
</div>
<button
type="submit"
className={clsx('btn mt-5', isCreating ? 'loading disabled' : '')}
>
{isCreating ? '' : 'Create'}
</button>
</form>
<Title text="Preview" />
<p>This is what the person you send the link to will see:</p>
<ManalinkCard
className="my-5"
defaultMessage={`From ${user.name}`}
info={newManalink}
isClaiming={false}
/>
</>
)
}
export function ClaimsList(props: { txns: ManalinkTxn[] }) {
const { txns } = props
return (
<>
<h1 className="mb-4 text-xl font-semibold text-gray-900">
Claimed links
</h1>
{txns.map((txn) => (
<ClaimDescription txn={txn} key={txn.id} />
))}
</>
)
}
export function ClaimDescription(props: { txn: ManalinkTxn }) {
const { txn } = props
const from = useUserById(txn.fromId)
const to = useUserById(txn.toId)
if (!from || !to) {
return <>Loading...</>
}
return (
<div className="mb-2 flow-root pr-2 md:pr-0">
<div className="relative flex items-center space-x-3">
<Avatar username={to.name} avatarUrl={to.avatarUrl} size="sm" />
<div className="min-w-0 flex-1">
<p className="mt-0.5 text-sm text-gray-500">
<UserLink
className="text-gray-500"
username={to.username}
name={to.name}
/>{' '}
claimed {formatMoney(txn.amount)} from{' '}
<UserLink
className="text-gray-500"
username={from.username}
name={from.name}
/>
<RelativeTimestamp time={txn.createdTime} />
</p>
</div>
</div>
</div>
)
}
function ClaimTableRow(props: { claim: Claim }) {
const { claim } = props
const who = useUserById(claim.toId)
return (
<tr>
<td className="px-5 py-2">{who?.name || 'Loading...'}</td>
<td className="px-5 py-2">{`${new Date(
claim.claimedTime
).toLocaleString()}, ${fromNow(claim.claimedTime)}`}</td>
</tr>
)
}
function LinkDetailsTable(props: { link: Manalink }) {
const { link } = props
return (
<table className="w-full divide-y divide-gray-300 border border-gray-400">
<thead className="bg-gray-50 text-left text-sm font-semibold text-gray-900">
<tr>
<th className="px-5 py-2">Claimed by</th>
<th className="px-5 py-2">Time</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white text-sm text-gray-500">
{link.claims.length ? (
link.claims.map((claim) => <ClaimTableRow claim={claim} />)
) : (
<tr>
<td className="px-5 py-2" colSpan={2}>
No claims yet.
</td>
</tr>
)}
</tbody>
</table>
)
}
function LinkTableRow(props: { link: Manalink; highlight: boolean }) {
const { link, highlight } = props
const [expanded, setExpanded] = useState(false)
return (
<>
<LinkSummaryRow
link={link}
highlight={highlight}
expanded={expanded}
onToggle={() => setExpanded((exp) => !exp)}
/>
{expanded && (
<tr>
<td className="bg-gray-100 p-3" colSpan={5}>
<LinkDetailsTable link={link} />
</td>
</tr>
)}
</>
)
}
function LinkSummaryRow(props: {
link: Manalink
highlight: boolean
expanded: boolean
onToggle: () => void
}) {
const { link, highlight, expanded, onToggle } = props
const className = clsx(
'whitespace-nowrap text-sm hover:cursor-pointer',
highlight ? 'bg-primary' : 'text-gray-500 hover:bg-sky-50 bg-white'
)
return (
<tr id={link.slug} key={link.slug} className={className}>
<td className="py-4 pl-5" onClick={onToggle}>
{expanded ? (
<ChevronUpIcon className="h-5 w-5" />
) : (
<ChevronDownIcon className="h-5 w-5" />
)}
</td>
<td className="px-5 py-4 font-medium text-gray-900">
{formatMoney(link.amount)}
</td>
<td className="px-5 py-4">{getLinkUrl(link.slug)}</td>
<td className="px-5 py-4">{link.claimedUserIds.length}</td>
<td className="px-5 py-4">{link.maxUses == null ? '∞' : link.maxUses}</td>
<td className="px-5 py-4">
{link.expiresTime == null ? 'Never' : fromNow(link.expiresTime)}
</td>
</tr>
)
}
function LinksTable(props: { links: Manalink[]; highlightedSlug?: string }) {
const { links, highlightedSlug } = props
return links.length == 0 ? (
<p>You don&apos;t currently have any outstanding manalinks.</p>
) : (
<table className="w-full divide-y divide-gray-300 rounded-lg border border-gray-200">
<thead className="bg-gray-50 text-left text-sm font-semibold text-gray-900">
<tr>
<th></th>
<th className="px-5 py-3.5">Amount</th>
<th className="px-5 py-3.5">Link</th>
<th className="px-5 py-3.5">Uses</th>
<th className="px-5 py-3.5">Max Uses</th>
<th className="px-5 py-3.5">Expires</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white">
{links.map((link) => (
<LinkTableRow link={link} highlight={link.slug === highlightedSlug} />
))}
</tbody>
</table>
)
}

View File

@ -7,7 +7,7 @@ import {
notification_source_types,
notification_source_update_types,
} from 'common/notification'
import { Avatar } from 'web/components/avatar'
import { Avatar, EmptyAvatar } from 'web/components/avatar'
import { Row } from 'web/components/layout/row'
import { Page } from 'web/components/page'
import { Title } from 'web/components/title'
@ -25,7 +25,6 @@ import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
import { listenForPrivateUser, updatePrivateUser } from 'web/lib/firebase/users'
import { LoadingIndicator } from 'web/components/loading-indicator'
import clsx from 'clsx'
import { UsersIcon } from '@heroicons/react/solid'
import { RelativeTimestamp } from 'web/components/relative-timestamp'
import { Linkify } from 'web/components/linkify'
import {
@ -235,9 +234,7 @@ function NotificationGroupItem(props: {
/>
)}
<Row className={'items-center text-gray-500 sm:justify-start'}>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-200">
<UsersIcon className="h-5 w-5 text-gray-500" aria-hidden="true" />
</div>
<EmptyAvatar multi />
<div className={'flex-1 overflow-hidden pl-2 sm:flex'}>
<div
onClick={() => setExpanded(!expanded)}

View File

@ -1,5 +1,5 @@
import dayjs from 'dayjs'
import { zip, uniq, sumBy, concat, countBy, sortBy, sum } from 'lodash'
import { useEffect, useState } from 'react'
import {
DailyCountChart,
DailyPercentChart,
@ -9,265 +9,18 @@ import { Spacer } from 'web/components/layout/spacer'
import { Tabs } from 'web/components/layout/tabs'
import { Page } from 'web/components/page'
import { Title } from 'web/components/title'
import { fromPropz, usePropz } from 'web/hooks/use-propz'
import { getDailyBets } from 'web/lib/firebase/bets'
import { getDailyComments } from 'web/lib/firebase/comments'
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'
import { getStats } from 'web/lib/firebase/stats'
import { Stats } from 'common/stats'
export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz() {
const numberOfDays = 90
const today = dayjs(dayjs().format('YYYY-MM-DD'))
// Convert from UTC midnight to PT midnight.
.add(7, 'hours')
const startDate = today.subtract(numberOfDays, 'day')
const [dailyBets, dailyContracts, dailyComments, dailyNewUsers] =
await Promise.all([
getDailyBets(startDate.valueOf(), numberOfDays),
getDailyContracts(startDate.valueOf(), numberOfDays),
getDailyComments(startDate.valueOf(), numberOfDays),
getDailyNewUsers(startDate.valueOf(), numberOfDays),
])
const dailyBetCounts = dailyBets.map((bets) => bets.length)
const dailyContractCounts = dailyContracts.map(
(contracts) => contracts.length
)
const dailyCommentCounts = dailyComments.map((comments) => comments.length)
const dailyUserIds = zip(dailyContracts, dailyBets, dailyComments).map(
([contracts, bets, comments]) => {
const creatorIds = (contracts ?? []).map((c) => c.creatorId)
const betUserIds = (bets ?? []).map((bet) => bet.userId)
const commentUserIds = (comments ?? []).map((comment) => comment.userId)
return uniq([...creatorIds, ...betUserIds, ...commentUserIds])
}
)
const dailyActiveUsers = dailyUserIds.map((userIds) => userIds.length)
const weeklyActiveUsers = dailyUserIds.map((_, i) => {
const start = Math.max(0, i - 6)
const end = i
const uniques = new Set<string>()
for (let j = start; j <= end; j++)
dailyUserIds[j].forEach((userId) => uniques.add(userId))
return uniques.size
})
const monthlyActiveUsers = dailyUserIds.map((_, i) => {
const start = Math.max(0, i - 29)
const end = i
const uniques = new Set<string>()
for (let j = start; j <= end; j++)
dailyUserIds[j].forEach((userId) => uniques.add(userId))
return uniques.size
})
const weekOnWeekRetention = dailyUserIds.map((_userId, i) => {
const twoWeeksAgo = {
start: Math.max(0, i - 13),
end: Math.max(0, i - 7),
}
const lastWeek = {
start: Math.max(0, i - 6),
end: i,
}
const activeTwoWeeksAgo = new Set<string>()
for (let j = twoWeeksAgo.start; j <= twoWeeksAgo.end; j++) {
dailyUserIds[j].forEach((userId) => activeTwoWeeksAgo.add(userId))
}
const activeLastWeek = new Set<string>()
for (let j = lastWeek.start; j <= lastWeek.end; j++) {
dailyUserIds[j].forEach((userId) => activeLastWeek.add(userId))
}
const retainedCount = sumBy(Array.from(activeTwoWeeksAgo), (userId) =>
activeLastWeek.has(userId) ? 1 : 0
)
const retainedFrac = retainedCount / activeTwoWeeksAgo.size
return Math.round(retainedFrac * 100 * 100) / 100
})
const monthlyRetention = dailyUserIds.map((_userId, i) => {
const twoMonthsAgo = {
start: Math.max(0, i - 60),
end: Math.max(0, i - 30),
}
const lastMonth = {
start: Math.max(0, i - 30),
end: i,
}
const activeTwoMonthsAgo = new Set<string>()
for (let j = twoMonthsAgo.start; j <= twoMonthsAgo.end; j++) {
dailyUserIds[j].forEach((userId) => activeTwoMonthsAgo.add(userId))
}
const activeLastMonth = new Set<string>()
for (let j = lastMonth.start; j <= lastMonth.end; j++) {
dailyUserIds[j].forEach((userId) => activeLastMonth.add(userId))
}
const retainedCount = sumBy(Array.from(activeTwoMonthsAgo), (userId) =>
activeLastMonth.has(userId) ? 1 : 0
)
const retainedFrac = retainedCount / activeTwoMonthsAgo.size
return Math.round(retainedFrac * 100 * 100) / 100
})
const firstBetDict: { [userId: string]: number } = {}
for (let i = 0; i < dailyBets.length; i++) {
const bets = dailyBets[i]
for (const bet of bets) {
if (bet.userId in firstBetDict) continue
firstBetDict[bet.userId] = i
}
}
const weeklyActivationRate = dailyNewUsers.map((_, i) => {
const start = Math.max(0, i - 6)
const end = i
let activatedCount = 0
let newUsers = 0
for (let j = start; j <= end; j++) {
const userIds = dailyNewUsers[j].map((user) => user.id)
newUsers += userIds.length
for (const userId of userIds) {
const dayIndex = firstBetDict[userId]
if (dayIndex !== undefined && dayIndex <= end) {
activatedCount++
}
}
}
const frac = activatedCount / (newUsers || 1)
return Math.round(frac * 100 * 100) / 100
})
const dailySignups = dailyNewUsers.map((users) => users.length)
const dailyTopTenthActions = zip(
dailyContracts,
dailyBets,
dailyComments
).map(([contracts, bets, comments]) => {
const userIds = concat(
contracts?.map((c) => c.creatorId) ?? [],
bets?.map((b) => b.userId) ?? [],
comments?.map((c) => c.userId) ?? []
)
const counts = Object.values(countBy(userIds))
const sortedCounts = sortBy(counts, (count) => count).reverse()
if (sortedCounts.length === 0) return 0
const tenthPercentile = sortedCounts[Math.floor(sortedCounts.length * 0.1)]
return tenthPercentile
})
const weeklyTopTenthActions = dailyTopTenthActions.map((_, i) => {
const start = Math.max(0, i - 6)
const end = i
return average(dailyTopTenthActions.slice(start, end))
})
const monthlyTopTenthActions = dailyTopTenthActions.map((_, i) => {
const start = Math.max(0, i - 29)
const end = i
return average(dailyTopTenthActions.slice(start, end))
})
// Total mana divided by 100.
const dailyManaBet = dailyBets.map((bets) => {
return Math.round(sumBy(bets, (bet) => bet.amount) / 100)
})
const weeklyManaBet = dailyManaBet.map((_, i) => {
const start = Math.max(0, i - 6)
const end = i
const total = sum(dailyManaBet.slice(start, end))
if (end - start < 7) return (total * 7) / (end - start)
return total
})
const monthlyManaBet = dailyManaBet.map((_, i) => {
const start = Math.max(0, i - 29)
const end = i
const total = sum(dailyManaBet.slice(start, end))
const range = end - start + 1
if (range < 30) return (total * 30) / range
return total
})
return {
props: {
startDate: startDate.valueOf(),
dailyActiveUsers,
weeklyActiveUsers,
monthlyActiveUsers,
dailyBetCounts,
dailyContractCounts,
dailyCommentCounts,
dailySignups,
weekOnWeekRetention,
weeklyActivationRate,
monthlyRetention,
topTenthActions: {
daily: dailyTopTenthActions,
weekly: weeklyTopTenthActions,
monthly: monthlyTopTenthActions,
},
manaBet: {
daily: dailyManaBet,
weekly: weeklyManaBet,
monthly: monthlyManaBet,
},
},
revalidate: 60 * 60, // Regenerate after an hour
}
}
export default function Analytics(props: {
startDate: number
dailyActiveUsers: number[]
weeklyActiveUsers: number[]
monthlyActiveUsers: number[]
dailyBetCounts: number[]
dailyContractCounts: number[]
dailyCommentCounts: number[]
dailySignups: number[]
weekOnWeekRetention: number[]
monthlyRetention: number[]
weeklyActivationRate: number[]
topTenthActions: {
daily: number[]
weekly: number[]
monthly: number[]
}
manaBet: {
daily: number[]
weekly: number[]
monthly: number[]
}
}) {
props = usePropz(props, getStaticPropz) ?? {
startDate: 0,
dailyActiveUsers: [],
weeklyActiveUsers: [],
monthlyActiveUsers: [],
dailyBetCounts: [],
dailyContractCounts: [],
dailyCommentCounts: [],
dailySignups: [],
weekOnWeekRetention: [],
monthlyRetention: [],
weeklyActivationRate: [],
topTenthActions: {
daily: [],
weekly: [],
monthly: [],
},
manaBet: {
daily: [],
weekly: [],
monthly: [],
},
export default function Analytics() {
const [stats, setStats] = useState<Stats | undefined>(undefined)
useEffect(() => {
getStats().then(setStats)
}, [])
if (stats == null) {
return <></>
}
return (
<Page>
@ -275,7 +28,7 @@ export default function Analytics(props: {
tabs={[
{
title: 'Activity',
content: <CustomAnalytics {...props} />,
content: <CustomAnalytics {...stats} />,
},
{
title: 'Market Stats',

View File

@ -16,7 +16,6 @@
"jsx": "preserve",
"incremental": true
},
"watchOptions": {
"excludeDirectories": [".next"]
},

View File

@ -3148,6 +3148,11 @@
dependencies:
"@types/node" "*"
"@types/string-similarity@^4.0.0":
version "4.0.0"
resolved "https://registry.yarnpkg.com/@types/string-similarity/-/string-similarity-4.0.0.tgz#8cc03d5d1baad2b74530fe6c7d849d5768d391ad"
integrity sha512-dMS4S07fbtY1AILG/RhuwmptmzK1Ql8scmAebOTJ/8iBtK/KI17NwGwKzu1uipjj8Kk+3mfPxum56kKZE93mzQ==
"@types/unist@*", "@types/unist@^2.0.0", "@types/unist@^2.0.2", "@types/unist@^2.0.3":
version "2.0.6"
resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.6.tgz#250a7b16c3b91f672a24552ec64678eeb1d3a08d"
@ -10315,6 +10320,11 @@ streamsearch@^1.1.0:
resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764"
integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==
string-similarity@^4.0.4:
version "4.0.4"
resolved "https://registry.yarnpkg.com/string-similarity/-/string-similarity-4.0.4.tgz#42d01ab0b34660ea8a018da8f56a3309bb8b2a5b"
integrity sha512-/q/8Q4Bl4ZKAPjj8WerIBJWALKkaPRfrvhfF8k/B23i4nzrlRj2/go1m90In7nG/3XDSbOo0+pu6RvCTM9RGMQ==
string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"