Merge branch 'main' into referrals

This commit is contained in:
Ian Philips 2022-06-28 18:59:13 -05:00 committed by GitHub
commit dbb11d6637
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
44 changed files with 1046 additions and 624 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

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

@ -516,6 +516,22 @@ The American Civil Liberties Union is our nation's guardian of liberty, working
The U.S. Constitution and the Bill of Rights trumpet our aspirations for the kind of society that we want to be. But for much of our history, our nation failed to fulfill the promise of liberty for whole groups of people.`,
},
{
name: 'The Center for Election Science',
website: 'https://electionscience.org/',
photo: 'https://i.imgur.com/WvdHHZa.png',
preview:
'The Center for Election Science is a nonpartisan nonprofit dedicated to empowering voters with voting methods that strengthen democracy. We believe you deserve a vote that empowers you to impact the world you live in.',
description: `Founded in 2011, The Center for Election Science is a national, nonpartisan nonprofit focused on voting reform.
Our Mission To empower people with voting methods that strengthen democracy.
Our Vision A world where democracies thrive because voters voices are heard.
With an emphasis on approval voting, we bring better elections to people across the country through both advocacy and research.
The movement for a better way to vote is rapidly gaining momentum as voters grow tired of election results that dont represent the will of the people. In 2018, we worked with locals in Fargo, ND to help them become the first city in the U.S. to adopt approval voting. And in 2020, we helped grassroots activists empower the 300k people of St. Louis, MO with stronger democracy through approval voting.`,
},
].map((charity) => {
const slug = charity.name.toLowerCase().replace(/\s/g, '-')
return {

View File

@ -3,7 +3,8 @@
"version": "1.0.0",
"private": true,
"scripts": {
"verify": "(cd .. && yarn verify)"
"verify": "(cd .. && yarn verify)",
"verify:dir": "npx eslint . --max-warnings 0"
},
"sideEffects": false,
"dependencies": {

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

@ -398,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",
"predeploy": "cd functions && yarn build",
"runtime": "nodejs16",
"source": "functions"
"source": "functions/dist"
},
"firestore": {
"rules": "firestore.rules",

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', 'referredByUserId', 'referredByContractId']);
}
match /{somePath=**}/portfolioHistory/{portfolioHistoryId} {
allow read;
}

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

@ -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",
@ -16,9 +17,10 @@
"db:backup-local": "firebase emulators:export --force ./firestore_export",
"db:rename-remote-backup-folder": "gsutil mv gs://$npm_package_config_firestore/firestore_export gs://$npm_package_config_firestore/firestore_export_$(date +%d-%m-%Y-%H-%M)",
"db:backup-remote": "yarn db:rename-remote-backup-folder && gcloud firestore export gs://$npm_package_config_firestore/firestore_export/",
"verify": "(cd .. && yarn verify)"
"verify": "(cd .. && yarn verify)",
"verify:dir": "npx eslint . --max-warnings 0; tsc -b -v --pretty"
},
"main": "lib/functions/src/index.js",
"main": "functions/src/index.js",
"dependencies": {
"@amplitude/node": "1.10.0",
"fetch": "1.1.0",

View File

@ -15,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

@ -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

@ -8,7 +8,7 @@
"web"
],
"scripts": {
"verify": "(cd web && npx prettier --check .; yarn lint --max-warnings 0; tsc --pretty --project tsconfig.json --noEmit); (cd common && npx eslint . --max-warnings 0); (cd functions && npx eslint . --max-warnings 0; tsc --pretty --project tsconfig.json --noEmit)"
"verify": "(cd web && yarn verify:dir); (cd functions && yarn verify:dir)"
},
"dependencies": {},
"devDependencies": {

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

@ -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

@ -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

@ -35,6 +35,7 @@ import {
} from 'web/components/feed/feed-comments'
import { FeedBet } from 'web/components/feed/feed-bets'
import { NumericContract } from 'common/contract'
import { FeedLiquidity } from './feed-liquidity'
export function FeedItems(props: {
contract: Contract
@ -83,6 +84,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

@ -8,7 +8,7 @@ import { trackCallback } from 'web/lib/service/analytics'
export function ProfileSummary(props: { user: User }) {
const { user } = props
return (
<Link href={`/${user.username}`}>
<Link href={`/${user.username}?tab=bets`}>
<a
onClick={trackCallback('sidebar: profile')}
className="group flex flex-row items-center gap-4 rounded-md py-3 text-gray-500 hover:bg-gray-100 hover:text-gray-700"

View File

@ -5,7 +5,6 @@ import {
DotsHorizontalIcon,
CashIcon,
HeartIcon,
PresentationChartLineIcon,
UserGroupIcon,
ChevronDownIcon,
TrendingUpIcon,
@ -27,14 +26,9 @@ import { groupPath } from 'web/lib/firebase/groups'
import { trackCallback, withTracking } from 'web/lib/service/analytics'
import { Group } from 'common/group'
function getNavigation(username: string) {
function getNavigation() {
return [
{ name: 'Home', href: '/home', icon: HomeIcon },
{
name: 'Portfolio',
href: `/${username}?tab=bets`,
icon: PresentationChartLineIcon,
},
{
name: 'Notifications',
href: `/notifications`,
@ -63,12 +57,10 @@ function getMoreNavigation(user?: User | null) {
}
return [
{ name: 'Send M$', href: '/links' },
{ 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' },
{ name: 'Statistics', href: '/stats' },
{ name: 'About', href: 'https://docs.manifold.markets/$how-to' },
{
name: 'Sign out',
@ -106,6 +98,18 @@ const signedInMobileNavigation = [
...signedOutMobileNavigation,
]
function getMoreMobileNav() {
return [
{ name: 'Discord', href: 'https://discord.gg/eHQBNBqXuh' },
{ name: 'Statistics', href: '/stats' },
{
name: 'Sign out',
href: '#',
onClick: withTracking(firebaseLogout, 'sign out'),
},
]
}
export type Item = {
name: string
href: string
@ -177,9 +181,7 @@ export default function Sidebar(props: { className?: string }) {
const currentPage = router.pathname
const user = useUser()
const navigationOptions = !user
? signedOutNavigation
: getNavigation(user?.username || 'error')
const navigationOptions = !user ? signedOutNavigation : getNavigation()
const mobileNavigationOptions = !user
? signedOutMobileNavigation
: signedInMobileNavigation
@ -219,29 +221,7 @@ 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: '#',
onClick: withTracking(firebaseLogout, 'sign out'),
},
]}
menuItems={getMoreMobileNav()}
buttonContent={<MoreButton />}
/>
)}

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

@ -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

@ -65,19 +65,18 @@ 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>()

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

@ -17,7 +17,7 @@ const groupCollection = collection(db, 'groups')
export function groupPath(
groupSlug: string,
subpath?: 'edit' | 'questions' | 'details' | 'chat'
subpath?: 'edit' | 'questions' | 'about' | 'chat'
) {
return `/group/${groupSlug}${subpath ? `/${subpath}` : ''}`
}

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

@ -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 { 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'
@ -289,30 +288,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<{

View File

@ -14,7 +14,8 @@
"lint": "next lint",
"format": "npx prettier --write .",
"postbuild": "next-sitemap",
"verify": "(cd .. && yarn verify)"
"verify": "(cd .. && yarn verify)",
"verify:dir": "npx prettier --check .; yarn lint --max-warnings 0; tsc --pretty --project tsconfig.json --noEmit"
},
"dependencies": {
"@amplitude/analytics-browser": "0.4.1",
@ -41,7 +42,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",
@ -50,6 +52,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

@ -51,6 +51,7 @@ import { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns'
import { useRouter } from 'next/router'
import dayjs from 'dayjs'
import { addUserToGroupViaSlug, getGroup } from 'web/lib/firebase/groups'
import { useLiquidity } from 'web/hooks/use-liquidity'
export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: {
@ -126,6 +127,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)
@ -250,6 +253,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
@ -143,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

@ -26,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('')
@ -33,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>
@ -58,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>
@ -66,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('')

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

@ -22,7 +22,7 @@ import {
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'
@ -44,12 +44,14 @@ 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((_) => []) : []
@ -70,6 +72,7 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) {
return {
props: {
group,
members,
creator,
traderScores,
topTraders,
@ -96,10 +99,11 @@ async function toTopUsers(userScores: { [userId: string]: number }) {
export async function getStaticPaths() {
return { paths: [], fallback: 'blocking' }
}
const groupSubpages = [undefined, 'chat', '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[]
@ -108,14 +112,21 @@ 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[] }
@ -159,20 +170,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)
@ -186,13 +188,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>
@ -205,30 +216,27 @@ 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={'hidden text-gray-700 sm:block'}>
{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: 'Chat',
@ -272,26 +280,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'),
},
]}
/>
@ -299,6 +290,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
@ -306,7 +315,6 @@ function GroupOverview(props: {
isCreator: boolean
}) {
const { group, creator, isCreator, user } = props
const { about } = group
const anyoneCanJoinChoices: { [key: string]: string } = {
Closed: 'false',
Open: 'true',
@ -325,7 +333,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">
@ -337,7 +345,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 ? (
@ -356,14 +363,6 @@ function GroupOverview(props: {
</span>
)}
</Row>
{about && (
<>
<Spacer h={2} />
<div className="text-gray-500">
<Linkify text={about} />
</div>
</>
)}
</Col>
</Col>
)
@ -394,40 +393,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: {
@ -435,41 +418,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>
)
}

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"
@ -8026,11 +8031,6 @@ nanoid@^3.1.23, nanoid@^3.1.30, nanoid@^3.3.4:
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab"
integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==
nanoid@^3.3.4:
version "3.3.4"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab"
integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==
natural-compare@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
@ -10320,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"