Merge branch 'main' into comments-on-posts

This commit is contained in:
Pico2x 2022-09-07 15:50:53 +01:00
commit 93a85e2286
83 changed files with 1631 additions and 2194 deletions

158
common/calculate-metrics.ts Normal file
View File

@ -0,0 +1,158 @@
import { last, sortBy, sum, sumBy } from 'lodash'
import { calculatePayout } from './calculate'
import { Bet } from './bet'
import { Contract } from './contract'
import { PortfolioMetrics, User } from './user'
import { DAY_MS } from './util/time'
const computeInvestmentValue = (
bets: Bet[],
contractsDict: { [k: string]: Contract }
) => {
return sumBy(bets, (bet) => {
const contract = contractsDict[bet.contractId]
if (!contract || contract.isResolved) return 0
if (bet.sale || bet.isSold) return 0
const payout = calculatePayout(contract, bet, 'MKT')
const value = payout - (bet.loanAmount ?? 0)
if (isNaN(value)) return 0
return value
})
}
const computeTotalPool = (userContracts: Contract[], startTime = 0) => {
const periodFilteredContracts = userContracts.filter(
(contract) => contract.createdTime >= startTime
)
return sum(
periodFilteredContracts.map((contract) => sum(Object.values(contract.pool)))
)
}
export const computeVolume = (contractBets: Bet[], since: number) => {
return sumBy(contractBets, (b) =>
b.createdTime > since && !b.isRedemption ? Math.abs(b.amount) : 0
)
}
const calculateProbChangeSince = (descendingBets: Bet[], since: number) => {
const newestBet = descendingBets[0]
if (!newestBet) return 0
const betBeforeSince = descendingBets.find((b) => b.createdTime < since)
if (!betBeforeSince) {
const oldestBet = last(descendingBets) ?? newestBet
return newestBet.probAfter - oldestBet.probBefore
}
return newestBet.probAfter - betBeforeSince.probAfter
}
export const calculateProbChanges = (descendingBets: Bet[]) => {
const now = Date.now()
const yesterday = now - DAY_MS
const weekAgo = now - 7 * DAY_MS
const monthAgo = now - 30 * DAY_MS
return {
day: calculateProbChangeSince(descendingBets, yesterday),
week: calculateProbChangeSince(descendingBets, weekAgo),
month: calculateProbChangeSince(descendingBets, monthAgo),
}
}
export const calculateCreatorVolume = (userContracts: Contract[]) => {
const allTimeCreatorVolume = computeTotalPool(userContracts, 0)
const monthlyCreatorVolume = computeTotalPool(
userContracts,
Date.now() - 30 * DAY_MS
)
const weeklyCreatorVolume = computeTotalPool(
userContracts,
Date.now() - 7 * DAY_MS
)
const dailyCreatorVolume = computeTotalPool(
userContracts,
Date.now() - 1 * DAY_MS
)
return {
daily: dailyCreatorVolume,
weekly: weeklyCreatorVolume,
monthly: monthlyCreatorVolume,
allTime: allTimeCreatorVolume,
}
}
export const calculateNewPortfolioMetrics = (
user: User,
contractsById: { [k: string]: Contract },
currentBets: Bet[]
) => {
const investmentValue = computeInvestmentValue(currentBets, contractsById)
const newPortfolio = {
investmentValue: investmentValue,
balance: user.balance,
totalDeposits: user.totalDeposits,
timestamp: Date.now(),
userId: user.id,
}
return newPortfolio
}
const calculateProfitForPeriod = (
startTime: number,
descendingPortfolio: PortfolioMetrics[],
currentProfit: number
) => {
const startingPortfolio = descendingPortfolio.find(
(p) => p.timestamp < startTime
)
if (startingPortfolio === undefined) {
return currentProfit
}
const startingProfit = calculateTotalProfit(startingPortfolio)
return currentProfit - startingProfit
}
const calculateTotalProfit = (portfolio: PortfolioMetrics) => {
return portfolio.investmentValue + portfolio.balance - portfolio.totalDeposits
}
export const calculateNewProfit = (
portfolioHistory: PortfolioMetrics[],
newPortfolio: PortfolioMetrics
) => {
const allTimeProfit = calculateTotalProfit(newPortfolio)
const descendingPortfolio = sortBy(
portfolioHistory,
(p) => p.timestamp
).reverse()
const newProfit = {
daily: calculateProfitForPeriod(
Date.now() - 1 * DAY_MS,
descendingPortfolio,
allTimeProfit
),
weekly: calculateProfitForPeriod(
Date.now() - 7 * DAY_MS,
descendingPortfolio,
allTimeProfit
),
monthly: calculateProfitForPeriod(
Date.now() - 30 * DAY_MS,
descendingPortfolio,
allTimeProfit
),
allTime: allTimeProfit,
}
return newProfit
}

View File

@ -23,10 +23,16 @@ export type Comment<T extends AnyCommentType = AnyCommentType> = {
type OnContract = {
commentType: 'contract'
contractId: string
contractSlug: string
contractQuestion: string
answerOutcome?: string
betId?: string
// denormalized from contract
contractSlug: string
contractQuestion: string
// denormalized from bet
betAmount?: number
betOutcome?: string
}
type OnGroup = {

View File

@ -87,6 +87,12 @@ export type CPMM = {
pool: { [outcome: string]: number }
p: number // probability constant in y^p * n^(1-p) = k
totalLiquidity: number // in M$
prob: number
probChanges: {
day: number
week: number
month: number
}
}
export type Binary = {

View File

@ -6,13 +6,11 @@ export type Group = {
creatorId: string // User id
createdTime: number
mostRecentActivityTime: number
memberIds: string[] // User ids
anyoneCanJoin: boolean
contractIds: string[]
totalContracts: number
totalMembers: number
aboutPostId?: string
chatDisabled?: boolean
mostRecentChatActivityTime?: number
mostRecentContractAddedTime?: number
}
export const MAX_GROUP_NAME_LENGTH = 75

View File

@ -123,6 +123,8 @@ const getBinaryCpmmProps = (initialProb: number, ante: number) => {
initialProbability: p,
p,
pool: pool,
prob: initialProb,
probChanges: { day: 0, week: 0, month: 0 },
}
return system

View File

@ -54,6 +54,10 @@ Returns the authenticated user.
Gets all groups, in no particular order.
Parameters:
- `availableToUserId`: Optional. if specified, only groups that the user can
join and groups they've already joined will be returned.
Requires no authorization.
### `GET /v0/groups/[slug]`
@ -62,12 +66,18 @@ Gets a group by its slug.
Requires no authorization.
### `GET /v0/groups/by-id/[id]`
### `GET /v0/group/by-id/[id]`
Gets a group by its unique ID.
Requires no authorization.
### `GET /v0/group/by-id/[id]/markets`
Gets a group's markets by its unique ID.
Requires no authorization.
### `GET /v0/markets`
Lists all markets, ordered by creation date descending.

View File

@ -160,25 +160,40 @@ service cloud.firestore {
.hasOnly(['isSeen', 'viewTime']);
}
match /groups/{groupId} {
match /{somePath=**}/groupMembers/{memberId} {
allow read;
}
match /{somePath=**}/groupContracts/{contractId} {
allow read;
}
match /groups/{groupId} {
allow read;
allow update: if (request.auth.uid == resource.data.creatorId || isAdmin())
&& request.resource.data.diff(resource.data)
.affectedKeys()
.hasOnly(['name', 'about', 'contractIds', 'memberIds', 'anyoneCanJoin', 'aboutPostId' ]);
allow update: if (request.auth.uid in resource.data.memberIds || resource.data.anyoneCanJoin)
&& request.resource.data.diff(resource.data)
.affectedKeys()
.hasOnly([ 'contractIds', 'memberIds' ]);
.hasOnly(['name', 'about', 'anyoneCanJoin', 'aboutPostId' ]);
allow delete: if request.auth.uid == resource.data.creatorId;
function isMember() {
return request.auth.uid in get(/databases/$(database)/documents/groups/$(groupId)).data.memberIds;
match /groupContracts/{contractId} {
allow write: if isGroupMember() || request.auth.uid == get(/databases/$(database)/documents/groups/$(groupId)).data.creatorId
}
match /groupMembers/{memberId}{
allow create: if request.auth.uid == get(/databases/$(database)/documents/groups/$(groupId)).data.creatorId || (request.auth.uid == request.resource.data.userId && get(/databases/$(database)/documents/groups/$(groupId)).data.anyoneCanJoin);
allow delete: if request.auth.uid == resource.data.userId;
}
function isGroupMember() {
return exists(/databases/$(database)/documents/groups/$(groupId)/groupMembers/$(request.auth.uid));
}
match /comments/{commentId} {
allow read;
allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) && isMember();
allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) && isGroupMember();
}
}
match /posts/{postId} {

View File

@ -58,13 +58,23 @@ export const creategroup = newEndpoint({}, async (req, auth) => {
createdTime: Date.now(),
mostRecentActivityTime: Date.now(),
// TODO: allow users to add contract ids on group creation
contractIds: [],
anyoneCanJoin,
memberIds,
totalContracts: 0,
totalMembers: memberIds.length,
}
await groupRef.create(group)
// create a GroupMemberDoc for each member
await Promise.all(
memberIds.map((memberId) =>
groupRef.collection('groupMembers').doc(memberId).create({
userId: memberId,
createdTime: Date.now(),
})
)
)
return { status: 'success', group: group }
})

View File

@ -155,8 +155,14 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
}
group = groupDoc.data() as Group
const groupMembersSnap = await firestore
.collection(`groups/${groupId}/groupMembers`)
.get()
const groupMemberDocs = groupMembersSnap.docs.map(
(doc) => doc.data() as { userId: string; createdTime: number }
)
if (
!group.memberIds.includes(user.id) &&
!groupMemberDocs.map((m) => m.userId).includes(user.id) &&
!group.anyoneCanJoin &&
group.creatorId !== user.id
) {
@ -227,11 +233,20 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
await contractRef.create(contract)
if (group != null) {
if (!group.contractIds.includes(contractRef.id)) {
const groupContractsSnap = await firestore
.collection(`groups/${groupId}/groupContracts`)
.get()
const groupContracts = groupContractsSnap.docs.map(
(doc) => doc.data() as { contractId: string; createdTime: number }
)
if (!groupContracts.map((c) => c.contractId).includes(contractRef.id)) {
await createGroupLinks(group, [contractRef.id], auth.uid)
const groupDocRef = firestore.collection('groups').doc(group.id)
groupDocRef.update({
contractIds: uniq([...group.contractIds, contractRef.id]),
const groupContractRef = firestore
.collection(`groups/${groupId}/groupContracts`)
.doc(contract.id)
await groupContractRef.set({
contractId: contract.id,
createdTime: Date.now(),
})
}
}

View File

@ -1,6 +1,5 @@
import * as admin from 'firebase-admin'
import { z } from 'zod'
import { uniq } from 'lodash'
import { PrivateUser, User } from '../../common/user'
import { getUser, getUserByUsername, getValues } from './utils'
@ -17,7 +16,7 @@ import {
import { track } from './analytics'
import { APIError, newEndpoint, validate } from './api'
import { Group, NEW_USER_GROUP_SLUGS } from '../../common/group'
import { Group } from '../../common/group'
import { SUS_STARTING_BALANCE, STARTING_BALANCE } from '../../common/economy'
const bodySchema = z.object({
@ -117,23 +116,8 @@ const addUserToDefaultGroups = async (user: User) => {
firestore.collection('groups').where('slug', '==', slug)
)
await firestore
.collection('groups')
.doc(groups[0].id)
.update({
memberIds: uniq(groups[0].memberIds.concat(user.id)),
})
}
for (const slug of NEW_USER_GROUP_SLUGS) {
const groups = await getValues<Group>(
firestore.collection('groups').where('slug', '==', slug)
)
const group = groups[0]
await firestore
.collection('groups')
.doc(group.id)
.update({
memberIds: uniq(group.memberIds.concat(user.id)),
})
.collection(`groups/${groups[0].id}/groupMembers`)
.doc(user.id)
.set({ userId: user.id, createdTime: Date.now() })
}
}

View File

@ -186,7 +186,7 @@ export const sendPersonalFollowupEmail = async (
const emailBody = `Hi ${firstName},
Thanks for signing up! I'm one of the cofounders of Manifold Markets, and was wondering how you've found your exprience on the platform so far?
Thanks for signing up! I'm one of the cofounders of Manifold Markets, and was wondering how you've found your experience on the platform so far?
If you haven't already, I encourage you to try creating your own prediction market (https://manifold.markets/create) and joining our Discord chat (https://discord.com/invite/eHQBNBqXuh).

View File

@ -1,33 +0,0 @@
import * as admin from 'firebase-admin'
import {
APIError,
EndpointDefinition,
lookupUser,
parseCredentials,
writeResponseError,
} from './api'
const opts = {
method: 'GET',
minInstances: 1,
concurrency: 100,
memory: '2GiB',
cpu: 1,
} as const
export const getcustomtoken: EndpointDefinition = {
opts,
handler: async (req, res) => {
try {
const credentials = await parseCredentials(req)
if (credentials.kind != 'jwt') {
throw new APIError(403, 'API keys cannot mint custom tokens.')
}
const user = await lookupUser(credentials)
const token = await admin.auth().createCustomToken(user.uid)
res.status(200).json({ token: token })
} catch (e) {
writeResponseError(e, res)
}
},
}

View File

@ -21,9 +21,7 @@ export * from './on-follow-user'
export * from './on-unfollow-user'
export * from './on-create-liquidity-provision'
export * from './on-update-group'
export * from './on-create-group'
export * from './on-update-user'
export * from './on-create-comment-on-group'
export * from './on-create-txn'
export * from './on-delete-group'
export * from './score-contracts'
@ -72,7 +70,6 @@ import { unsubscribe } from './unsubscribe'
import { stripewebhook, createcheckoutsession } from './stripe'
import { getcurrentuser } from './get-current-user'
import { acceptchallenge } from './accept-challenge'
import { getcustomtoken } from './get-custom-token'
import { createpost } from './create-post'
const toCloudFunction = ({ opts, handler }: EndpointDefinition) => {
@ -98,7 +95,6 @@ const stripeWebhookFunction = toCloudFunction(stripewebhook)
const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession)
const getCurrentUserFunction = toCloudFunction(getcurrentuser)
const acceptChallenge = toCloudFunction(acceptchallenge)
const getCustomTokenFunction = toCloudFunction(getcustomtoken)
const createPostFunction = toCloudFunction(createpost)
export {
@ -122,6 +118,5 @@ export {
createCheckoutSessionFunction as createcheckoutsession,
getCurrentUserFunction as getcurrentuser,
acceptChallenge as acceptchallenge,
getCustomTokenFunction as getcustomtoken,
createPostFunction as createpost,
}

View File

@ -63,11 +63,15 @@ export const onCreateCommentOnContract = functions
.doc(comment.betId)
.get()
bet = betSnapshot.data() as Bet
answer =
contract.outcomeType === 'FREE_RESPONSE' && contract.answers
? contract.answers.find((answer) => answer.id === bet?.outcome)
: undefined
await change.ref.update({
betOutcome: bet.outcome,
betAmount: bet.amount,
})
}
const comments = await getValues<ContractComment>(

View File

@ -1,46 +0,0 @@
import * as functions from 'firebase-functions'
import { GroupComment } from '../../common/comment'
import * as admin from 'firebase-admin'
import { Group } from '../../common/group'
import { User } from '../../common/user'
import { createGroupCommentNotification } from './create-notification'
const firestore = admin.firestore()
export const onCreateCommentOnGroup = functions.firestore
.document('groups/{groupId}/comments/{commentId}')
.onCreate(async (change, context) => {
const { eventId } = context
const { groupId } = context.params as {
groupId: string
}
const comment = change.data() as GroupComment
const creatorSnapshot = await firestore
.collection('users')
.doc(comment.userId)
.get()
if (!creatorSnapshot.exists) throw new Error('Could not find user')
const groupSnapshot = await firestore
.collection('groups')
.doc(groupId)
.get()
if (!groupSnapshot.exists) throw new Error('Could not find group')
const group = groupSnapshot.data() as Group
await firestore.collection('groups').doc(groupId).update({
mostRecentChatActivityTime: comment.createdTime,
})
await Promise.all(
group.memberIds.map(async (memberId) => {
return await createGroupCommentNotification(
creatorSnapshot.data() as User,
memberId,
comment,
group,
eventId
)
})
)
})

View File

@ -1,28 +0,0 @@
import * as functions from 'firebase-functions'
import { getUser } from './utils'
import { createNotification } from './create-notification'
import { Group } from '../../common/group'
export const onCreateGroup = functions.firestore
.document('groups/{groupId}')
.onCreate(async (change, context) => {
const group = change.data() as Group
const { eventId } = context
const groupCreator = await getUser(group.creatorId)
if (!groupCreator) throw new Error('Could not find group creator')
// create notifications for all members of the group
await createNotification(
group.id,
'group',
'created',
groupCreator,
eventId,
group.about,
{
recipients: group.memberIds,
slug: group.slug,
title: group.name,
}
)
})

View File

@ -15,21 +15,68 @@ export const onUpdateGroup = functions.firestore
if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime)
return
if (prevGroup.contractIds.length < group.contractIds.length) {
await firestore
.collection('groups')
.doc(group.id)
.update({ mostRecentContractAddedTime: Date.now() })
//TODO: create notification with isSeeOnHref set to the group's /group/slug/questions url
// but first, let the new /group/slug/chat notification permeate so that we can differentiate between the two
}
await firestore
.collection('groups')
.doc(group.id)
.update({ mostRecentActivityTime: Date.now() })
})
export const onCreateGroupContract = functions.firestore
.document('groups/{groupId}/groupContracts/{contractId}')
.onCreate(async (change) => {
const groupId = change.ref.parent.parent?.id
if (groupId)
await firestore
.collection('groups')
.doc(groupId)
.update({
mostRecentContractAddedTime: Date.now(),
totalContracts: admin.firestore.FieldValue.increment(1),
})
})
export const onDeleteGroupContract = functions.firestore
.document('groups/{groupId}/groupContracts/{contractId}')
.onDelete(async (change) => {
const groupId = change.ref.parent.parent?.id
if (groupId)
await firestore
.collection('groups')
.doc(groupId)
.update({
mostRecentContractAddedTime: Date.now(),
totalContracts: admin.firestore.FieldValue.increment(-1),
})
})
export const onCreateGroupMember = functions.firestore
.document('groups/{groupId}/groupMembers/{memberId}')
.onCreate(async (change) => {
const groupId = change.ref.parent.parent?.id
if (groupId)
await firestore
.collection('groups')
.doc(groupId)
.update({
mostRecentActivityTime: Date.now(),
totalMembers: admin.firestore.FieldValue.increment(1),
})
})
export const onDeleteGroupMember = functions.firestore
.document('groups/{groupId}/groupMembers/{memberId}')
.onDelete(async (change) => {
const groupId = change.ref.parent.parent?.id
if (groupId)
await firestore
.collection('groups')
.doc(groupId)
.update({
mostRecentActivityTime: Date.now(),
totalMembers: admin.firestore.FieldValue.increment(-1),
})
})
export async function removeGroupLinks(group: Group, contractIds: string[]) {
for (const contractId of contractIds) {
const contract = await getContract(contractId)

View File

@ -1,108 +0,0 @@
import * as admin from 'firebase-admin'
import { initAdmin } from './script-init'
import { getValues, isProd } from '../utils'
import { CATEGORIES_GROUP_SLUG_POSTFIX } from 'common/categories'
import { Group, GroupLink } from 'common/group'
import { uniq } from 'lodash'
import { Contract } from 'common/contract'
import { User } from 'common/user'
import { filterDefined } from 'common/util/array'
import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID,
} from 'common/antes'
initAdmin()
const adminFirestore = admin.firestore()
const convertCategoriesToGroupsInternal = async (categories: string[]) => {
for (const category of categories) {
const markets = await getValues<Contract>(
adminFirestore
.collection('contracts')
.where('lowercaseTags', 'array-contains', category.toLowerCase())
)
const slug = category.toLowerCase() + CATEGORIES_GROUP_SLUG_POSTFIX
const oldGroup = await getValues<Group>(
adminFirestore.collection('groups').where('slug', '==', slug)
)
if (oldGroup.length > 0) {
console.log(`Found old group for ${category}`)
await adminFirestore.collection('groups').doc(oldGroup[0].id).delete()
}
const allUsers = await getValues<User>(adminFirestore.collection('users'))
const groupUsers = filterDefined(
allUsers.map((user: User) => {
if (!user.followedCategories || user.followedCategories.length === 0)
return user.id
if (!user.followedCategories.includes(category.toLowerCase()))
return null
return user.id
})
)
const manifoldAccount = isProd()
? HOUSE_LIQUIDITY_PROVIDER_ID
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
const newGroupRef = await adminFirestore.collection('groups').doc()
const newGroup: Group = {
id: newGroupRef.id,
name: category,
slug,
creatorId: manifoldAccount,
createdTime: Date.now(),
anyoneCanJoin: true,
memberIds: [manifoldAccount],
about: 'Default group for all things related to ' + category,
mostRecentActivityTime: Date.now(),
contractIds: markets.map((market) => market.id),
chatDisabled: true,
}
await adminFirestore.collection('groups').doc(newGroupRef.id).set(newGroup)
// Update group with new memberIds to avoid notifying everyone
await adminFirestore
.collection('groups')
.doc(newGroupRef.id)
.update({
memberIds: uniq(groupUsers),
})
for (const market of markets) {
if (market.groupLinks?.map((l) => l.groupId).includes(newGroup.id))
continue // already in that group
const newGroupLinks = [
...(market.groupLinks ?? []),
{
groupId: newGroup.id,
createdTime: Date.now(),
slug: newGroup.slug,
name: newGroup.name,
} as GroupLink,
]
await adminFirestore
.collection('contracts')
.doc(market.id)
.update({
groupSlugs: uniq([...(market.groupSlugs ?? []), newGroup.slug]),
groupLinks: newGroupLinks,
})
}
}
}
async function convertCategoriesToGroups() {
// const defaultCategories = Object.values(DEFAULT_CATEGORIES)
const moreCategories = ['world', 'culture']
await convertCategoriesToGroupsInternal(moreCategories)
}
if (require.main === module) {
convertCategoriesToGroups()
.then(() => process.exit())
.catch(console.log)
}

View File

@ -4,21 +4,23 @@ import * as admin from 'firebase-admin'
import { initAdmin } from './script-init'
import { isProd, log } from '../utils'
import { getSlug } from '../create-group'
import { Group } from '../../../common/group'
import { Group, GroupLink } from '../../../common/group'
import { uniq } from 'lodash'
import { Contract } from 'common/contract'
const getTaggedContractIds = async (tag: string) => {
const getTaggedContracts = async (tag: string) => {
const firestore = admin.firestore()
const results = await firestore
.collection('contracts')
.where('lowercaseTags', 'array-contains', tag.toLowerCase())
.get()
return results.docs.map((d) => d.id)
return results.docs.map((d) => d.data() as Contract)
}
const createGroup = async (
name: string,
about: string,
contractIds: string[]
contracts: Contract[]
) => {
const firestore = admin.firestore()
const creatorId = isProd()
@ -36,21 +38,60 @@ const createGroup = async (
about,
createdTime: now,
mostRecentActivityTime: now,
contractIds: contractIds,
anyoneCanJoin: true,
memberIds: [],
totalContracts: contracts.length,
totalMembers: 1,
}
return await groupRef.create(group)
await groupRef.create(group)
// create a GroupMemberDoc for the creator
const memberDoc = groupRef.collection('groupMembers').doc(creatorId)
await memberDoc.create({
userId: creatorId,
createdTime: now,
})
// create GroupContractDocs for each contractId
await Promise.all(
contracts
.map((c) => c.id)
.map((contractId) =>
groupRef.collection('groupContracts').doc(contractId).create({
contractId,
createdTime: now,
})
)
)
for (const market of contracts) {
if (market.groupLinks?.map((l) => l.groupId).includes(group.id)) continue // already in that group
const newGroupLinks = [
...(market.groupLinks ?? []),
{
groupId: group.id,
createdTime: Date.now(),
slug: group.slug,
name: group.name,
} as GroupLink,
]
await firestore
.collection('contracts')
.doc(market.id)
.update({
groupSlugs: uniq([...(market.groupSlugs ?? []), group.slug]),
groupLinks: newGroupLinks,
})
}
return { status: 'success', group: group }
}
const convertTagToGroup = async (tag: string, groupName: string) => {
log(`Looking up contract IDs with tag ${tag}...`)
const contractIds = await getTaggedContractIds(tag)
log(`${contractIds.length} contracts found.`)
if (contractIds.length > 0) {
const contracts = await getTaggedContracts(tag)
log(`${contracts.length} contracts found.`)
if (contracts.length > 0) {
log(`Creating group ${groupName}...`)
const about = `Contracts that used to be tagged ${tag}.`
const result = await createGroup(groupName, about, contractIds)
const result = await createGroup(groupName, about, contracts)
log(`Done. Group: `, result)
}
}

View File

@ -0,0 +1,69 @@
// Filling in the bet-based fields on comments.
import * as admin from 'firebase-admin'
import { zip } from 'lodash'
import { initAdmin } from './script-init'
import {
DocumentCorrespondence,
findDiffs,
describeDiff,
applyDiff,
} from './denormalize'
import { log } from '../utils'
import { Transaction } from 'firebase-admin/firestore'
initAdmin()
const firestore = admin.firestore()
async function getBetComments(transaction: Transaction) {
const allComments = await transaction.get(
firestore.collectionGroup('comments')
)
const betComments = allComments.docs.filter((d) => d.get('betId'))
log(`Found ${betComments.length} comments associated with bets.`)
return betComments
}
async function denormalize() {
let hasMore = true
while (hasMore) {
hasMore = await admin.firestore().runTransaction(async (trans) => {
const betComments = await getBetComments(trans)
const bets = await Promise.all(
betComments.map((doc) =>
trans.get(
firestore
.collection('contracts')
.doc(doc.get('contractId'))
.collection('bets')
.doc(doc.get('betId'))
)
)
)
log(`Found ${bets.length} bets associated with comments.`)
const mapping = zip(bets, betComments)
.map(([bet, comment]): DocumentCorrespondence => {
return [bet!, [comment!]] // eslint-disable-line
})
.filter(([bet, _]) => bet.exists) // dev DB has some invalid bet IDs
const amountDiffs = findDiffs(mapping, 'amount', 'betAmount')
const outcomeDiffs = findDiffs(mapping, 'outcome', 'betOutcome')
log(`Found ${amountDiffs.length} comments with mismatched amounts.`)
log(`Found ${outcomeDiffs.length} comments with mismatched outcomes.`)
const diffs = amountDiffs.concat(outcomeDiffs)
diffs.slice(0, 500).forEach((d) => {
log(describeDiff(d))
applyDiff(trans, d)
})
if (diffs.length > 500) {
console.log(`Applying first 500 because of Firestore limit...`)
}
return diffs.length > 500
})
}
}
if (require.main === module) {
denormalize().catch((e) => console.error(e))
}

View File

@ -0,0 +1,122 @@
import * as admin from 'firebase-admin'
import { Group } from 'common/group'
import { initAdmin } from 'functions/src/scripts/script-init'
import { log } from '../utils'
const getGroups = async () => {
const firestore = admin.firestore()
const groups = await firestore.collection('groups').get()
return groups.docs.map((doc) => doc.data() as Group)
}
// const createContractIdForGroup = async (
// groupId: string,
// contractId: string
// ) => {
// const firestore = admin.firestore()
// const now = Date.now()
// const contractDoc = await firestore
// .collection('groups')
// .doc(groupId)
// .collection('groupContracts')
// .doc(contractId)
// .get()
// if (!contractDoc.exists)
// await firestore
// .collection('groups')
// .doc(groupId)
// .collection('groupContracts')
// .doc(contractId)
// .create({
// contractId,
// createdTime: now,
// })
// }
// const createMemberForGroup = async (groupId: string, userId: string) => {
// const firestore = admin.firestore()
// const now = Date.now()
// const memberDoc = await firestore
// .collection('groups')
// .doc(groupId)
// .collection('groupMembers')
// .doc(userId)
// .get()
// if (!memberDoc.exists)
// await firestore
// .collection('groups')
// .doc(groupId)
// .collection('groupMembers')
// .doc(userId)
// .create({
// userId,
// createdTime: now,
// })
// }
// async function convertGroupFieldsToGroupDocuments() {
// const groups = await getGroups()
// for (const group of groups) {
// log('updating group', group.slug)
// const groupRef = admin.firestore().collection('groups').doc(group.id)
// const totalMembers = (await groupRef.collection('groupMembers').get()).size
// const totalContracts = (await groupRef.collection('groupContracts').get())
// .size
// if (
// totalMembers === group.memberIds?.length &&
// totalContracts === group.contractIds?.length
// ) {
// log('group already converted', group.slug)
// continue
// }
// const contractStart = totalContracts - 1 < 0 ? 0 : totalContracts - 1
// const membersStart = totalMembers - 1 < 0 ? 0 : totalMembers - 1
// for (const contractId of group.contractIds?.slice(
// contractStart,
// group.contractIds?.length
// ) ?? []) {
// await createContractIdForGroup(group.id, contractId)
// }
// for (const userId of group.memberIds?.slice(
// membersStart,
// group.memberIds?.length
// ) ?? []) {
// await createMemberForGroup(group.id, userId)
// }
// }
// }
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async function updateTotalContractsAndMembers() {
const groups = await getGroups()
for (const group of groups) {
log('updating group total contracts and members', group.slug)
const groupRef = admin.firestore().collection('groups').doc(group.id)
const totalMembers = (await groupRef.collection('groupMembers').get()).size
const totalContracts = (await groupRef.collection('groupContracts').get())
.size
await groupRef.update({
totalMembers,
totalContracts,
})
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async function removeUnusedMemberAndContractFields() {
const groups = await getGroups()
for (const group of groups) {
log('removing member and contract ids', group.slug)
const groupRef = admin.firestore().collection('groups').doc(group.id)
await groupRef.update({
memberIds: admin.firestore.FieldValue.delete(),
contractIds: admin.firestore.FieldValue.delete(),
})
}
}
if (require.main === module) {
initAdmin()
// convertGroupFieldsToGroupDocuments()
// updateTotalContractsAndMembers()
removeUnusedMemberAndContractFields()
}

View File

@ -26,7 +26,6 @@ import { resolvemarket } from './resolve-market'
import { unsubscribe } from './unsubscribe'
import { stripewebhook, createcheckoutsession } from './stripe'
import { getcurrentuser } from './get-current-user'
import { getcustomtoken } from './get-custom-token'
import { createpost } from './create-post'
type Middleware = (req: Request, res: Response, next: NextFunction) => void
@ -66,7 +65,6 @@ addJsonEndpointRoute('/resolvemarket', resolvemarket)
addJsonEndpointRoute('/unsubscribe', unsubscribe)
addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession)
addJsonEndpointRoute('/getcurrentuser', getcurrentuser)
addEndpointRoute('/getcustomtoken', getcustomtoken)
addEndpointRoute('/stripewebhook', stripewebhook, express.raw())
addEndpointRoute('/createpost', createpost)

View File

@ -1,43 +1,29 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { groupBy, isEmpty, keyBy, sum, sumBy } from 'lodash'
import { groupBy, isEmpty, keyBy, last, sortBy } from 'lodash'
import { getValues, log, logMemory, writeAsync } from './utils'
import { Bet } from '../../common/bet'
import { Contract } from '../../common/contract'
import { Contract, CPMM } from '../../common/contract'
import { PortfolioMetrics, User } from '../../common/user'
import { calculatePayout } from '../../common/calculate'
import { DAY_MS } from '../../common/util/time'
import { last } from 'lodash'
import { getLoanUpdates } from '../../common/loans'
import {
calculateCreatorVolume,
calculateNewPortfolioMetrics,
calculateNewProfit,
calculateProbChanges,
computeVolume,
} from '../../common/calculate-metrics'
import { getProbability } from '../../common/calculate'
const firestore = admin.firestore()
const computeInvestmentValue = (
bets: Bet[],
contractsDict: { [k: string]: Contract }
) => {
return sumBy(bets, (bet) => {
const contract = contractsDict[bet.contractId]
if (!contract || contract.isResolved) return 0
if (bet.sale || bet.isSold) return 0
export const updateMetrics = functions
.runWith({ memory: '2GB', timeoutSeconds: 540 })
.pubsub.schedule('every 15 minutes')
.onRun(updateMetricsCore)
const payout = calculatePayout(contract, bet, 'MKT')
const value = payout - (bet.loanAmount ?? 0)
if (isNaN(value)) return 0
return value
})
}
const computeTotalPool = (userContracts: Contract[], startTime = 0) => {
const periodFilteredContracts = userContracts.filter(
(contract) => contract.createdTime >= startTime
)
return sum(
periodFilteredContracts.map((contract) => sum(Object.values(contract.pool)))
)
}
export const updateMetricsCore = async () => {
export async function updateMetricsCore() {
const [users, contracts, bets, allPortfolioHistories] = await Promise.all([
getValues<User>(firestore.collection('users')),
getValues<Contract>(firestore.collection('contracts')),
@ -59,11 +45,29 @@ export const updateMetricsCore = async () => {
.filter((contract) => contract.id)
.map((contract) => {
const contractBets = betsByContract[contract.id] ?? []
const descendingBets = sortBy(
contractBets,
(bet) => bet.createdTime
).reverse()
let cpmmFields: Partial<CPMM> = {}
if (contract.mechanism === 'cpmm-1') {
const prob = descendingBets[0]
? descendingBets[0].probAfter
: getProbability(contract)
cpmmFields = {
prob,
probChanges: calculateProbChanges(descendingBets),
}
}
return {
doc: firestore.collection('contracts').doc(contract.id),
fields: {
volume24Hours: computeVolume(contractBets, now - DAY_MS),
volume7Days: computeVolume(contractBets, now - DAY_MS * 7),
...cpmmFields,
},
}
})
@ -88,23 +92,20 @@ export const updateMetricsCore = async () => {
currentBets
)
const lastPortfolio = last(portfolioHistory)
const didProfitChange =
const didPortfolioChange =
lastPortfolio === undefined ||
lastPortfolio.balance !== newPortfolio.balance ||
lastPortfolio.totalDeposits !== newPortfolio.totalDeposits ||
lastPortfolio.investmentValue !== newPortfolio.investmentValue
const newProfit = calculateNewProfit(
portfolioHistory,
newPortfolio,
didProfitChange
)
const newProfit = calculateNewProfit(portfolioHistory, newPortfolio)
return {
user,
newCreatorVolume,
newPortfolio,
newProfit,
didProfitChange,
didPortfolioChange,
}
})
@ -120,16 +121,20 @@ export const updateMetricsCore = async () => {
const nextLoanByUser = keyBy(userPayouts, (payout) => payout.user.id)
const userUpdates = userMetrics.map(
({ user, newCreatorVolume, newPortfolio, newProfit, didProfitChange }) => {
({
user,
newCreatorVolume,
newPortfolio,
newProfit,
didPortfolioChange,
}) => {
const nextLoanCached = nextLoanByUser[user.id]?.payout ?? 0
return {
fieldUpdates: {
doc: firestore.collection('users').doc(user.id),
fields: {
creatorVolumeCached: newCreatorVolume,
...(didProfitChange && {
profitCached: newProfit,
}),
profitCached: newProfit,
nextLoanCached,
},
},
@ -140,11 +145,7 @@ export const updateMetricsCore = async () => {
.doc(user.id)
.collection('portfolioHistory')
.doc(),
fields: {
...(didProfitChange && {
...newPortfolio,
}),
},
fields: didPortfolioChange ? newPortfolio : {},
},
}
}
@ -162,108 +163,3 @@ export const updateMetricsCore = async () => {
)
log(`Updated metrics for ${users.length} users.`)
}
const computeVolume = (contractBets: Bet[], since: number) => {
return sumBy(contractBets, (b) =>
b.createdTime > since && !b.isRedemption ? Math.abs(b.amount) : 0
)
}
const calculateProfitForPeriod = (
startTime: number,
portfolioHistory: PortfolioMetrics[],
currentProfit: number
) => {
const startingPortfolio = [...portfolioHistory]
.reverse() // so we search in descending order (most recent first), for efficiency
.find((p) => p.timestamp < startTime)
if (startingPortfolio === undefined) {
return 0
}
const startingProfit = calculateTotalProfit(startingPortfolio)
return currentProfit - startingProfit
}
const calculateTotalProfit = (portfolio: PortfolioMetrics) => {
return portfolio.investmentValue + portfolio.balance - portfolio.totalDeposits
}
const calculateCreatorVolume = (userContracts: Contract[]) => {
const allTimeCreatorVolume = computeTotalPool(userContracts, 0)
const monthlyCreatorVolume = computeTotalPool(
userContracts,
Date.now() - 30 * DAY_MS
)
const weeklyCreatorVolume = computeTotalPool(
userContracts,
Date.now() - 7 * DAY_MS
)
const dailyCreatorVolume = computeTotalPool(
userContracts,
Date.now() - 1 * DAY_MS
)
return {
daily: dailyCreatorVolume,
weekly: weeklyCreatorVolume,
monthly: monthlyCreatorVolume,
allTime: allTimeCreatorVolume,
}
}
const calculateNewPortfolioMetrics = (
user: User,
contractsById: { [k: string]: Contract },
currentBets: Bet[]
) => {
const investmentValue = computeInvestmentValue(currentBets, contractsById)
const newPortfolio = {
investmentValue: investmentValue,
balance: user.balance,
totalDeposits: user.totalDeposits,
timestamp: Date.now(),
userId: user.id,
}
return newPortfolio
}
const calculateNewProfit = (
portfolioHistory: PortfolioMetrics[],
newPortfolio: PortfolioMetrics,
didProfitChange: boolean
) => {
if (!didProfitChange) {
return {} // early return for performance
}
const allTimeProfit = calculateTotalProfit(newPortfolio)
const newProfit = {
daily: calculateProfitForPeriod(
Date.now() - 1 * DAY_MS,
portfolioHistory,
allTimeProfit
),
weekly: calculateProfitForPeriod(
Date.now() - 7 * DAY_MS,
portfolioHistory,
allTimeProfit
),
monthly: calculateProfitForPeriod(
Date.now() - 30 * DAY_MS,
portfolioHistory,
allTimeProfit
),
allTime: allTimeProfit,
}
return newProfit
}
export const updateMetrics = functions
.runWith({ memory: '2GB', timeoutSeconds: 540 })
.pubsub.schedule('every 15 minutes')
.onRun(updateMetricsCore)

View File

@ -22,13 +22,13 @@ export function getHtml(parsedReq: ParsedRequest) {
const hideAvatar = creatorAvatarUrl ? '' : 'hidden'
let resolutionColor = 'text-primary'
let resolutionString = 'Yes'
let resolutionString = 'YES'
switch (resolution) {
case 'YES':
break
case 'NO':
resolutionColor = 'text-red-500'
resolutionString = 'No'
resolutionString = 'NO'
break
case 'CANCEL':
resolutionColor = 'text-yellow-500'
@ -118,7 +118,9 @@ export function getHtml(parsedReq: ParsedRequest) {
? resolutionDiv
: numericValue
? numericValueDiv
: probabilityDiv
: probability
? probabilityDiv
: ''
}
</div>
</div>

View File

@ -84,6 +84,7 @@ export function BuyAmountInput(props: {
setError: (error: string | undefined) => void
minimumAmount?: number
disabled?: boolean
showSliderOnMobile?: boolean
className?: string
inputClassName?: string
// Needed to focus the amount input
@ -94,6 +95,7 @@ export function BuyAmountInput(props: {
onChange,
error,
setError,
showSliderOnMobile: showSlider,
disabled,
className,
inputClassName,
@ -121,15 +123,28 @@ export function BuyAmountInput(props: {
}
return (
<AmountInput
amount={amount}
onChange={onAmountChange}
label={ENV_CONFIG.moneyMoniker}
error={error}
disabled={disabled}
className={className}
inputClassName={inputClassName}
inputRef={inputRef}
/>
<>
<AmountInput
amount={amount}
onChange={onAmountChange}
label={ENV_CONFIG.moneyMoniker}
error={error}
disabled={disabled}
className={className}
inputClassName={inputClassName}
inputRef={inputRef}
/>
{showSlider && (
<input
type="range"
min="0"
max="200"
value={amount ?? 0}
onChange={(e) => onAmountChange(parseInt(e.target.value))}
className="range range-lg z-40 mb-2 xl:hidden"
step="5"
/>
)}
</>
)
}

View File

@ -134,8 +134,9 @@ export function AnswerBetPanel(props: {
</Row>
<Row className="my-3 justify-between text-left text-sm text-gray-500">
Amount
<span>(balance: {formatMoney(user?.balance ?? 0)})</span>
<span>Balance: {formatMoney(user?.balance ?? 0)}</span>
</Row>
<BuyAmountInput
inputClassName="w-full max-w-none"
amount={betAmount}
@ -144,6 +145,7 @@ export function AnswerBetPanel(props: {
setError={setError}
disabled={isSubmitting}
inputRef={inputRef}
showSliderOnMobile
/>
{(betAmount ?? 0) > 10 &&

View File

@ -120,7 +120,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
return (
<Col className="gap-4 rounded">
<Col className="flex-1 gap-2">
<Col className="flex-1 gap-2 px-4 xl:px-0">
<div className="mb-1">Add your answer</div>
<Textarea
value={text}
@ -152,7 +152,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
<Row className="my-3 justify-between text-left text-sm text-gray-500">
Bet Amount
<span className={'sm:hidden'}>
(balance: {formatMoney(user?.balance ?? 0)})
Balance: {formatMoney(user?.balance ?? 0)}
</span>
</Row>{' '}
<BuyAmountInput
@ -162,6 +162,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
setError={setAmountError}
minimumAmount={1}
disabled={isSubmitting}
showSliderOnMobile
/>
</Col>
<Col className="gap-3">
@ -205,7 +206,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
disabled={!canSubmit}
onClick={withTracking(submitAnswer, 'submit answer')}
>
Submit answer & buy
Submit
</button>
) : (
text && (

View File

@ -67,6 +67,16 @@ export function AuthProvider(props: {
}
}, [setAuthUser, serverUser])
useEffect(() => {
if (authUser != null) {
// Persist to local storage, to reduce login blink next time.
// Note: Cap on localStorage size is ~5mb
localStorage.setItem(CACHED_USER_KEY, JSON.stringify(authUser))
} else {
localStorage.removeItem(CACHED_USER_KEY)
}
}, [authUser])
useEffect(() => {
return onIdTokenChanged(
auth,
@ -77,17 +87,13 @@ export function AuthProvider(props: {
if (!current.user || !current.privateUser) {
const deviceToken = ensureDeviceToken()
current = (await createUser({ deviceToken })) as UserAndPrivateUser
setCachedReferralInfoForUser(current.user)
}
setAuthUser(current)
// Persist to local storage, to reduce login blink next time.
// Note: Cap on localStorage size is ~5mb
localStorage.setItem(CACHED_USER_KEY, JSON.stringify(current))
setCachedReferralInfoForUser(current.user)
} else {
// User logged out; reset to null
setUserCookie(undefined)
setAuthUser(null)
localStorage.removeItem(CACHED_USER_KEY)
}
},
(e) => {
@ -97,29 +103,32 @@ export function AuthProvider(props: {
}, [setAuthUser])
const uid = authUser?.user.id
const username = authUser?.user.username
useEffect(() => {
if (uid && username) {
if (uid) {
identifyUser(uid)
setUserProperty('username', username)
const userListener = listenForUser(uid, (user) =>
setAuthUser((authUser) => {
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
return { ...authUser!, user: user! }
})
)
const userListener = listenForUser(uid, (user) => {
setAuthUser((currAuthUser) =>
currAuthUser && user ? { ...currAuthUser, user } : null
)
})
const privateUserListener = listenForPrivateUser(uid, (privateUser) => {
setAuthUser((authUser) => {
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
return { ...authUser!, privateUser: privateUser! }
})
setAuthUser((currAuthUser) =>
currAuthUser && privateUser ? { ...currAuthUser, privateUser } : null
)
})
return () => {
userListener()
privateUserListener()
}
}
}, [uid, username, setAuthUser])
}, [uid, setAuthUser])
const username = authUser?.user.username
useEffect(() => {
if (username != null) {
setUserProperty('username', username)
}
}, [username])
return (
<AuthContext.Provider value={authUser}>{children}</AuthContext.Provider>

View File

@ -8,6 +8,7 @@ import { Col } from './layout/col'
import { Row } from './layout/row'
import { Spacer } from './layout/spacer'
import {
formatLargeNumber,
formatMoney,
formatPercent,
formatWithCommas,
@ -28,7 +29,7 @@ import { getProbability } from 'common/calculate'
import { useFocus } from 'web/hooks/use-focus'
import { useUserContractBets } from 'web/hooks/use-user-bets'
import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm'
import { getFormattedMappedValue } from 'common/pseudo-numeric'
import { getFormattedMappedValue, getMappedValue } from 'common/pseudo-numeric'
import { SellRow } from './sell-row'
import { useSaveBinaryShares } from './use-save-binary-shares'
import { BetSignUpPrompt } from './sign-up-prompt'
@ -256,17 +257,43 @@ function BuyPanel(props: {
const resultProb = getCpmmProbability(newPool, newP)
const probStayedSame =
formatPercent(resultProb) === formatPercent(initialProb)
const probChange = Math.abs(resultProb - initialProb)
const currentPayout = newBet.shares
const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0
const currentReturnPercent = formatPercent(currentReturn)
const format = getFormattedMappedValue(contract)
const getValue = getMappedValue(contract)
const rawDifference = Math.abs(getValue(resultProb) - getValue(initialProb))
const displayedDifference = isPseudoNumeric
? formatLargeNumber(rawDifference)
: formatPercent(rawDifference)
const bankrollFraction = (betAmount ?? 0) / (user?.balance ?? 1e9)
const warning =
(betAmount ?? 0) > 10 &&
bankrollFraction >= 0.5 &&
bankrollFraction <= 1 ? (
<AlertBox
title="Whoa, there!"
text={`You might not want to spend ${formatPercent(
bankrollFraction
)} of your balance on a single bet. \n\nCurrent balance: ${formatMoney(
user?.balance ?? 0
)}`}
/>
) : (betAmount ?? 0) > 10 && probChange >= 0.3 && bankrollFraction <= 1 ? (
<AlertBox
title="Whoa, there!"
text={`Are you sure you want to move the market by ${displayedDifference}?`}
/>
) : (
<></>
)
return (
<Col className={hidden ? 'hidden' : ''}>
<div className="my-3 text-left text-sm text-gray-500">
@ -283,9 +310,10 @@ function BuyPanel(props: {
<Row className="my-3 justify-between text-left text-sm text-gray-500">
Amount
<span className={'xl:hidden'}>
(balance: {formatMoney(user?.balance ?? 0)})
Balance: {formatMoney(user?.balance ?? 0)}
</span>
</Row>
<BuyAmountInput
inputClassName="w-full max-w-none"
amount={betAmount}
@ -294,35 +322,10 @@ function BuyPanel(props: {
setError={setError}
disabled={isSubmitting}
inputRef={inputRef}
showSliderOnMobile
/>
{(betAmount ?? 0) > 10 &&
bankrollFraction >= 0.5 &&
bankrollFraction <= 1 ? (
<AlertBox
title="Whoa, there!"
text={`You might not want to spend ${formatPercent(
bankrollFraction
)} of your balance on a single bet. \n\nCurrent balance: ${formatMoney(
user?.balance ?? 0
)}`}
/>
) : (
''
)}
{(betAmount ?? 0) > 10 && probChange >= 0.3 ? (
<AlertBox
title="Whoa, there!"
text={`Are you sure you want to move the market ${
isPseudoNumeric && contract.isLogScale
? 'this much'
: format(probChange)
}?`}
/>
) : (
''
)}
{warning}
<Col className="mt-3 w-full gap-3">
<Row className="items-center justify-between text-sm">
@ -351,9 +354,6 @@ function BuyPanel(props: {
</>
)}
</div>
{/* <InfoTooltip
text={`Includes ${formatMoneyWithDecimals(totalFees)} in fees`}
/> */}
</Row>
<div>
<span className="mr-2 whitespace-nowrap">
@ -608,9 +608,10 @@ function LimitOrderPanel(props: {
Max amount<span className="ml-1 text-red-500">*</span>
</span>
<span className={'xl:hidden'}>
(balance: {formatMoney(user?.balance ?? 0)})
Balance: {formatMoney(user?.balance ?? 0)}
</span>
</Row>
<BuyAmountInput
inputClassName="w-full max-w-none"
amount={betAmount}
@ -618,6 +619,7 @@ function LimitOrderPanel(props: {
error={error}
setError={setError}
disabled={isSubmitting}
showSliderOnMobile
/>
<Col className="mt-3 w-full gap-3">

View File

@ -1,14 +1,13 @@
import Link from 'next/link'
import { keyBy, groupBy, mapValues, sortBy, partition, sumBy } from 'lodash'
import dayjs from 'dayjs'
import { useEffect, useMemo, useState } from 'react'
import { useMemo, useState } from 'react'
import clsx from 'clsx'
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid'
import { Bet } from 'web/lib/firebase/bets'
import { User } from 'web/lib/firebase/users'
import {
formatLargeNumber,
formatMoney,
formatPercent,
formatWithCommas,
@ -35,8 +34,6 @@ import {
resolvedPayout,
getContractBetNullMetrics,
} from 'common/calculate'
import { useTimeSinceFirstRender } from 'web/hooks/use-time-since-first-render'
import { trackLatency } from 'web/lib/firebase/tracking'
import { NumericContract } from 'common/contract'
import { formatNumericProbability } from 'common/pseudo-numeric'
import { useUser } from 'web/hooks/use-user'
@ -85,13 +82,6 @@ export function BetsList(props: { user: User }) {
const start = page * CONTRACTS_PER_PAGE
const end = start + CONTRACTS_PER_PAGE
const getTime = useTimeSinceFirstRender()
useEffect(() => {
if (bets && contractsById && signedInUser) {
trackLatency(signedInUser.id, 'portfolio', getTime())
}
}, [signedInUser, bets, contractsById, getTime])
if (!bets || !contractsById) {
return <LoadingIndicator />
}
@ -171,7 +161,7 @@ export function BetsList(props: { user: User }) {
((currentBetsValue - currentInvested) / (currentInvested + 0.1)) * 100
return (
<Col className="mt-6">
<Col>
<Col className="mx-4 gap-4 sm:flex-row sm:justify-between md:mx-0">
<Row className="gap-8">
<Col>
@ -219,26 +209,27 @@ export function BetsList(props: { user: User }) {
<Col className="mt-6 divide-y">
{displayedContracts.length === 0 ? (
<NoBets user={user} />
<NoMatchingBets />
) : (
displayedContracts.map((contract) => (
<ContractBets
key={contract.id}
contract={contract}
bets={contractBets[contract.id] ?? []}
metric={sort === 'profit' ? 'profit' : 'value'}
isYourBets={isYourBets}
<>
{displayedContracts.map((contract) => (
<ContractBets
key={contract.id}
contract={contract}
bets={contractBets[contract.id] ?? []}
metric={sort === 'profit' ? 'profit' : 'value'}
isYourBets={isYourBets}
/>
))}
<Pagination
page={page}
itemsPerPage={CONTRACTS_PER_PAGE}
totalItems={filteredContracts.length}
setPage={setPage}
/>
))
</>
)}
</Col>
<Pagination
page={page}
itemsPerPage={CONTRACTS_PER_PAGE}
totalItems={filteredContracts.length}
setPage={setPage}
/>
</Col>
)
}
@ -246,7 +237,7 @@ export function BetsList(props: { user: User }) {
const NoBets = ({ user }: { user: User }) => {
const me = useUser()
return (
<div className="mx-4 text-gray-500">
<div className="mx-4 py-4 text-gray-500">
{user.id === me?.id ? (
<>
You have not made any bets yet.{' '}
@ -260,6 +251,11 @@ const NoBets = ({ user }: { user: User }) => {
</div>
)
}
const NoMatchingBets = () => (
<div className="mx-4 py-4 text-gray-500">
No bets matching the current filter.
</div>
)
function ContractBets(props: {
contract: Contract
@ -483,23 +479,6 @@ export function BetsSummary(props: {
<div className="whitespace-nowrap">{formatMoney(noWinnings)}</div>
</Col>
</>
) : isPseudoNumeric ? (
<>
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">
Payout if {'>='} {formatLargeNumber(contract.max)}
</div>
<div className="whitespace-nowrap">
{formatMoney(yesWinnings)}
</div>
</Col>
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">
Payout if {'<='} {formatLargeNumber(contract.min)}
</div>
<div className="whitespace-nowrap">{formatMoney(noWinnings)}</div>
</Col>
</>
) : (
<Col>
<div className="whitespace-nowrap text-sm text-gray-500">

View File

@ -33,7 +33,7 @@ export function Carousel(props: {
}, 500)
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(onScroll, [])
useEffect(onScroll, [children])
return (
<div className={clsx('relative', className)}>

View File

@ -18,6 +18,7 @@ import { NoLabel, YesLabel } from '../outcome-label'
import { QRCode } from '../qr-code'
import { copyToClipboard } from 'web/lib/util/copy'
import { AmountInput } from '../amount-input'
import { getProbability } from 'common/calculate'
import { createMarket } from 'web/lib/firebase/api'
import { removeUndefinedProps } from 'common/util/object'
import { FIXED_ANTE } from 'common/economy'
@ -25,7 +26,6 @@ import Textarea from 'react-expanding-textarea'
import { useTextEditor } from 'web/components/editor'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { track } from 'web/lib/service/analytics'
import { useWindowSize } from 'web/hooks/use-window-size'
type challengeInfo = {
amount: number
@ -110,9 +110,8 @@ function CreateChallengeForm(props: {
const [isCreating, setIsCreating] = useState(false)
const [finishedCreating, setFinishedCreating] = useState(false)
const [error, setError] = useState<string>('')
const [editingAcceptorAmount, setEditingAcceptorAmount] = useState(false)
const defaultExpire = 'week'
const { width } = useWindowSize()
const isMobile = (width ?? 0) < 768
const [challengeInfo, setChallengeInfo] = useState<challengeInfo>({
expiresTime: dayjs().add(2, defaultExpire).valueOf(),
@ -158,7 +157,7 @@ function CreateChallengeForm(props: {
<Textarea
placeholder="e.g. Will a Democrat be the next president?"
className="input input-bordered mt-1 w-full resize-none"
autoFocus={!isMobile}
autoFocus={true}
maxLength={MAX_QUESTION_LENGTH}
value={challengeInfo.question}
onChange={(e) =>
@ -171,7 +170,7 @@ function CreateChallengeForm(props: {
)}
</div>
<Col className="mt-2 flex-wrap justify-center gap-x-5 gap-y-0 sm:gap-y-2">
<Col className="mt-2 flex-wrap justify-center gap-x-5 sm:gap-y-2">
<Col>
<div>You'll bet:</div>
<Row
@ -186,7 +185,9 @@ function CreateChallengeForm(props: {
return {
...m,
amount: newAmount ?? 0,
acceptorAmount: newAmount ?? 0,
acceptorAmount: editingAcceptorAmount
? m.acceptorAmount
: newAmount ?? 0,
}
})
}
@ -197,7 +198,7 @@ function CreateChallengeForm(props: {
<span className={''}>on</span>
{challengeInfo.outcome === 'YES' ? <YesLabel /> : <NoLabel />}
</Row>
<Row className={'max-w-xs justify-end'}>
<Row className={'mt-3 max-w-xs justify-end'}>
<Button
color={'gray-white'}
onClick={() =>
@ -212,18 +213,50 @@ function CreateChallengeForm(props: {
<SwitchVerticalIcon className={'h-6 w-6'} />
</Button>
</Row>
<Row className={'items-center'}>If they bet:</Row>
<Row
className={'max-w-xs items-center justify-between gap-4 pr-3'}
>
<div className={'w-32 sm:mr-1'}>
<AmountInput
amount={challengeInfo.acceptorAmount || undefined}
onChange={(newAmount) => {
setEditingAcceptorAmount(true)
setChallengeInfo((m: challengeInfo) => {
return {
...m,
acceptorAmount: newAmount ?? 0,
}
})
}}
error={undefined}
label={'M$'}
inputClassName="w-24"
/>
</div>
<span>on</span>
{challengeInfo.outcome === 'YES' ? <NoLabel /> : <YesLabel />}
</Row>
</Col>
<Row className={'items-center'}>If they bet:</Row>
<Row className={'max-w-xs items-center justify-between gap-4 pr-3'}>
<div className={'mt-1 w-32 sm:mr-1'}>
<span className={'ml-2 font-bold'}>
{formatMoney(challengeInfo.acceptorAmount)}
</span>
</div>
<span>on</span>
{challengeInfo.outcome === 'YES' ? <NoLabel /> : <YesLabel />}
</Row>
</Col>
{contract && (
<Button
size="2xs"
color="gray"
onClick={() => {
setEditingAcceptorAmount(true)
const p = getProbability(contract)
const prob = challengeInfo.outcome === 'YES' ? p : 1 - p
const { amount } = challengeInfo
const acceptorAmount = Math.round(amount / prob - amount)
setChallengeInfo({ ...challengeInfo, acceptorAmount })
}}
>
Use market odds
</Button>
)}
<div className="mt-8">
If the challenge is accepted, whoever is right will earn{' '}
<span className="font-semibold">

View File

@ -43,10 +43,13 @@ export const SORTS = [
{ label: 'Trending', value: 'score' },
{ label: 'Most traded', value: 'most-traded' },
{ label: '24h volume', value: '24-hour-vol' },
{ label: '24h change', value: 'prob-change-day' },
{ label: 'Last updated', value: 'last-updated' },
{ label: 'Subsidy', value: 'liquidity' },
{ label: 'Close date', value: 'close-date' },
{ label: 'Resolve date', value: 'resolve-date' },
{ label: 'Highest %', value: 'prob-descending' },
{ label: 'Lowest %', value: 'prob-ascending' },
] as const
export type Sort = typeof SORTS[number]['value']
@ -282,8 +285,8 @@ function ContractSearchControls(props: {
: DEFAULT_CATEGORY_GROUPS.map((g) => g.slug)
const memberPillGroups = sortBy(
memberGroups.filter((group) => group.contractIds.length > 0),
(group) => group.contractIds.length
memberGroups.filter((group) => group.totalContracts > 0),
(group) => group.totalContracts
).reverse()
const pillGroups: { name: string; slug: string }[] =

View File

@ -35,7 +35,6 @@ import { Tooltip } from '../tooltip'
export function ContractCard(props: {
contract: Contract
showHotVolume?: boolean
showTime?: ShowTime
className?: string
questionClass?: string
@ -45,7 +44,6 @@ export function ContractCard(props: {
trackingPostfix?: string
}) {
const {
showHotVolume,
showTime,
className,
questionClass,
@ -147,7 +145,6 @@ export function ContractCard(props: {
<AvatarDetails contract={contract} short={true} className="md:hidden" />
<MiscDetails
contract={contract}
showHotVolume={showHotVolume}
showTime={showTime}
hideGroupLink={hideGroupLink}
/>

View File

@ -2,7 +2,6 @@ import {
ClockIcon,
DatabaseIcon,
PencilIcon,
TrendingUpIcon,
UserGroupIcon,
} from '@heroicons/react/outline'
import clsx from 'clsx'
@ -40,30 +39,19 @@ export type ShowTime = 'resolve-date' | 'close-date'
export function MiscDetails(props: {
contract: Contract
showHotVolume?: boolean
showTime?: ShowTime
hideGroupLink?: boolean
}) {
const { contract, showHotVolume, showTime, hideGroupLink } = props
const {
volume,
volume24Hours,
closeTime,
isResolved,
createdTime,
resolutionTime,
} = contract
const { contract, showTime, hideGroupLink } = props
const { volume, closeTime, isResolved, createdTime, resolutionTime } =
contract
const isNew = createdTime > Date.now() - DAY_MS && !isResolved
const groupToDisplay = getGroupLinkToDisplay(contract)
return (
<Row className="items-center gap-3 truncate text-sm text-gray-400">
{showHotVolume ? (
<Row className="gap-0.5">
<TrendingUpIcon className="h-5 w-5" /> {formatMoney(volume24Hours)}
</Row>
) : showTime === 'close-date' ? (
{showTime === 'close-date' ? (
<Row className="gap-0.5 whitespace-nowrap">
<ClockIcon className="h-5 w-5" />
{(closeTime || 0) < Date.now() ? 'Closed' : 'Closes'}{' '}
@ -369,7 +357,7 @@ function EditableCloseDate(props: {
return (
<>
{isEditingCloseTime ? (
<Row className="z-10 mr-2 w-full shrink-0 items-start items-center gap-1">
<Row className="z-10 mr-2 w-full shrink-0 items-center gap-1">
<input
type="date"
className="input input-bordered shrink-0"

View File

@ -109,10 +109,6 @@ export function ContractTopTrades(props: {
betsBySameUser={[betsById[topCommentId]]}
/>
</div>
<div className="mt-2 text-sm text-gray-500">
{commentsById[topCommentId].userName} made{' '}
{formatMoney(profitById[topCommentId] || 0)}!
</div>
<Spacer h={16} />
</>
)}
@ -120,11 +116,11 @@ export function ContractTopTrades(props: {
{/* If they're the same, only show the comment; otherwise show both */}
{topBettor && topBetId !== topCommentId && profitById[topBetId] > 0 && (
<>
<Title text="💸 Smartest money" className="!mt-0" />
<Title text="💸 Best bet" className="!mt-0" />
<div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">
<FeedBet contract={contract} bet={betsById[topBetId]} />
</div>
<div className="mt-2 text-sm text-gray-500">
<div className="mt-2 ml-2 text-sm text-gray-500">
{topBettor?.name} made {formatMoney(profitById[topBetId] || 0)}!
</div>
</>

View File

@ -42,7 +42,6 @@ export function ExtraContractActionsRow(props: { contract: Contract }) {
/>
<span>Share</span>
</Col>
<ShareModal
isOpen={isShareOpen}
setOpen={setShareOpen}
@ -50,17 +49,23 @@ export function ExtraContractActionsRow(props: { contract: Contract }) {
user={user}
/>
</Button>
{showChallenge && (
<Button
size="lg"
color="gray-white"
className={'flex hidden max-w-xs self-center sm:inline-block'}
className="max-w-xs self-center"
onClick={withTracking(
() => setOpenCreateChallengeModal(true),
'click challenge button'
)}
>
<span> Challenge</span>
<Col className="items-center sm:flex-row">
<span className="h-[24px] w-5 sm:mr-2" aria-hidden="true">
</span>
<span>Challenge</span>
</Col>
<CreateChallengeModal
isOpen={openCreateChallengeModal}
setOpen={setOpenCreateChallengeModal}

View File

@ -0,0 +1,76 @@
import clsx from 'clsx'
import { contractPath } from 'web/lib/firebase/contracts'
import { CPMMContract } from 'common/contract'
import { formatPercent } from 'common/util/format'
import { useProbChanges } from 'web/hooks/use-prob-changes'
import { SiteLink } from '../site-link'
import { Col } from '../layout/col'
import { Row } from '../layout/row'
export function ProbChangeTable(props: { userId: string | undefined }) {
const { userId } = props
const changes = useProbChanges(userId ?? '')
if (!changes) {
return null
}
const { positiveChanges, negativeChanges } = changes
const count = 3
return (
<Row className="w-full flex-wrap divide-x-2 rounded bg-white shadow-md">
<Col className="min-w-[300px] flex-1 divide-y">
{positiveChanges.slice(0, count).map((contract) => (
<Row className="hover:bg-gray-100">
<ProbChange className="p-4 text-right" contract={contract} />
<SiteLink
className="p-4 font-semibold text-indigo-700"
href={contractPath(contract)}
>
{contract.question}
</SiteLink>
</Row>
))}
</Col>
<Col className="justify-content-stretch min-w-[300px] flex-1 divide-y">
{negativeChanges.slice(0, count).map((contract) => (
<Row className="hover:bg-gray-100">
<ProbChange className="p-4 text-right" contract={contract} />
<SiteLink
className="p-4 font-semibold text-indigo-700"
href={contractPath(contract)}
>
{contract.question}
</SiteLink>
</Row>
))}
</Col>
</Row>
)
}
export function ProbChange(props: {
contract: CPMMContract
className?: string
}) {
const { contract, className } = props
const {
probChanges: { day: change },
} = contract
const color =
change > 0
? 'text-green-600'
: change < 0
? 'text-red-600'
: 'text-gray-600'
const str =
change === 0
? '+0%'
: `${change > 0 ? '+' : '-'}${formatPercent(Math.abs(change))}`
return <div className={clsx(className, color)}>{str}</div>
}

View File

@ -1,27 +1,13 @@
import React from 'react'
import Link from 'next/link'
import clsx from 'clsx'
import { User } from 'web/lib/firebase/users'
import { Button } from './button'
import { SiteLink } from 'web/components/site-link'
export const CreateQuestionButton = (props: {
user: User | null | undefined
overrideText?: string
className?: string
query?: string
}) => {
const { user, overrideText, className, query } = props
if (!user || user?.isBannedFromPosting) return <></>
export const CreateQuestionButton = () => {
return (
<div className={clsx('flex justify-center', className)}>
<Link href={`/create${query ? query : ''}`} passHref>
<Button color="gradient" size="xl" className="mt-4">
{overrideText ?? 'Create a market'}
</Button>
</Link>
</div>
<SiteLink href="/create">
<Button color="gradient" size="xl" className="mt-4 w-full">
Create a market
</Button>
</SiteLink>
)
}

View File

@ -254,7 +254,7 @@ export function RichContent(props: {
extensions: [
StarterKit,
smallImage ? DisplayImage : Image,
DisplayLink,
DisplayLink.configure({ openOnClick: false }), // stop link opening twice (browser still opens)
DisplayMention,
Iframe,
TiptapTweet,

View File

@ -123,15 +123,12 @@ export function FeedComment(props: {
} = props
const { text, content, userUsername, userName, userAvatarUrl, createdTime } =
comment
let betOutcome: string | undefined,
bought: string | undefined,
money: string | undefined
const matchedBet = betsBySameUser.find((bet) => bet.id === comment.betId)
if (matchedBet) {
betOutcome = matchedBet.outcome
bought = matchedBet.amount >= 0 ? 'bought' : 'sold'
money = formatMoney(Math.abs(matchedBet.amount))
const betOutcome = comment.betOutcome
let bought: string | undefined
let money: string | undefined
if (comment.betAmount != null) {
bought = comment.betAmount >= 0 ? 'bought' : 'sold'
money = formatMoney(Math.abs(comment.betAmount))
}
const [highlighted, setHighlighted] = useState(false)
@ -146,7 +143,7 @@ export function FeedComment(props: {
const { userPosition, outcome } = getBettorsLargestPositionBeforeTime(
contract,
comment.createdTime,
matchedBet ? [] : betsBySameUser
comment.betId ? [] : betsBySameUser
)
return (
@ -173,7 +170,7 @@ export function FeedComment(props: {
username={userUsername}
name={userName}
/>{' '}
{!matchedBet &&
{!comment.betId != null &&
userPosition > 0 &&
contract.outcomeType !== 'NUMERIC' && (
<>
@ -192,7 +189,6 @@ export function FeedComment(props: {
of{' '}
<OutcomeLabel
outcome={betOutcome ? betOutcome : ''}
value={(matchedBet as any).value}
contract={contract}
truncate="short"
/>

View File

@ -1,99 +0,0 @@
import { groupBy, mapValues, maxBy, sortBy } from 'lodash'
import { Contract } from 'web/lib/firebase/contracts'
import { ContractComment } from 'common/comment'
import { Bet } from 'common/bet'
const MAX_ACTIVE_CONTRACTS = 75
// This does NOT include comment times, since those aren't part of the contract atm.
// TODO: Maybe store last activity time directly in the contract?
// Pros: simplifies this code; cons: harder to tweak "activity" definition later
function lastActivityTime(contract: Contract) {
return Math.max(contract.resolutionTime || 0, contract.createdTime)
}
// Types of activity to surface:
// - Comment on a market
// - New market created
// - Market resolved
// - Bet on market
export function findActiveContracts(
allContracts: Contract[],
recentComments: ContractComment[],
recentBets: Bet[],
seenContracts: { [contractId: string]: number }
) {
const idToActivityTime = new Map<string, number>()
function record(contractId: string, time: number) {
// Only record if the time is newer
const oldTime = idToActivityTime.get(contractId)
idToActivityTime.set(contractId, Math.max(oldTime ?? 0, time))
}
const contractsById = new Map(allContracts.map((c) => [c.id, c]))
// Record contract activity.
for (const contract of allContracts) {
record(contract.id, lastActivityTime(contract))
}
// Add every contract that had a recent comment, too
for (const comment of recentComments) {
if (comment.contractId) {
const contract = contractsById.get(comment.contractId)
if (contract) record(contract.id, comment.createdTime)
}
}
// Add contracts by last bet time.
const contractBets = groupBy(recentBets, (bet) => bet.contractId)
const contractMostRecentBet = mapValues(
contractBets,
(bets) => maxBy(bets, (bet) => bet.createdTime) as Bet
)
for (const bet of Object.values(contractMostRecentBet)) {
const contract = contractsById.get(bet.contractId)
if (contract) record(contract.id, bet.createdTime)
}
let activeContracts = allContracts.filter(
(contract) =>
contract.visibility === 'public' &&
!contract.isResolved &&
(contract.closeTime ?? Infinity) > Date.now()
)
activeContracts = sortBy(
activeContracts,
(c) => -(idToActivityTime.get(c.id) ?? 0)
)
const contractComments = groupBy(
recentComments,
(comment) => comment.contractId
)
const contractMostRecentComment = mapValues(
contractComments,
(comments) => maxBy(comments, (c) => c.createdTime) as ContractComment
)
const prioritizedContracts = sortBy(activeContracts, (c) => {
const seenTime = seenContracts[c.id]
if (!seenTime) {
return 0
}
const lastCommentTime = contractMostRecentComment[c.id]?.createdTime
if (lastCommentTime && lastCommentTime > seenTime) {
return 1
}
const lastBetTime = contractMostRecentBet[c.id]?.createdTime
if (lastBetTime && lastBetTime > seenTime) {
return 2
}
return seenTime
})
return prioritizedContracts.slice(0, MAX_ACTIVE_CONTRACTS)
}

View File

@ -7,13 +7,13 @@ import { Button } from 'web/components/button'
import { GroupSelector } from 'web/components/groups/group-selector'
import {
addContractToGroup,
canModifyGroupContracts,
removeContractFromGroup,
} from 'web/lib/firebase/groups'
import { User } from 'common/user'
import { Contract } from 'common/contract'
import { SiteLink } from 'web/components/site-link'
import { useGroupsWithContract } from 'web/hooks/use-group'
import { useGroupsWithContract, useMemberGroupIds } from 'web/hooks/use-group'
import { Group } from 'common/group'
export function ContractGroupsList(props: {
contract: Contract
@ -22,6 +22,15 @@ export function ContractGroupsList(props: {
const { user, contract } = props
const { groupLinks } = contract
const groups = useGroupsWithContract(contract)
const memberGroupIds = useMemberGroupIds(user)
const canModifyGroupContracts = (group: Group, userId: string) => {
return (
group.creatorId === userId ||
group.anyoneCanJoin ||
memberGroupIds?.includes(group.id)
)
}
return (
<Col className={'gap-2'}>
<span className={'text-xl text-indigo-700'}>
@ -61,7 +70,7 @@ export function ContractGroupsList(props: {
<Button
color={'gray-white'}
size={'xs'}
onClick={() => removeContractFromGroup(group, contract, user.id)}
onClick={() => removeContractFromGroup(group, contract)}
>
<XIcon className="h-4 w-4 text-gray-500" />
</Button>

View File

@ -3,17 +3,16 @@ import clsx from 'clsx'
import { PencilIcon } from '@heroicons/react/outline'
import { Group } from 'common/group'
import { deleteGroup, updateGroup } from 'web/lib/firebase/groups'
import { deleteGroup, joinGroup } from 'web/lib/firebase/groups'
import { Spacer } from '../layout/spacer'
import { useRouter } from 'next/router'
import { Modal } from 'web/components/layout/modal'
import { FilterSelectUsers } from 'web/components/filter-select-users'
import { User } from 'common/user'
import { uniq } from 'lodash'
import { useMemberIds } from 'web/hooks/use-group'
export function EditGroupButton(props: { group: Group; className?: string }) {
const { group, className } = props
const { memberIds } = group
const router = useRouter()
const [name, setName] = useState(group.name)
@ -21,7 +20,7 @@ export function EditGroupButton(props: { group: Group; className?: string }) {
const [open, setOpen] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [addMemberUsers, setAddMemberUsers] = useState<User[]>([])
const memberIds = useMemberIds(group.id)
function updateOpen(newOpen: boolean) {
setAddMemberUsers([])
setOpen(newOpen)
@ -33,11 +32,7 @@ export function EditGroupButton(props: { group: Group; className?: string }) {
const onSubmit = async () => {
setIsSubmitting(true)
await updateGroup(group, {
name,
about,
memberIds: uniq([...memberIds, ...addMemberUsers.map((user) => user.id)]),
})
await Promise.all(addMemberUsers.map((user) => joinGroup(group, user.id)))
setIsSubmitting(false)
updateOpen(false)

View File

@ -1,391 +0,0 @@
import { Row } from 'web/components/layout/row'
import { Col } from 'web/components/layout/col'
import { PrivateUser, User } from 'common/user'
import React, { useEffect, memo, useState, useMemo } from 'react'
import { Avatar } from 'web/components/avatar'
import { Group } from 'common/group'
import { Comment, GroupComment } from 'common/comment'
import { createCommentOnGroup } from 'web/lib/firebase/comments'
import { track } from 'web/lib/service/analytics'
import { firebaseLogin } from 'web/lib/firebase/users'
import { useRouter } from 'next/router'
import clsx from 'clsx'
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns'
import { Tipper } from 'web/components/tipper'
import { sum } from 'lodash'
import { formatMoney } from 'common/util/format'
import { useWindowSize } from 'web/hooks/use-window-size'
import { Content, useTextEditor } from 'web/components/editor'
import { useUnseenNotifications } from 'web/hooks/use-notifications'
import { ChevronDownIcon, UsersIcon } from '@heroicons/react/outline'
import { setNotificationsAsSeen } from 'web/pages/notifications'
import { usePrivateUser } from 'web/hooks/use-user'
import { UserLink } from 'web/components/user-link'
import { CommentInputTextArea } from '../comment-input'
export function GroupChat(props: {
messages: GroupComment[]
user: User | null | undefined
group: Group
tips: CommentTipMap
}) {
const { messages, user, group, tips } = props
const privateUser = usePrivateUser()
const { editor, upload } = useTextEditor({
simple: true,
placeholder: 'Send a message',
})
const [isSubmitting, setIsSubmitting] = useState(false)
const [scrollToBottomRef, setScrollToBottomRef] =
useState<HTMLDivElement | null>(null)
const [scrollToMessageId, setScrollToMessageId] = useState('')
const [scrollToMessageRef, setScrollToMessageRef] =
useState<HTMLDivElement | null>(null)
const [replyToUser, setReplyToUser] = useState<any>()
const router = useRouter()
const isMember = user && group.memberIds.includes(user?.id)
const { width, height } = useWindowSize()
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null)
// Subtract bottom bar when it's showing (less than lg screen)
const bottomBarHeight = (width ?? 0) < 1024 ? 58 : 0
const remainingHeight =
(height ?? 0) - (containerRef?.offsetTop ?? 0) - bottomBarHeight
// array of groups, where each group is an array of messages that are displayed as one
const groupedMessages = useMemo(() => {
// Group messages with createdTime within 2 minutes of each other.
const tempGrouped: GroupComment[][] = []
for (let i = 0; i < messages.length; i++) {
const message = messages[i]
if (i === 0) tempGrouped.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) {
tempGrouped.at(-1)?.push(message)
} else {
tempGrouped.push([message])
}
}
}
return tempGrouped
}, [messages])
useEffect(() => {
scrollToMessageRef?.scrollIntoView()
}, [scrollToMessageRef])
useEffect(() => {
if (scrollToBottomRef)
scrollToBottomRef.scrollTo({ top: scrollToBottomRef.scrollHeight || 0 })
// Must also listen to groupedMessages as they update the height of the messaging window
}, [scrollToBottomRef, groupedMessages])
useEffect(() => {
const elementInUrl = router.asPath.split('#')[1]
if (messages.map((m) => m.id).includes(elementInUrl)) {
setScrollToMessageId(elementInUrl)
}
}, [messages, router.asPath])
useEffect(() => {
// is mobile?
if (width && width > 720) focusInput()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [width])
function onReplyClick(comment: Comment) {
setReplyToUser({ id: comment.userId, username: comment.userUsername })
}
async function submitMessage() {
if (!user) {
track('sign in to comment')
return await firebaseLogin()
}
if (!editor || editor.isEmpty || isSubmitting) return
setIsSubmitting(true)
await createCommentOnGroup(group.id, editor.getJSON(), user)
editor.commands.clearContent()
setIsSubmitting(false)
setReplyToUser(undefined)
focusInput()
}
function focusInput() {
editor?.commands.focus()
}
return (
<Col ref={setContainerRef} style={{ height: remainingHeight }}>
<Col
className={
'w-full flex-1 space-y-2 overflow-x-hidden overflow-y-scroll pt-2'
}
ref={setScrollToBottomRef}
>
{groupedMessages.map((messages) => (
<GroupMessage
user={user}
key={`group ${messages[0].id}`}
comments={messages}
group={group}
onReplyClick={onReplyClick}
highlight={messages[0].id === scrollToMessageId}
setRef={
scrollToMessageId === messages[0].id
? setScrollToMessageRef
: undefined
}
tips={tips[messages[0].id] ?? {}}
/>
))}
{messages.length === 0 && (
<div className="p-2 text-gray-500">
No messages yet. Why not{isMember ? ` ` : ' join and '}
<button
className={'cursor-pointer font-bold text-gray-700'}
onClick={focusInput}
>
add one?
</button>
</div>
)}
</Col>
{user && group.memberIds.includes(user.id) && (
<div className="flex w-full justify-start gap-2 p-2">
<div className="mt-1">
<Avatar
username={user?.username}
avatarUrl={user?.avatarUrl}
size={'sm'}
/>
</div>
<div className={'flex-1'}>
<CommentInputTextArea
editor={editor}
upload={upload}
user={user}
replyToUser={replyToUser}
submitComment={submitMessage}
isSubmitting={isSubmitting}
submitOnEnter
/>
</div>
</div>
)}
{privateUser && (
<GroupChatNotificationsIcon
group={group}
privateUser={privateUser}
shouldSetAsSeen={true}
hidden={true}
/>
)}
</Col>
)
}
export function GroupChatInBubble(props: {
messages: GroupComment[]
user: User | null | undefined
privateUser: PrivateUser | null | undefined
group: Group
tips: CommentTipMap
}) {
const { messages, user, group, tips, privateUser } = props
const [shouldShowChat, setShouldShowChat] = useState(false)
const router = useRouter()
useEffect(() => {
const groupsWithChatEmphasis = [
'welcome',
'bugs',
'manifold-features-25bad7c7792e',
'updates',
]
if (
router.asPath.includes('/chat') ||
groupsWithChatEmphasis.includes(
router.asPath.split('/group/')[1].split('/')[0]
)
) {
setShouldShowChat(true)
}
// Leave chat open between groups if user is using chat?
else {
setShouldShowChat(false)
}
}, [router.asPath])
return (
<Col
className={clsx(
'fixed right-0 bottom-[0px] h-1 w-full sm:bottom-[20px] sm:right-20 sm:w-2/3 md:w-1/2 lg:right-24 lg:w-1/3 xl:right-32 xl:w-1/4',
shouldShowChat ? 'p-2m z-10 h-screen bg-white' : ''
)}
>
{shouldShowChat && (
<GroupChat messages={messages} user={user} group={group} tips={tips} />
)}
<button
type="button"
className={clsx(
'fixed right-1 inline-flex items-center rounded-full border md:right-2 lg:right-5 xl:right-10' +
' border-transparent p-3 text-white shadow-sm lg:p-4' +
' focus:outline-none focus:ring-2 focus:ring-offset-2 ' +
' bottom-[70px] ',
shouldShowChat
? 'bottom-auto top-2 bg-gray-600 hover:bg-gray-400 focus:ring-gray-500 sm:bottom-[70px] sm:top-auto '
: ' bg-indigo-600 hover:bg-indigo-700 focus:ring-indigo-500'
)}
onClick={() => {
// router.push('/chat')
setShouldShowChat(!shouldShowChat)
track('mobile group chat button')
}}
>
{!shouldShowChat ? (
<UsersIcon className="h-10 w-10" aria-hidden="true" />
) : (
<ChevronDownIcon className={'h-10 w-10'} aria-hidden={'true'} />
)}
{privateUser && (
<GroupChatNotificationsIcon
group={group}
privateUser={privateUser}
shouldSetAsSeen={shouldShowChat}
hidden={false}
/>
)}
</button>
</Col>
)
}
function GroupChatNotificationsIcon(props: {
group: Group
privateUser: PrivateUser
shouldSetAsSeen: boolean
hidden: boolean
}) {
const { privateUser, group, shouldSetAsSeen, hidden } = props
const notificationsForThisGroup = useUnseenNotifications(
privateUser
// Disabled tracking by customHref for now.
// {
// customHref: `/group/${group.slug}`,
// }
)
useEffect(() => {
if (!notificationsForThisGroup) return
notificationsForThisGroup.forEach((notification) => {
if (
(shouldSetAsSeen && notification.isSeenOnHref?.includes('chat')) ||
// old style chat notif that simply ended with the group slug
notification.isSeenOnHref?.endsWith(group.slug)
) {
setNotificationsAsSeen([notification])
}
})
}, [group.slug, notificationsForThisGroup, shouldSetAsSeen])
return (
<div
className={
!hidden &&
notificationsForThisGroup &&
notificationsForThisGroup.length > 0 &&
!shouldSetAsSeen
? 'absolute right-4 top-4 h-3 w-3 rounded-full border-2 border-white bg-red-500'
: 'hidden'
}
></div>
)
}
const GroupMessage = memo(function GroupMessage_(props: {
user: User | null | undefined
comments: GroupComment[]
group: Group
onReplyClick?: (comment: Comment) => void
setRef?: (ref: HTMLDivElement) => void
highlight?: boolean
tips: CommentTips
}) {
const { comments, onReplyClick, group, setRef, highlight, user, tips } = props
const first = comments[0]
const { id, userUsername, userName, userAvatarUrl, createdTime } = first
const isCreatorsComment = user && first.userId === user.id
return (
<Col
ref={setRef}
className={clsx(
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` : ''
)}
>
<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={id}
/>
</Row>
<div className="mt-2 text-base text-black">
{comments.map((comment) => (
<Content
key={comment.id}
content={comment.content || comment.text}
smallImage
/>
))}
</div>
<Row>
{!isCreatorsComment && onReplyClick && (
<button
className={
'self-start py-1 text-xs font-bold text-gray-500 hover:underline'
}
onClick={() => onReplyClick(first)}
>
Reply
</button>
)}
{isCreatorsComment && sum(Object.values(tips)) > 0 && (
<span className={'text-primary'}>
{formatMoney(sum(Object.values(tips)))}
</span>
)}
{!isCreatorsComment && <Tipper comment={first} tips={tips} />}
</Row>
</Col>
)
})

View File

@ -5,6 +5,7 @@ import {
CheckIcon,
PlusCircleIcon,
SelectorIcon,
UserIcon,
} from '@heroicons/react/outline'
import clsx from 'clsx'
import { CreateGroupButton } from 'web/components/groups/create-group-button'
@ -12,6 +13,7 @@ import { useState } from 'react'
import { useMemberGroups, useOpenGroups } from 'web/hooks/use-group'
import { User } from 'common/user'
import { searchInAny } from 'common/util/parse'
import { Row } from 'web/components/layout/row'
export function GroupSelector(props: {
selectedGroup: Group | undefined
@ -28,13 +30,27 @@ export function GroupSelector(props: {
const { showSelector, showLabel, ignoreGroupIds } = options
const [query, setQuery] = useState('')
const openGroups = useOpenGroups()
const memberGroups = useMemberGroups(creator?.id)
const memberGroupIds = memberGroups?.map((g) => g.id) ?? []
const availableGroups = openGroups
.concat(
(useMemberGroups(creator?.id) ?? []).filter(
(memberGroups ?? []).filter(
(g) => !openGroups.map((og) => og.id).includes(g.id)
)
)
.filter((group) => !ignoreGroupIds?.includes(group.id))
.sort((a, b) => b.totalContracts - a.totalContracts)
// put the groups the user is a member of first
.sort((a, b) => {
if (memberGroupIds.includes(a.id)) {
return -1
}
if (memberGroupIds.includes(b.id)) {
return 1
}
return 0
})
const filteredGroups = availableGroups.filter((group) =>
searchInAny(query, group.name)
)
@ -96,7 +112,7 @@ export function GroupSelector(props: {
value={group}
className={({ active }) =>
clsx(
'relative h-12 cursor-pointer select-none py-2 pl-4 pr-9',
'relative h-12 cursor-pointer select-none py-2 pr-6',
active ? 'bg-indigo-500 text-white' : 'text-gray-900'
)
}
@ -115,11 +131,28 @@ export function GroupSelector(props: {
)}
<span
className={clsx(
'ml-5 mt-1 block truncate',
'ml-3 mt-1 block flex flex-row justify-between',
selected && 'font-semibold'
)}
>
{group.name}
<Row className={'items-center gap-1 truncate pl-5'}>
{memberGroupIds.includes(group.id) && (
<UserIcon
className={'text-primary h-4 w-4 shrink-0'}
/>
)}
{group.name}
</Row>
<span
className={clsx(
'ml-1 w-[1.4rem] shrink-0 rounded-full bg-indigo-500 text-center text-white',
group.totalContracts > 99 ? 'w-[2.1rem]' : ''
)}
>
{group.totalContracts > 99
? '99+'
: group.totalContracts}
</span>
</span>
</>
)}

View File

@ -1,10 +1,10 @@
import clsx from 'clsx'
import { User } from 'common/user'
import { useEffect, useState } from 'react'
import { useState } from 'react'
import { useUser } from 'web/hooks/use-user'
import { withTracking } from 'web/lib/service/analytics'
import { Row } from 'web/components/layout/row'
import { useMemberGroups } from 'web/hooks/use-group'
import { useMemberGroups, useMemberIds } from 'web/hooks/use-group'
import { TextButton } from 'web/components/text-button'
import { Group } from 'common/group'
import { Modal } from 'web/components/layout/modal'
@ -17,9 +17,7 @@ import toast from 'react-hot-toast'
export function GroupsButton(props: { user: User }) {
const { user } = props
const [isOpen, setIsOpen] = useState(false)
const groups = useMemberGroups(user.id, undefined, {
by: 'mostRecentChatActivityTime',
})
const groups = useMemberGroups(user.id)
return (
<>
@ -74,51 +72,34 @@ function GroupsList(props: { groups: Group[] }) {
function GroupItem(props: { group: Group; className?: string }) {
const { group, className } = props
const user = useUser()
const memberIds = useMemberIds(group.id)
return (
<Row className={clsx('items-center justify-between gap-2 p-2', className)}>
<Row className="line-clamp-1 items-center gap-2">
<GroupLinkItem group={group} />
</Row>
<JoinOrLeaveGroupButton group={group} />
<JoinOrLeaveGroupButton
group={group}
user={user}
isMember={user ? memberIds?.includes(user.id) : false}
/>
</Row>
)
}
export function JoinOrLeaveGroupButton(props: {
group: Group
isMember: boolean
user: User | undefined | null
small?: boolean
className?: string
}) {
const { group, small, className } = props
const currentUser = useUser()
const [isMember, setIsMember] = useState<boolean>(false)
useEffect(() => {
if (currentUser && group.memberIds.includes(currentUser.id)) {
setIsMember(group.memberIds.includes(currentUser.id))
}
}, [currentUser, group])
const onJoinGroup = () => {
if (!currentUser) return
setIsMember(true)
joinGroup(group, currentUser.id).catch(() => {
setIsMember(false)
toast.error('Failed to join group')
})
}
const onLeaveGroup = () => {
if (!currentUser) return
setIsMember(false)
leaveGroup(group, currentUser.id).catch(() => {
setIsMember(true)
toast.error('Failed to leave group')
})
}
const { group, small, className, isMember, user } = props
const smallStyle =
'btn !btn-xs border-2 border-gray-500 bg-white normal-case text-gray-500 hover:border-gray-500 hover:bg-white hover:text-gray-500'
if (!currentUser || isMember === undefined) {
if (!user) {
if (!group.anyoneCanJoin)
return <div className={clsx(className, 'text-gray-500')}>Closed</div>
return (
@ -126,10 +107,20 @@ export function JoinOrLeaveGroupButton(props: {
onClick={firebaseLogin}
className={clsx('btn btn-sm', small && smallStyle, className)}
>
Login to Join
Login to follow
</button>
)
}
const onJoinGroup = () => {
joinGroup(group, user.id).catch(() => {
toast.error('Failed to join group')
})
}
const onLeaveGroup = () => {
leaveGroup(group, user.id).catch(() => {
toast.error('Failed to leave group')
})
}
if (isMember) {
return (
@ -141,7 +132,7 @@ export function JoinOrLeaveGroupButton(props: {
)}
onClick={withTracking(onLeaveGroup, 'leave group')}
>
Leave
Unfollow
</button>
)
}
@ -153,7 +144,7 @@ export function JoinOrLeaveGroupButton(props: {
className={clsx('btn btn-sm', small && smallStyle, className)}
onClick={withTracking(onJoinGroup, 'join group')}
>
Join
Follow
</button>
)
}

View File

@ -32,20 +32,21 @@ export function MultiUserTransactionLink(props: {
setOpen(true)
}}
>
<Row className={'gap-1'}>
{userInfos.map((userInfo, index) =>
index < maxShowCount ? (
<Row key={userInfo.username + 'shortened'}>
<Row className={'items-center gap-1 sm:gap-2'}>
{userInfos.map(
(userInfo, index) =>
index < maxShowCount && (
<Avatar
username={userInfo.username}
size={'sm'}
avatarUrl={userInfo.avatarUrl}
noLink={userInfos.length > 1}
key={userInfo.username + 'avatar'}
/>
</Row>
) : (
<span>& {userInfos.length - maxShowCount} more</span>
)
)
)}
{userInfos.length > maxShowCount && (
<span>& {userInfos.length - maxShowCount} more</span>
)}
</Row>
</Button>

View File

@ -19,12 +19,10 @@ export function MenuButton(props: {
as="div"
className={clsx(className ? className : 'relative z-40 flex-shrink-0')}
>
<div>
<Menu.Button className="w-full rounded-full">
<span className="sr-only">Open user menu</span>
{buttonContent}
</Menu.Button>
</div>
<Menu.Button className="w-full rounded-full">
<span className="sr-only">Open user menu</span>
{buttonContent}
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-100"

View File

@ -11,7 +11,7 @@ export function ProfileSummary(props: { user: User }) {
<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"
className="group mb-3 flex flex-row items-center gap-4 truncate rounded-md py-3 text-gray-500 hover:bg-gray-100 hover:text-gray-700"
>
<Avatar avatarUrl={user.avatarUrl} username={user.username} noLink />

View File

@ -234,11 +234,7 @@ export default function Sidebar(props: { className?: string }) {
{!user && <SignInButton className="mb-4" />}
{user && (
<div className="min-h-[80px] w-full">
<ProfileSummary user={user} />
</div>
)}
{user && <ProfileSummary user={user} />}
{/* Mobile navigation */}
<div className="flex min-h-0 shrink flex-col gap-1 lg:hidden">
@ -255,7 +251,7 @@ export default function Sidebar(props: { className?: string }) {
</div>
{/* Desktop navigation */}
<div className="hidden min-h-0 shrink flex-col gap-1 lg:flex">
<div className="hidden min-h-0 shrink flex-col items-stretch gap-1 lg:flex ">
{navigationOptions.map((item) => (
<SidebarItem key={item.href} item={item} currentPage={currentPage} />
))}
@ -264,7 +260,7 @@ export default function Sidebar(props: { className?: string }) {
buttonContent={<MoreButton />}
/>
{user && <CreateQuestionButton user={user} />}
{user && !user.isBannedFromPosting && <CreateQuestionButton />}
</div>
</nav>
)

View File

@ -12,11 +12,9 @@ export default function NotificationsIcon(props: { className?: string }) {
const privateUser = usePrivateUser()
return (
<Row className={clsx('justify-center')}>
<div className={'relative'}>
{privateUser && <UnseenNotificationsBubble privateUser={privateUser} />}
<BellIcon className={clsx(props.className)} />
</div>
<Row className="relative justify-center">
{privateUser && <UnseenNotificationsBubble privateUser={privateUser} />}
<BellIcon className={clsx(props.className)} />
</Row>
)
}
@ -32,11 +30,11 @@ function UnseenNotificationsBubble(props: { privateUser: PrivateUser }) {
const notifications = useUnseenGroupedNotification(privateUser)
if (!notifications || notifications.length === 0 || seen) {
return <div />
return null
}
return (
<div className="-mt-0.75 absolute ml-3.5 min-w-[15px] rounded-full bg-indigo-500 p-[2px] text-center text-[10px] leading-3 text-white lg:-mt-1 lg:ml-2">
<div className="-mt-0.75 absolute ml-3.5 min-w-[15px] rounded-full bg-indigo-500 p-[2px] text-center text-[10px] leading-3 text-white lg:left-0 lg:-mt-1 lg:ml-2">
{notifications.length > NOTIFICATIONS_PER_PAGE
? `${NOTIFICATIONS_PER_PAGE}+`
: notifications.length}

View File

@ -58,7 +58,7 @@ export function Pagination(props: {
const maxPage = Math.ceil(totalItems / itemsPerPage) - 1
if (maxPage === 0) return <Spacer h={4} />
if (maxPage <= 0) return <Spacer h={4} />
return (
<nav

View File

@ -8,16 +8,21 @@ import { formatTime } from 'web/lib/util/time'
export const PortfolioValueGraph = memo(function PortfolioValueGraph(props: {
portfolioHistory: PortfolioMetrics[]
mode: 'value' | 'profit'
height?: number
includeTime?: boolean
}) {
const { portfolioHistory, height, includeTime } = props
const { portfolioHistory, height, includeTime, mode } = props
const { width } = useWindowSize()
const points = portfolioHistory.map((p) => {
const { timestamp, balance, investmentValue, totalDeposits } = p
const value = balance + investmentValue
const profit = value - totalDeposits
return {
x: new Date(p.timestamp),
y: p.balance + p.investmentValue,
x: new Date(timestamp),
y: mode === 'value' ? value : profit,
}
})
const data = [{ id: 'Value', data: points, color: '#11b981' }]

View File

@ -5,6 +5,7 @@ import { usePortfolioHistory } from 'web/hooks/use-portfolio-history'
import { Period } from 'web/lib/firebase/users'
import { Col } from '../layout/col'
import { Row } from '../layout/row'
import { Spacer } from '../layout/spacer'
import { PortfolioValueGraph } from './portfolio-value-graph'
export const PortfolioValueSection = memo(
@ -24,15 +25,16 @@ export const PortfolioValueSection = memo(
return <></>
}
const { balance, investmentValue } = lastPortfolioMetrics
const { balance, investmentValue, totalDeposits } = lastPortfolioMetrics
const totalValue = balance + investmentValue
const totalProfit = totalValue - totalDeposits
return (
<>
<Row className="gap-8">
<Col className="flex-1 justify-center">
<div className="text-sm text-gray-500">Portfolio value</div>
<div className="text-lg">{formatMoney(totalValue)}</div>
<div className="text-sm text-gray-500">Profit</div>
<div className="text-lg">{formatMoney(totalProfit)}</div>
</Col>
<select
className="select select-bordered self-start"
@ -49,6 +51,17 @@ export const PortfolioValueSection = memo(
<PortfolioValueGraph
portfolioHistory={currPortfolioHistory}
includeTime={portfolioPeriod == 'daily'}
mode="profit"
/>
<Spacer h={8} />
<Col className="flex-1 justify-center">
<div className="text-sm text-gray-500">Portfolio value</div>
<div className="text-lg">{formatMoney(totalValue)}</div>
</Col>
<PortfolioValueGraph
portfolioHistory={currPortfolioHistory}
includeTime={portfolioPeriod == 'daily'}
mode="value"
/>
</>
)

View File

@ -168,62 +168,63 @@ export function UserPage(props: { user: User }) {
<Spacer h={4} />
</>
)}
<Row className="flex-wrap items-center gap-2 sm:gap-4">
{user.website && (
<SiteLink
href={
'https://' +
user.website.replace('http://', '').replace('https://', '')
}
>
<Row className="items-center gap-1">
<LinkIcon className="h-4 w-4" />
<span className="text-sm text-gray-500">{user.website}</span>
</Row>
</SiteLink>
)}
{(user.website || user.twitterHandle || user.discordHandle) && (
<Row className="mb-5 flex-wrap items-center gap-2 sm:gap-4">
{user.website && (
<SiteLink
href={
'https://' +
user.website.replace('http://', '').replace('https://', '')
}
>
<Row className="items-center gap-1">
<LinkIcon className="h-4 w-4" />
<span className="text-sm text-gray-500">{user.website}</span>
</Row>
</SiteLink>
)}
{user.twitterHandle && (
<SiteLink
href={`https://twitter.com/${user.twitterHandle
.replace('https://www.twitter.com/', '')
.replace('https://twitter.com/', '')
.replace('www.twitter.com/', '')
.replace('twitter.com/', '')}`}
>
<Row className="items-center gap-1">
<img
src="/twitter-logo.svg"
className="h-4 w-4"
alt="Twitter"
/>
<span className="text-sm text-gray-500">
{user.twitterHandle}
</span>
</Row>
</SiteLink>
)}
{user.twitterHandle && (
<SiteLink
href={`https://twitter.com/${user.twitterHandle
.replace('https://www.twitter.com/', '')
.replace('https://twitter.com/', '')
.replace('www.twitter.com/', '')
.replace('twitter.com/', '')}`}
>
<Row className="items-center gap-1">
<img
src="/twitter-logo.svg"
className="h-4 w-4"
alt="Twitter"
/>
<span className="text-sm text-gray-500">
{user.twitterHandle}
</span>
</Row>
</SiteLink>
)}
{user.discordHandle && (
<SiteLink href="https://discord.com/invite/eHQBNBqXuh">
<Row className="items-center gap-1">
<img
src="/discord-logo.svg"
className="h-4 w-4"
alt="Discord"
/>
<span className="text-sm text-gray-500">
{user.discordHandle}
</span>
</Row>
</SiteLink>
)}
</Row>
<Spacer h={5} />
{user.discordHandle && (
<SiteLink href="https://discord.com/invite/eHQBNBqXuh">
<Row className="items-center gap-1">
<img
src="/discord-logo.svg"
className="h-4 w-4"
alt="Discord"
/>
<span className="text-sm text-gray-500">
{user.discordHandle}
</span>
</Row>
</SiteLink>
)}
</Row>
)}
{currentUser?.id === user.id && REFERRAL_AMOUNT > 0 && (
<Row
className={
'w-full items-center justify-center gap-2 rounded-md border-2 border-indigo-100 bg-indigo-50 p-2 text-indigo-600'
'mb-5 w-full items-center justify-center gap-2 rounded-md border-2 border-indigo-100 bg-indigo-50 p-2 text-indigo-600'
}
>
<span>
@ -240,7 +241,6 @@ export function UserPage(props: { user: User }) {
/>
</Row>
)}
<Spacer h={5} />
<QueryUncontrolledTabs
currentPageForAnalytics={'profile'}
labelClassName={'pb-2 pt-1 '}
@ -255,13 +255,6 @@ export function UserPage(props: { user: User }) {
title: 'Comments',
content: (
<Col>
<Row className={'mt-2 mb-4 flex-wrap items-center gap-6'}>
<FollowingButton user={user} />
<FollowersButton user={user} />
<ReferralsButton user={user} />
<GroupsButton user={user} />
<UserLikesButton user={user} />
</Row>
<UserCommentsList user={user} />
</Col>
),
@ -270,11 +263,25 @@ export function UserPage(props: { user: User }) {
title: 'Bets',
content: (
<>
<PortfolioValueSection userId={user.id} />
<BetsList user={user} />
</>
),
},
{
title: 'Stats',
content: (
<Col className="mb-8">
<Row className={'mb-8 flex-wrap items-center gap-6'}>
<FollowingButton user={user} />
<FollowersButton user={user} />
<ReferralsButton user={user} />
<GroupsButton user={user} />
<UserLikesButton user={user} />
</Row>
<PortfolioValueSection userId={user.id} />
</Col>
),
},
]}
/>
</Col>

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useEvent } from '../hooks/use-event'
export function VisibilityObserver(props: {
@ -8,17 +8,18 @@ export function VisibilityObserver(props: {
const { className } = props
const [elem, setElem] = useState<HTMLElement | null>(null)
const onVisibilityUpdated = useEvent(props.onVisibilityUpdated)
useEffect(() => {
const hasIOSupport = !!window.IntersectionObserver
if (!hasIOSupport || !elem) return
const observer = new IntersectionObserver(([entry]) => {
const observer = useRef(
new IntersectionObserver(([entry]) => {
onVisibilityUpdated(entry.isIntersecting)
}, {})
observer.observe(elem)
return () => observer.disconnect()
}, [elem, onVisibilityUpdated])
).current
useEffect(() => {
if (elem) {
observer.observe(elem)
return () => observer.unobserve(elem)
}
}, [elem, observer])
return <div ref={setElem} className={className}></div>
}

View File

@ -2,16 +2,21 @@ import { useEffect, useState } from 'react'
import { Group } from 'common/group'
import { User } from 'common/user'
import {
GroupMemberDoc,
groupMembers,
listenForGroup,
listenForGroupContractDocs,
listenForGroups,
listenForMemberGroupIds,
listenForMemberGroups,
listenForOpenGroups,
listGroups,
} from 'web/lib/firebase/groups'
import { getUser, getUsers } from 'web/lib/firebase/users'
import { getUser } from 'web/lib/firebase/users'
import { filterDefined } from 'common/util/array'
import { Contract } from 'common/contract'
import { uniq } from 'lodash'
import { listenForValues } from 'web/lib/firebase/utils'
export const useGroup = (groupId: string | undefined) => {
const [group, setGroup] = useState<Group | null | undefined>()
@ -43,29 +48,12 @@ export const useOpenGroups = () => {
return groups
}
export const useMemberGroups = (
userId: string | null | undefined,
options?: { withChatEnabled: boolean },
sort?: { by: 'mostRecentChatActivityTime' | 'mostRecentContractAddedTime' }
) => {
export const useMemberGroups = (userId: string | null | undefined) => {
const [memberGroups, setMemberGroups] = useState<Group[] | undefined>()
useEffect(() => {
if (userId)
return listenForMemberGroups(
userId,
(groups) => {
if (options?.withChatEnabled)
return setMemberGroups(
filterDefined(
groups.filter((group) => group.chatDisabled !== true)
)
)
return setMemberGroups(groups)
},
sort
)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [options?.withChatEnabled, sort?.by, userId])
return listenForMemberGroups(userId, (groups) => setMemberGroups(groups))
}, [userId])
return memberGroups
}
@ -77,16 +65,8 @@ export const useMemberGroupIds = (user: User | null | undefined) => {
useEffect(() => {
if (user) {
const key = `member-groups-${user.id}`
const memberGroupJson = localStorage.getItem(key)
if (memberGroupJson) {
setMemberGroupIds(JSON.parse(memberGroupJson))
}
return listenForMemberGroups(user.id, (Groups) => {
const groupIds = Groups.map((group) => group.id)
return listenForMemberGroupIds(user.id, (groupIds) => {
setMemberGroupIds(groupIds)
localStorage.setItem(key, JSON.stringify(groupIds))
})
}
}, [user])
@ -94,26 +74,29 @@ export const useMemberGroupIds = (user: User | null | undefined) => {
return memberGroupIds
}
export function useMembers(group: Group, max?: number) {
export function useMembers(groupId: string | undefined) {
const [members, setMembers] = useState<User[]>([])
useEffect(() => {
const { memberIds } = group
if (memberIds.length > 0) {
listMembers(group, max).then((members) => setMembers(members))
}
}, [group, max])
if (groupId)
listenForValues<GroupMemberDoc>(groupMembers(groupId), (memDocs) => {
const memberIds = memDocs.map((memDoc) => memDoc.userId)
Promise.all(memberIds.map((id) => getUser(id))).then((users) => {
setMembers(users)
})
})
}, [groupId])
return members
}
export async function listMembers(group: Group, max?: number) {
const { memberIds } = group
const numToRetrieve = max ?? memberIds.length
if (memberIds.length === 0) return []
if (numToRetrieve > 100)
return (await getUsers()).filter((user) =>
group.memberIds.includes(user.id)
)
return await Promise.all(group.memberIds.slice(0, numToRetrieve).map(getUser))
export function useMemberIds(groupId: string | null) {
const [memberIds, setMemberIds] = useState<string[]>([])
useEffect(() => {
if (groupId)
return listenForValues<GroupMemberDoc>(groupMembers(groupId), (docs) => {
setMemberIds(docs.map((doc) => doc.userId))
})
}, [groupId])
return memberIds
}
export const useGroupsWithContract = (contract: Contract) => {
@ -128,3 +111,16 @@ export const useGroupsWithContract = (contract: Contract) => {
return groups
}
export function useGroupContractIds(groupId: string) {
const [contractIds, setContractIds] = useState<string[]>([])
useEffect(() => {
if (groupId)
return listenForGroupContractDocs(groupId, (docs) =>
setContractIds(docs.map((doc) => doc.contractId))
)
}, [groupId])
return contractIds
}

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
function inIframe() {
export function inIframe() {
try {
return window.self !== window.top
} catch (e) {

View File

@ -16,11 +16,7 @@ export type NotificationGroup = {
function useNotifications(privateUser: PrivateUser) {
const result = useFirestoreQueryData(
['notifications-all', privateUser.id],
getNotificationsQuery(privateUser.id),
{ subscribe: true, includeMetadataChanges: true },
// Temporary workaround for react-query bug:
// https://github.com/invertase/react-query-firebase/issues/25
{ refetchOnMount: 'always' }
getNotificationsQuery(privateUser.id)
)
const notifications = useMemo(() => {

View File

@ -103,6 +103,7 @@ export const usePagination = <T>(opts: PaginationOptions<T>) => {
isEnd: state.isComplete && state.pageEnd >= state.docs.length,
getPrev: () => dispatch({ type: 'PREV' }),
getNext: () => dispatch({ type: 'NEXT' }),
allItems: () => state.docs.map((d) => d.data()),
getItems: () =>
state.docs.slice(state.pageStart, state.pageEnd).map((d) => d.data()),
}

View File

@ -0,0 +1,22 @@
import { useFirestoreQueryData } from '@react-query-firebase/firestore'
import {
getProbChangesNegative,
getProbChangesPositive,
} from 'web/lib/firebase/contracts'
export const useProbChanges = (userId: string) => {
const { data: positiveChanges } = useFirestoreQueryData(
['prob-changes-day-positive', userId],
getProbChangesPositive(userId)
)
const { data: negativeChanges } = useFirestoreQueryData(
['prob-changes-day-negative', userId],
getProbChangesNegative(userId)
)
if (!positiveChanges || !negativeChanges) {
return undefined
}
return { positiveChanges, negativeChanges }
}

View File

@ -1,13 +0,0 @@
import { useCallback, useEffect, useRef } from 'react'
export function useTimeSinceFirstRender() {
const startTimeRef = useRef(0)
useEffect(() => {
startTimeRef.current = Date.now()
}, [])
return useCallback(() => {
if (!startTimeRef.current) return 0
return Date.now() - startTimeRef.current
}, [])
}

View File

@ -1,8 +1,14 @@
import { track } from '@amplitude/analytics-browser'
import { useEffect } from 'react'
import { inIframe } from './use-is-iframe'
export const useTracking = (eventName: string, eventProperties?: any) => {
export const useTracking = (
eventName: string,
eventProperties?: any,
excludeIframe?: boolean
) => {
useEffect(() => {
if (excludeIframe && inIframe()) return
track(eventName, eventProperties)
}, [])
}

View File

@ -16,7 +16,7 @@ import {
import { partition, sortBy, sum, uniqBy } from 'lodash'
import { coll, getValues, listenForValue, listenForValues } from './utils'
import { BinaryContract, Contract } from 'common/contract'
import { BinaryContract, Contract, CPMMContract } from 'common/contract'
import { createRNG, shuffle } from 'common/util/random'
import { formatMoney, formatPercent } from 'common/util/format'
import { DAY_MS } from 'common/util/time'
@ -104,6 +104,14 @@ export async function listContracts(creatorId: string): Promise<Contract[]> {
return snapshot.docs.map((doc) => doc.data())
}
export const tournamentContractsByGroupSlugQuery = (slug: string) =>
query(
contracts,
where('groupSlugs', 'array-contains', slug),
where('isResolved', '==', false),
orderBy('popularityScore', 'desc')
)
export async function listContractsByGroupSlug(
slug: string
): Promise<Contract[]> {
@ -395,3 +403,21 @@ export async function getRecentBetsAndComments(contract: Contract) {
recentComments,
}
}
export const getProbChangesPositive = (userId: string) =>
query(
contracts,
where('uniqueBettorIds', 'array-contains', userId),
where('probChanges.day', '>', 0),
orderBy('probChanges.day', 'desc'),
limit(10)
) as Query<CPMMContract>
export const getProbChangesNegative = (userId: string) =>
query(
contracts,
where('uniqueBettorIds', 'array-contains', userId),
where('probChanges.day', '<', 0),
orderBy('probChanges.day', 'asc'),
limit(10)
) as Query<CPMMContract>

View File

@ -1,13 +1,17 @@
import {
collection,
collectionGroup,
deleteDoc,
deleteField,
doc,
getDocs,
onSnapshot,
query,
setDoc,
updateDoc,
where,
} from 'firebase/firestore'
import { sortBy, uniq } from 'lodash'
import { uniq, uniqBy } from 'lodash'
import { Group, GROUP_CHAT_SLUG, GroupLink } from 'common/group'
import {
coll,
@ -17,9 +21,19 @@ import {
listenForValues,
} from './utils'
import { Contract } from 'common/contract'
import { updateContract } from 'web/lib/firebase/contracts'
import { getContractFromId, updateContract } from 'web/lib/firebase/contracts'
import { db } from 'web/lib/firebase/init'
import { filterDefined } from 'common/util/array'
import { getUser } from 'web/lib/firebase/users'
export const groups = coll<Group>('groups')
export const groupMembers = (groupId: string) =>
collection(groups, groupId, 'groupMembers')
export const groupContracts = (groupId: string) =>
collection(groups, groupId, 'groupContracts')
const openGroupsQuery = query(groups, where('anyoneCanJoin', '==', true))
const memberGroupsQuery = (userId: string) =>
query(collectionGroup(db, 'groupMembers'), where('userId', '==', userId))
export function groupPath(
groupSlug: string,
@ -33,6 +47,9 @@ export function groupPath(
return `/group/${groupSlug}${subpath ? `/${subpath}` : ''}`
}
export type GroupContractDoc = { contractId: string; createdTime: number }
export type GroupMemberDoc = { userId: string; createdTime: number }
export function updateGroup(group: Group, updates: Partial<Group>) {
return updateDoc(doc(groups, group.id), updates)
}
@ -57,11 +74,26 @@ export function listenForGroups(setGroups: (groups: Group[]) => void) {
return listenForValues(groups, setGroups)
}
export function listenForOpenGroups(setGroups: (groups: Group[]) => void) {
return listenForValues(
query(groups, where('anyoneCanJoin', '==', true)),
setGroups
export function listenForGroupContractDocs(
groupId: string,
setContractDocs: (docs: GroupContractDoc[]) => void
) {
return listenForValues(groupContracts(groupId), setContractDocs)
}
export async function listGroupContracts(groupId: string) {
const contractDocs = await getValues<{
contractId: string
createdTime: number
}>(groupContracts(groupId))
const contracts = await Promise.all(
contractDocs.map((doc) => getContractFromId(doc.contractId))
)
return filterDefined(contracts)
}
export function listenForOpenGroups(setGroups: (groups: Group[]) => void) {
return listenForValues(openGroupsQuery, setGroups)
}
export function getGroup(groupId: string) {
@ -81,33 +113,47 @@ export function listenForGroup(
return listenForValue(doc(groups, groupId), setGroup)
}
export function listenForMemberGroups(
export function listenForMemberGroupIds(
userId: string,
setGroups: (groups: Group[]) => void,
sort?: { by: 'mostRecentChatActivityTime' | 'mostRecentContractAddedTime' }
setGroupIds: (groupIds: string[]) => void
) {
const q = query(groups, where('memberIds', 'array-contains', userId))
const sorter = (group: Group) => {
if (sort?.by === 'mostRecentChatActivityTime') {
return group.mostRecentChatActivityTime ?? group.createdTime
}
if (sort?.by === 'mostRecentContractAddedTime') {
return group.mostRecentContractAddedTime ?? group.createdTime
}
return group.mostRecentActivityTime
}
return listenForValues<Group>(q, (groups) => {
const sorted = sortBy(groups, [(group) => -sorter(group)])
setGroups(sorted)
const q = memberGroupsQuery(userId)
return onSnapshot(q, { includeMetadataChanges: true }, (snapshot) => {
if (snapshot.metadata.fromCache) return
const values = snapshot.docs.map((doc) => doc.ref.parent.parent?.id)
setGroupIds(filterDefined(values))
})
}
export async function listenForGroupsWithContractId(
contractId: string,
export function listenForMemberGroups(
userId: string,
setGroups: (groups: Group[]) => void
) {
const q = query(groups, where('contractIds', 'array-contains', contractId))
return listenForValues<Group>(q, setGroups)
return listenForMemberGroupIds(userId, (groupIds) => {
return Promise.all(groupIds.map(getGroup)).then((groups) => {
setGroups(filterDefined(groups))
})
})
}
export async function listAvailableGroups(userId: string) {
const [openGroups, memberGroupSnapshot] = await Promise.all([
getValues<Group>(openGroupsQuery),
getDocs(memberGroupsQuery(userId)),
])
const memberGroups = filterDefined(
await Promise.all(
memberGroupSnapshot.docs.map((doc) => {
return doc.ref.parent.parent?.id
? getGroup(doc.ref.parent.parent?.id)
: null
})
)
)
return uniqBy([...openGroups, ...memberGroups], (g) => g.id)
}
export async function addUserToGroupViaId(groupId: string, userId: string) {
@ -121,19 +167,18 @@ export async function addUserToGroupViaId(groupId: string, userId: string) {
}
export async function joinGroup(group: Group, userId: string): Promise<void> {
const { memberIds } = group
if (memberIds.includes(userId)) return // already a member
const newMemberIds = [...memberIds, userId]
return await updateGroup(group, { memberIds: uniq(newMemberIds) })
// create a new member document in grouoMembers collection
const memberDoc = doc(groupMembers(group.id), userId)
return await setDoc(memberDoc, {
userId,
createdTime: Date.now(),
})
}
export async function leaveGroup(group: Group, userId: string): Promise<void> {
const { memberIds } = group
if (!memberIds.includes(userId)) return // not a member
const newMemberIds = memberIds.filter((id) => id !== userId)
return await updateGroup(group, { memberIds: uniq(newMemberIds) })
// delete the member document in groupMembers collection
const memberDoc = doc(groupMembers(group.id), userId)
return await deleteDoc(memberDoc)
}
export async function addContractToGroup(
@ -141,7 +186,6 @@ export async function addContractToGroup(
contract: Contract,
userId: string
) {
if (!canModifyGroupContracts(group, userId)) return
const newGroupLinks = [
...(contract.groupLinks ?? []),
{
@ -158,25 +202,18 @@ export async function addContractToGroup(
groupLinks: newGroupLinks,
})
if (!group.contractIds.includes(contract.id)) {
return await updateGroup(group, {
contractIds: uniq([...group.contractIds, contract.id]),
})
.then(() => group)
.catch((err) => {
console.error('error adding contract to group', err)
return err
})
}
// create new contract document in groupContracts collection
const contractDoc = doc(groupContracts(group.id), contract.id)
await setDoc(contractDoc, {
contractId: contract.id,
createdTime: Date.now(),
})
}
export async function removeContractFromGroup(
group: Group,
contract: Contract,
userId: string
contract: Contract
) {
if (!canModifyGroupContracts(group, userId)) return
if (contract.groupLinks?.map((l) => l.groupId).includes(group.id)) {
const newGroupLinks = contract.groupLinks?.filter(
(link) => link.slug !== group.slug
@ -188,25 +225,9 @@ export async function removeContractFromGroup(
})
}
if (group.contractIds.includes(contract.id)) {
const newContractIds = group.contractIds.filter((id) => id !== contract.id)
return await updateGroup(group, {
contractIds: uniq(newContractIds),
})
.then(() => group)
.catch((err) => {
console.error('error removing contract from group', err)
return err
})
}
}
export function canModifyGroupContracts(group: Group, userId: string) {
return (
group.creatorId === userId ||
group.memberIds.includes(userId) ||
group.anyoneCanJoin
)
// delete the contract document in groupContracts collection
const contractDoc = doc(groupContracts(group.id), contract.id)
await deleteDoc(contractDoc)
}
export function getGroupLinkToDisplay(contract: Contract) {
@ -222,3 +243,8 @@ export function getGroupLinkToDisplay(contract: Contract) {
: sortedGroupLinks?.[0] ?? null
return groupToDisplay
}
export async function listMembers(group: Group) {
const members = await getValues<GroupMemberDoc>(groupMembers(group.id))
return await Promise.all(members.map((m) => m.userId).map(getUser))
}

View File

@ -1,43 +0,0 @@
import { doc, collection, setDoc } from 'firebase/firestore'
import { db } from './init'
import { ClickEvent, LatencyEvent, View } from 'common/tracking'
export async function trackView(userId: string, contractId: string) {
const ref = doc(collection(db, 'private-users', userId, 'views'))
const view: View = {
contractId,
timestamp: Date.now(),
}
return await setDoc(ref, view)
}
export async function trackClick(userId: string, contractId: string) {
const ref = doc(collection(db, 'private-users', userId, 'events'))
const clickEvent: ClickEvent = {
type: 'click',
contractId,
timestamp: Date.now(),
}
return await setDoc(ref, clickEvent)
}
export async function trackLatency(
userId: string,
type: 'feed' | 'portfolio',
latency: number
) {
const ref = doc(collection(db, 'private-users', userId, 'latency'))
const latencyEvent: LatencyEvent = {
type,
latency,
timestamp: Date.now(),
}
return await setDoc(ref, latencyEvent)
}

View File

@ -1,73 +0,0 @@
type Bid = { yesBid: number; noBid: number }
// An entry has a yes/no for bid, weight, payout, return. Also a current probability
export type Entry = {
yesBid: number
noBid: number
yesWeight: number
noWeight: number
yesPayout: number
noPayout: number
yesReturn: number
noReturn: number
prob: number
}
function makeWeights(bids: Bid[]) {
const weights = []
let yesPot = 0
let noPot = 0
// First pass: calculate all the weights
for (const { yesBid, noBid } of bids) {
const yesWeight =
yesBid +
(yesBid * Math.pow(noPot, 2)) /
(Math.pow(yesPot, 2) + yesBid * yesPot) || 0
const noWeight =
noBid +
(noBid * Math.pow(yesPot, 2)) / (Math.pow(noPot, 2) + noBid * noPot) ||
0
// Note: Need to calculate weights BEFORE updating pot
yesPot += yesBid
noPot += noBid
const prob =
Math.pow(yesPot, 2) / (Math.pow(yesPot, 2) + Math.pow(noPot, 2))
weights.push({
yesBid,
noBid,
yesWeight,
noWeight,
prob,
})
}
return weights
}
export function makeEntries(bids: Bid[]): Entry[] {
const YES_SEED = bids[0].yesBid
const NO_SEED = bids[0].noBid
const weights = makeWeights(bids)
const yesPot = weights.reduce((sum, { yesBid }) => sum + yesBid, 0)
const noPot = weights.reduce((sum, { noBid }) => sum + noBid, 0)
const yesWeightsSum = weights.reduce((sum, entry) => sum + entry.yesWeight, 0)
const noWeightsSum = weights.reduce((sum, entry) => sum + entry.noWeight, 0)
const potSize = yesPot + noPot - YES_SEED - NO_SEED
// Second pass: calculate all the payouts
const entries: Entry[] = []
for (const weight of weights) {
const { yesBid, noBid, yesWeight, noWeight } = weight
const yesPayout = (yesWeight / yesWeightsSum) * potSize
const noPayout = (noWeight / noWeightsSum) * potSize
const yesReturn = (yesPayout - yesBid) / yesBid
const noReturn = (noPayout - noBid) / noBid
entries.push({ ...weight, yesPayout, noPayout, yesReturn, noReturn })
}
return entries
}

View File

@ -1,58 +0,0 @@
const data = `1,9
8,
,1
1,
,1
1,
,5
5,
,5
5,
,1
1,
100,
,10
10,
,10
10,
,10
10,
,10
10,
,10
10,
,10
10,
,10
10,
,10
10,
,10
10,
,10
10,
,10
10,
,10
10,
,10
10,
,10
10,
,10
10,
,10
10,
,10
10,`
// Parse data into Yes/No orders
// E.g. `8,\n,1\n1,` =>
// [{yesBid: 8, noBid: 0}, {yesBid: 0, noBid: 1}, {yesBid: 1, noBid: 0}]
export const bids = data.split('\n').map((line) => {
const [yesBid, noBid] = line.split(',')
return {
yesBid: parseInt(yesBid || '0'),
noBid: parseInt(noBid || '0'),
}
})

View File

@ -4,6 +4,7 @@ const ABOUT_PAGE_URL = 'https://docs.manifold.markets/$how-to'
/** @type {import('next').NextConfig} */
module.exports = {
productionBrowserSourceMaps: true,
staticPageGenerationTimeout: 600, // e.g. stats page
reactStrictMode: true,
optimizeFonts: false,

View File

@ -69,7 +69,7 @@ export async function getStaticPropz(props: {
comments: comments.slice(0, 1000),
},
revalidate: 60, // regenerate after a minute
revalidate: 5, // regenerate after five seconds
}
}
@ -158,11 +158,15 @@ export function ContractPageContent(
const contract = useContractWithPreload(props.contract) ?? props.contract
usePrefetch(user?.id)
useTracking('view market', {
slug: contract.slug,
contractId: contract.id,
creatorId: contract.creatorId,
})
useTracking(
'view market',
{
slug: contract.slug,
contractId: contract.id,
creatorId: contract.creatorId,
},
true
)
const bets = useBets(contract.id) ?? props.bets
const nonChallengeBets = useMemo(

View File

@ -0,0 +1,21 @@
import { NextApiRequest, NextApiResponse } from 'next'
import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors'
import { listGroupContracts } from 'web/lib/firebase/groups'
import { toLiteMarket } from 'web/pages/api/v0/_types'
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
await applyCorsHeaders(req, res, CORS_UNRESTRICTED)
const { id } = req.query
const contracts = (await listGroupContracts(id as string)).map((contract) =>
toLiteMarket(contract)
)
if (!contracts) {
res.status(404).json({ error: 'Group not found' })
return
}
res.setHeader('Cache-Control', 'no-cache')
return res.status(200).json(contracts)
}

View File

@ -1,14 +1,42 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { listAllGroups } from 'web/lib/firebase/groups'
import { listAllGroups, listAvailableGroups } from 'web/lib/firebase/groups'
import { applyCorsHeaders, CORS_UNRESTRICTED } from 'web/lib/api/cors'
import { z } from 'zod'
import { validate } from 'web/pages/api/v0/_validate'
import { ValidationError } from 'web/pages/api/v0/_types'
type Data = any[]
const queryParams = z
.object({
availableToUserId: z.string().optional(),
})
.strict()
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<Data>
res: NextApiResponse
) {
await applyCorsHeaders(req, res, CORS_UNRESTRICTED)
let params: z.infer<typeof queryParams>
try {
params = validate(queryParams, req.query)
} catch (e) {
if (e instanceof ValidationError) {
return res.status(400).json(e)
}
console.error(`Unknown error during validation: ${e}`)
return res.status(500).json({ error: 'Unknown error during validation' })
}
const { availableToUserId } = params
// TODO: should we check if the user is a real user?
if (availableToUserId) {
const groups = await listAvailableGroups(availableToUserId)
res.setHeader('Cache-Control', 'max-age=0')
res.status(200).json(groups)
return
}
const groups = await listAllGroups()
res.setHeader('Cache-Control', 'max-age=0')
res.status(200).json(groups)

View File

@ -20,7 +20,7 @@ import {
import { formatMoney } from 'common/util/format'
import { removeUndefinedProps } from 'common/util/object'
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
import { canModifyGroupContracts, getGroup } from 'web/lib/firebase/groups'
import { getGroup, groupPath } from 'web/lib/firebase/groups'
import { Group } from 'common/group'
import { useTracking } from 'web/hooks/use-tracking'
import { useWarnUnsavedChanges } from 'web/hooks/use-warn-unsaved-changes'
@ -34,6 +34,8 @@ import { Title } from 'web/components/title'
import { SEO } from 'web/components/SEO'
import { MultipleChoiceAnswers } from 'web/components/answers/multiple-choice-answers'
import { MINUTE_MS } from 'common/util/time'
import { ExternalLinkIcon } from '@heroicons/react/outline'
import { SiteLink } from 'web/components/site-link'
export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => {
return { props: { auth: await getUserAndPrivateUser(creds.uid) } }
@ -139,7 +141,7 @@ export function NewContract(props: {
useEffect(() => {
if (groupId)
getGroup(groupId).then((group) => {
if (group && canModifyGroupContracts(group, creator.id)) {
if (group) {
setSelectedGroup(group)
setShowGroupSelector(false)
}
@ -290,9 +292,9 @@ export function NewContract(props: {
}}
choicesMap={{
'Yes / No': 'BINARY',
'Multiple choice': 'MULTIPLE_CHOICE',
// 'Multiple choice': 'MULTIPLE_CHOICE',
'Free response': 'FREE_RESPONSE',
Numeric: 'PSEUDO_NUMERIC',
// Numeric: 'PSEUDO_NUMERIC',
}}
isSubmitting={isSubmitting}
className={'col-span-4'}
@ -314,14 +316,14 @@ export function NewContract(props: {
<div className="form-control mb-2 items-start">
<label className="label gap-2">
<span className="mb-1">Range</span>
<InfoTooltip text="The minimum and maximum numbers across the numeric range." />
<InfoTooltip text="The lower and higher bounds of the numeric range. Choose bounds the value could reasonably be expected to hit." />
</label>
<Row className="gap-2">
<input
type="number"
className="input input-bordered w-32"
placeholder="MIN"
placeholder="LOW"
onClick={(e) => e.stopPropagation()}
onChange={(e) => setMinString(e.target.value)}
min={Number.MIN_SAFE_INTEGER}
@ -332,7 +334,7 @@ export function NewContract(props: {
<input
type="number"
className="input input-bordered w-32"
placeholder="MAX"
placeholder="HIGH"
onClick={(e) => e.stopPropagation()}
onChange={(e) => setMaxString(e.target.value)}
min={Number.MIN_SAFE_INTEGER}
@ -406,13 +408,19 @@ export function NewContract(props: {
<Spacer h={6} />
<GroupSelector
selectedGroup={selectedGroup}
setSelectedGroup={setSelectedGroup}
creator={creator}
options={{ showSelector: showGroupSelector, showLabel: true }}
/>
<Row className={'items-end gap-x-2'}>
<GroupSelector
selectedGroup={selectedGroup}
setSelectedGroup={setSelectedGroup}
creator={creator}
options={{ showSelector: showGroupSelector, showLabel: true }}
/>
{showGroupSelector && selectedGroup && (
<SiteLink href={groupPath(selectedGroup.slug)}>
<ExternalLinkIcon className=" ml-1 mb-3 h-5 w-5 text-gray-500" />
</SiteLink>
)}
</Row>
<Spacer h={6} />
<div className="form-control mb-1 items-start">
@ -483,17 +491,17 @@ export function NewContract(props: {
{formatMoney(ante)}
</div>
) : (
<div>
<div className="label-text text-primary pl-1">
FREE{' '}
<span className="label-text pl-1 text-gray-500">
(You have{' '}
{FREE_MARKETS_PER_USER_MAX -
(creator?.freeMarketsCreated ?? 0)}{' '}
free markets left)
</span>
<Row>
<div className="label-text text-neutral pl-1 line-through">
{formatMoney(ante)}
</div>
</div>
<div className="label-text text-primary pl-1">FREE </div>
<div className="label-text pl-1 text-gray-500">
(You have{' '}
{FREE_MARKETS_PER_USER_MAX - (creator?.freeMarketsCreated ?? 0)}{' '}
free markets left)
</div>
</Row>
)}
{ante > balance && !deservesFreeMarket && (

View File

@ -21,6 +21,7 @@ import { SiteLink } from 'web/components/site-link'
import { useContractWithPreload } from 'web/hooks/use-contract'
import { useMeasureSize } from 'web/hooks/use-measure-size'
import { fromPropz, usePropz } from 'web/hooks/use-propz'
import { useTracking } from 'web/hooks/use-tracking'
import { listAllBets } from 'web/lib/firebase/bets'
import {
contractPath,
@ -82,6 +83,12 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) {
const { contract, bets } = props
const { question, outcomeType } = contract
useTracking('view market embed', {
slug: contract.slug,
contractId: contract.id,
creatorId: contract.creatorId,
})
const isBinary = outcomeType === 'BINARY'
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'

View File

@ -25,6 +25,7 @@ import { Button } from 'web/components/button'
import { ArrangeHome, getHomeItems } from '../../../components/arrange-home'
import { Title } from 'web/components/title'
import { Row } from 'web/components/layout/row'
import { ProbChangeTable } from 'web/components/contract/prob-change-table'
export const getServerSideProps: GetServerSideProps = async (ctx) => {
const creds = await authenticateOnServer(ctx)
@ -75,36 +76,41 @@ const Home = (props: { auth: { user: User } | null }) => {
/>
</>
) : (
visibleItems.map((item) => {
const { id } = item
if (id === 'your-bets') {
return (
<SearchSection
key={id}
label={'Your bets'}
sort={'newest'}
user={user}
yourBets
/>
)
}
const sort = SORTS.find((sort) => sort.value === id)
if (sort)
return (
<SearchSection
key={id}
label={sort.label}
sort={sort.value}
user={user}
/>
)
<>
<div className="text-xl text-gray-800">Daily movers</div>
<ProbChangeTable userId={user?.id} />
const group = groups.find((g) => g.id === id)
if (group)
return <GroupSection key={id} group={group} user={user} />
{visibleItems.map((item) => {
const { id } = item
if (id === 'your-bets') {
return (
<SearchSection
key={id}
label={'Your bets'}
sort={'prob-change-day'}
user={user}
yourBets
/>
)
}
const sort = SORTS.find((sort) => sort.value === id)
if (sort)
return (
<SearchSection
key={id}
label={sort.label}
sort={sort.value}
user={user}
/>
)
return null
})
const group = groups.find((g) => g.id === id)
if (group)
return <GroupSection key={id} group={group} user={user} />
return null
})}
</>
)}
</Col>
<button

View File

@ -14,13 +14,14 @@ import {
getGroupBySlug,
groupPath,
joinGroup,
listMembers,
updateGroup,
} from 'web/lib/firebase/groups'
import { Row } from 'web/components/layout/row'
import { firebaseLogin, getUser, User } from 'web/lib/firebase/users'
import { Col } from 'web/components/layout/col'
import { useUser } from 'web/hooks/use-user'
import { listMembers, useGroup, useMembers } from 'web/hooks/use-group'
import { useGroup, useGroupContractIds, useMembers } from 'web/hooks/use-group'
import { scoreCreators, scoreTraders } from 'common/scoring'
import { Leaderboard } from 'web/components/leaderboard'
import { formatMoney } from 'common/util/format'
@ -51,6 +52,7 @@ import { Post } from 'common/post'
import { Spacer } from 'web/components/layout/spacer'
import { usePost } from 'web/hooks/use-post'
import { useAdmin } from 'web/hooks/use-admin'
import { track } from '@amplitude/analytics-browser'
export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: { params: { slugs: string[] } }) {
@ -157,7 +159,6 @@ export default function GroupPage(props: {
const {
contractsCount,
creator,
members,
traderScores,
topTraders,
creatorScores,
@ -174,6 +175,7 @@ export default function GroupPage(props: {
const user = useUser()
const isAdmin = useAdmin()
const members = useMembers(group?.id) ?? props.members
useSaveReferral(user, {
defaultReferrerUsername: creator.username,
@ -183,9 +185,8 @@ export default function GroupPage(props: {
if (group === null || !groupSubpages.includes(page) || slugs[2]) {
return <Custom404 />
}
const { memberIds } = group
const isCreator = user && group && user.id === group.creatorId
const isMember = user && memberIds.includes(user.id)
const isMember = user && members.map((m) => m.id).includes(user.id)
const leaderboard = (
<Col>
@ -331,6 +332,7 @@ function GroupOverview(props: {
const shareUrl = `https://${ENV_CONFIG.domain}${groupPath(
group.slug
)}${postFix}`
const isMember = user ? members.map((m) => m.id).includes(user.id) : false
return (
<>
@ -347,10 +349,13 @@ function GroupOverview(props: {
{isCreator ? (
<EditGroupButton className={'ml-1'} group={group} />
) : (
user &&
group.memberIds.includes(user?.id) && (
user && (
<Row>
<JoinOrLeaveGroupButton group={group} />
<JoinOrLeaveGroupButton
group={group}
user={user}
isMember={isMember}
/>
</Row>
)
)}
@ -425,7 +430,7 @@ function GroupMemberSearch(props: { members: User[]; group: Group }) {
let { members } = props
// Use static members on load, but also listen to member changes:
const listenToMembers = useMembers(group)
const listenToMembers = useMembers(group.id)
if (listenToMembers) {
members = listenToMembers
}
@ -547,6 +552,7 @@ function AddContractButton(props: { group: Group; user: User }) {
const [open, setOpen] = useState(false)
const [contracts, setContracts] = useState<Contract[]>([])
const [loading, setLoading] = useState(false)
const groupContractIds = useGroupContractIds(group.id)
async function addContractToCurrentGroup(contract: Contract) {
if (contracts.map((c) => c.id).includes(contract.id)) {
@ -634,7 +640,9 @@ function AddContractButton(props: { group: Group; user: User }) {
hideOrderSelector={true}
onContractClick={addContractToCurrentGroup}
cardHideOptions={{ hideGroupLink: true, hideQuickBet: true }}
additionalFilter={{ excludeContractIds: group.contractIds }}
additionalFilter={{
excludeContractIds: groupContractIds,
}}
highlightOptions={{
contractIds: contracts.map((c) => c.id),
highlightClassName: '!bg-indigo-100 border-indigo-100 border-2',
@ -652,22 +660,25 @@ function JoinGroupButton(props: {
user: User | null | undefined
}) {
const { group, user } = props
function addUserToGroup() {
if (user && !group.memberIds.includes(user.id)) {
toast.promise(joinGroup(group, user.id), {
loading: 'Joining group...',
success: 'Joined group!',
error: "Couldn't join group, try again?",
})
}
const follow = async () => {
track('join group')
const userId = user ? user.id : (await firebaseLogin()).user.uid
toast.promise(joinGroup(group, userId), {
loading: 'Following group...',
success: 'Followed',
error: "Couldn't follow group, try again?",
})
}
return (
<div>
<button
onClick={user ? addUserToGroup : firebaseLogin}
onClick={follow}
className={'btn-md btn-outline btn whitespace-nowrap normal-case'}
>
{user ? 'Join group' : 'Login to join group'}
Follow
</button>
</div>
)

View File

@ -7,10 +7,9 @@ import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row'
import { Page } from 'web/components/page'
import { Title } from 'web/components/title'
import { useGroups, useMemberGroupIds, useMembers } from 'web/hooks/use-group'
import { useUser } from 'web/hooks/use-user'
import { useGroups, useMemberGroupIds } from 'web/hooks/use-group'
import { groupPath, listAllGroups } from 'web/lib/firebase/groups'
import { getUser, User } from 'web/lib/firebase/users'
import { getUser, getUserAndPrivateUser, User } from 'web/lib/firebase/users'
import { Tabs } from 'web/components/layout/tabs'
import { SiteLink } from 'web/components/site-link'
import clsx from 'clsx'
@ -18,9 +17,13 @@ import { Avatar } from 'web/components/avatar'
import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button'
import { searchInAny } from 'common/util/parse'
import { SEO } from 'web/components/SEO'
import { UserLink } from 'web/components/user-link'
import { GetServerSideProps } from 'next'
import { authenticateOnServer } from 'web/lib/firebase/server-auth'
import { useUser } from 'web/hooks/use-user'
export async function getStaticProps() {
export const getServerSideProps: GetServerSideProps = async (ctx) => {
const creds = await authenticateOnServer(ctx)
const auth = creds ? await getUserAndPrivateUser(creds.uid) : null
const groups = await listAllGroups().catch((_) => [])
const creators = await Promise.all(
@ -30,24 +33,19 @@ export async function getStaticProps() {
creators.map((creator) => [creator.id, creator])
)
return {
props: {
groups: groups,
creatorsDict,
},
revalidate: 60, // regenerate after a minute
}
return { props: { auth, groups, creatorsDict } }
}
export default function Groups(props: {
auth: { user: User } | null
groups: Group[]
creatorsDict: { [k: string]: User }
}) {
//TODO: do we really need the creatorsDict?
const [creatorsDict, setCreatorsDict] = useState(props.creatorsDict)
const serverUser = props.auth?.user
const groups = useGroups() ?? props.groups
const user = useUser()
const user = useUser() ?? serverUser
const memberGroupIds = useMemberGroupIds(user) || []
useEffect(() => {
@ -67,26 +65,9 @@ export default function Groups(props: {
const [query, setQuery] = useState('')
// List groups with the highest question count, then highest member count
// TODO use find-active-contracts to sort by?
const matches = sortBy(groups, [
(group) => -1 * group.contractIds.length,
(group) => -1 * group.memberIds.length,
]).filter((g) =>
searchInAny(
query,
g.name,
g.about || '',
creatorsDict[g.creatorId].username
)
)
const matchesOrderedByRecentActivity = sortBy(groups, [
(group) =>
-1 *
(group.mostRecentChatActivityTime ??
group.mostRecentContractAddedTime ??
group.mostRecentActivityTime),
const matchesOrderedByMostContractAndMembers = sortBy(groups, [
(group) => -1 * group.totalContracts,
(group) => -1 * group.totalMembers,
]).filter((g) =>
searchInAny(
query,
@ -120,7 +101,7 @@ export default function Groups(props: {
<Tabs
currentPageForAnalytics={'groups'}
tabs={[
...(user && memberGroupIds.length > 0
...(user
? [
{
title: 'My Groups',
@ -128,13 +109,14 @@ export default function Groups(props: {
<Col>
<input
type="text"
value={query}
onChange={(e) => debouncedQuery(e.target.value)}
placeholder="Search your groups"
className="input input-bordered mb-4 w-full"
/>
<div className="flex flex-wrap justify-center gap-4">
{matchesOrderedByRecentActivity
{matchesOrderedByMostContractAndMembers
.filter((match) =>
memberGroupIds.includes(match.id)
)
@ -143,6 +125,8 @@ export default function Groups(props: {
key={group.id}
group={group}
creator={creatorsDict[group.creatorId]}
user={user}
isMember={memberGroupIds.includes(group.id)}
/>
))}
</div>
@ -159,15 +143,18 @@ export default function Groups(props: {
type="text"
onChange={(e) => debouncedQuery(e.target.value)}
placeholder="Search groups"
value={query}
className="input input-bordered mb-4 w-full"
/>
<div className="flex flex-wrap justify-center gap-4">
{matches.map((group) => (
{matchesOrderedByMostContractAndMembers.map((group) => (
<GroupCard
key={group.id}
group={group}
creator={creatorsDict[group.creatorId]}
user={user}
isMember={memberGroupIds.includes(group.id)}
/>
))}
</div>
@ -182,8 +169,14 @@ export default function Groups(props: {
)
}
export function GroupCard(props: { group: Group; creator: User | undefined }) {
const { group, creator } = props
export function GroupCard(props: {
group: Group
creator: User | undefined
user: User | undefined | null
isMember: boolean
}) {
const { group, creator, user, isMember } = props
const { totalContracts } = group
return (
<Col className="relative min-w-[20rem] max-w-xs gap-1 rounded-xl bg-white p-8 shadow-md hover:bg-gray-100">
<Link href={groupPath(group.slug)}>
@ -201,7 +194,7 @@ export function GroupCard(props: { group: Group; creator: User | undefined }) {
<Row className="items-center justify-between gap-2">
<span className="text-xl">{group.name}</span>
</Row>
<Row>{group.contractIds.length} questions</Row>
<Row>{totalContracts} questions</Row>
<Row className="text-sm text-gray-500">
<GroupMembersList group={group} />
</Row>
@ -209,7 +202,12 @@ export function GroupCard(props: { group: Group; creator: User | undefined }) {
<div className="text-sm text-gray-500">{group.about}</div>
</Row>
<Col className={'mt-2 h-full items-start justify-end'}>
<JoinOrLeaveGroupButton group={group} className={'z-10 w-24'} />
<JoinOrLeaveGroupButton
group={group}
className={'z-10 w-24'}
user={user}
isMember={isMember}
/>
</Col>
</Col>
)
@ -217,23 +215,11 @@ export function GroupCard(props: { group: Group; creator: User | undefined }) {
function GroupMembersList(props: { group: Group }) {
const { group } = props
const maxMembersToShow = 3
const members = useMembers(group, maxMembersToShow).filter(
(m) => m.id !== group.creatorId
)
if (group.memberIds.length === 1) return <div />
const { totalMembers } = group
if (totalMembers === 1) return <div />
return (
<div className="text-neutral flex flex-wrap gap-1">
<span className={'text-gray-500'}>Other members</span>
{members.slice(0, maxMembersToShow).map((member, i) => (
<div key={member.id} className={'flex-shrink'}>
<UserLink name={member.name} username={member.username} />
{members.length > 1 && i !== members.length - 1 && <span>,</span>}
</div>
))}
{group.memberIds.length > maxMembersToShow && (
<span> & {group.memberIds.length - maxMembersToShow} more</span>
)}
<span>{totalMembers} members</span>
</div>
)
}

View File

@ -4,23 +4,14 @@ import { PencilAltIcon } from '@heroicons/react/solid'
import { Page } from 'web/components/page'
import { Col } from 'web/components/layout/col'
import { ContractSearch } from 'web/components/contract-search'
import { User } from 'common/user'
import { getUserAndPrivateUser } from 'web/lib/firebase/users'
import { useTracking } from 'web/hooks/use-tracking'
import { useUser } from 'web/hooks/use-user'
import { track } from 'web/lib/service/analytics'
import { authenticateOnServer } from 'web/lib/firebase/server-auth'
import { useSaveReferral } from 'web/hooks/use-save-referral'
import { GetServerSideProps } from 'next'
import { usePrefetch } from 'web/hooks/use-prefetch'
export const getServerSideProps: GetServerSideProps = async (ctx) => {
const creds = await authenticateOnServer(ctx)
const auth = creds ? await getUserAndPrivateUser(creds.uid) : null
return { props: { auth } }
}
const Home = (props: { auth: { user: User } | null }) => {
const user = props.auth ? props.auth.user : null
const Home = () => {
const user = useUser()
const router = useRouter()
useTracking('view home')

View File

@ -390,7 +390,7 @@ function IncomeNotificationItem(props: {
reasonText = !simple
? `Bonus for ${
parseInt(sourceText) / UNIQUE_BETTOR_BONUS_AMOUNT
} unique traders on`
} new bettors on`
: 'bonus on'
} else if (sourceType === 'tip') {
reasonText = !simple ? `tipped you on` : `in tips on`

View File

@ -1,332 +0,0 @@
import React, { useMemo, useState } from 'react'
import { DatumValue } from '@nivo/core'
import { ResponsiveLine } from '@nivo/line'
import { Entry, makeEntries } from 'web/lib/simulator/entries'
import { Col } from 'web/components/layout/col'
function TableBody(props: { entries: Entry[] }) {
return (
<tbody>
{props.entries.map((entry, i) => (
<tr key={i}>
<th>{props.entries.length - i}</th>
<TableRowStart entry={entry} />
<TableRowEnd entry={entry} />
</tr>
))}
</tbody>
)
}
function TableRowStart(props: { entry: Entry }) {
const { entry } = props
if (entry.yesBid && entry.noBid) {
return (
<>
<td>
<div className="badge">ANTE</div>
</td>
<td>
${entry.yesBid} / ${entry.noBid}
</td>
</>
)
} else if (entry.yesBid) {
return (
<>
<td>
<div className="badge badge-success">YES</div>
</td>
<td>${entry.yesBid}</td>
</>
)
} else {
return (
<>
<td>
<div className="badge badge-error">NO</div>
</td>
<td>${entry.noBid}</td>
</>
)
}
}
function TableRowEnd(props: { entry: Entry | null; isNew?: boolean }) {
const { entry } = props
if (!entry) {
return (
<>
<td>0</td>
<td>0</td>
{!props.isNew && (
<>
<td>N/A</td>
<td>N/A</td>
</>
)}
</>
)
} else if (entry.yesBid && entry.noBid) {
return (
<>
<td>{(entry.prob * 100).toFixed(1)}%</td>
<td>N/A</td>
{!props.isNew && (
<>
<td>N/A</td>
<td>N/A</td>
</>
)}
</>
)
} else if (entry.yesBid) {
return (
<>
<td>{(entry.prob * 100).toFixed(1)}%</td>
<td>${entry.yesWeight.toFixed(0)}</td>
{!props.isNew && (
<>
<td>${entry.yesPayout.toFixed(0)}</td>
<td>{(entry.yesReturn * 100).toFixed(0)}%</td>
</>
)}
</>
)
} else {
return (
<>
<td>{(entry.prob * 100).toFixed(1)}%</td>
<td>${entry.noWeight.toFixed(0)}</td>
{!props.isNew && (
<>
<td>${entry.noPayout.toFixed(0)}</td>
<td>{(entry.noReturn * 100).toFixed(0)}%</td>
</>
)}
</>
)
}
}
type Bid = { yesBid: number; noBid: number }
function NewBidTable(props: {
steps: number
bids: Array<Bid>
setSteps: (steps: number) => void
setBids: (bids: Array<Bid>) => void
}) {
const { steps, bids, setSteps, setBids } = props
// Prepare for new bids
const [newBid, setNewBid] = useState(0)
const [newBidType, setNewBidType] = useState('YES')
function makeBid(type: string, bid: number) {
return {
yesBid: type == 'YES' ? bid : 0,
noBid: type == 'YES' ? 0 : bid,
}
}
function submitBid() {
if (newBid <= 0) return
const bid = makeBid(newBidType, newBid)
bids.splice(steps, 0, bid)
setBids(bids)
setSteps(steps + 1)
setNewBid(0)
}
function toggleBidType() {
setNewBidType(newBidType === 'YES' ? 'NO' : 'YES')
}
const nextBid = makeBid(newBidType, newBid)
const fakeBids = [...bids.slice(0, steps), nextBid]
const entries = makeEntries(fakeBids)
const nextEntry = entries[entries.length - 1]
function randomBid() {
const bidType = Math.random() < 0.5 ? 'YES' : 'NO'
// const p = bidType === 'YES' ? nextEntry.prob : 1 - nextEntry.prob
const amount = Math.floor(Math.random() * 300) + 1
const bid = makeBid(bidType, amount)
bids.splice(steps, 0, bid)
setBids(bids)
setSteps(steps + 1)
setNewBid(0)
}
return (
<>
<table className="table-compact my-8 table w-full text-center">
<thead>
<tr>
<th>Order #</th>
<th>Type</th>
<th>Bet</th>
<th>Prob</th>
<th>Est Payout</th>
<th></th>
</tr>
</thead>
<tbody>
<tr>
<th>{steps + 1}</th>
<td>
<div
className={
`badge hover:cursor-pointer ` +
(newBidType == 'YES' ? 'badge-success' : 'badge-ghost')
}
onClick={toggleBidType}
>
YES
</div>
<br />
<div
className={
`badge hover:cursor-pointer ` +
(newBidType == 'NO' ? 'badge-error' : 'badge-ghost')
}
onClick={toggleBidType}
>
NO
</div>
</td>
<td>
{/* Note: Would love to make this input smaller... */}
<input
type="number"
placeholder="0"
className="input input-bordered max-w-[100px]"
value={newBid.toString()}
onChange={(e) => setNewBid(parseInt(e.target.value) || 0)}
onKeyUp={(e) => {
if (e.key === 'Enter') {
submitBid()
}
}}
onFocus={(e) => e.target.select()}
/>
</td>
<TableRowEnd entry={nextEntry} isNew />
<button
className="btn btn-primary mt-2"
onClick={() => submitBid()}
disabled={newBid <= 0}
>
Submit
</button>
</tr>
</tbody>
</table>
<button className="btn btn-secondary mb-4" onClick={randomBid}>
Random bet!
</button>
</>
)
}
// Show a hello world React page
export default function Simulator() {
const [steps, setSteps] = useState(1)
const [bids, setBids] = useState([{ yesBid: 100, noBid: 100 }])
const entries = useMemo(
() => makeEntries(bids.slice(0, steps)),
[bids, steps]
)
const reversedEntries = [...entries].reverse()
const probs = entries.map((entry) => entry.prob)
const points = probs.map((prob, i) => ({ x: i + 1, y: prob * 100 }))
const data = [{ id: 'Yes', data: points, color: '#11b981' }]
const tickValues = [0, 25, 50, 75, 100]
return (
<Col>
<div className="mx-auto mt-8 grid w-full grid-cols-1 gap-4 p-2 text-center xl:grid-cols-2">
{/* Left column */}
<div>
<h1 className="mb-8 text-2xl font-bold">
Dynamic Parimutuel Market Simulator
</h1>
<NewBidTable {...{ steps, bids, setSteps, setBids }} />
{/* History of bids */}
<div className="overflow-x-auto">
<table className="table w-full text-center">
<thead>
<tr>
<th>Order #</th>
<th>Type</th>
<th>Bet</th>
<th>Prob</th>
<th>Est Payout</th>
<th>Payout</th>
<th>Return</th>
</tr>
</thead>
<TableBody entries={reversedEntries} />
</table>
</div>
</div>
{/* Right column */}
<Col>
<h1 className="mb-8 text-2xl font-bold">
Probability of
<div className="badge badge-success w-18 ml-3 h-8 text-2xl">
YES
</div>
</h1>
<div className="mb-10 h-[500px] w-full">
<ResponsiveLine
data={data}
yScale={{ min: 0, max: 100, type: 'linear' }}
yFormat={formatPercent}
gridYValues={tickValues}
axisLeft={{
tickValues,
format: formatPercent,
}}
enableGridX={false}
colors={{ datum: 'color' }}
pointSize={8}
pointBorderWidth={1}
pointBorderColor="#fff"
enableSlices="x"
enableArea
margin={{ top: 20, right: 10, bottom: 20, left: 40 }}
/>
</div>
{/* Range slider that sets the current step */}
<label>Orders # 1 - {steps}</label>
<input
type="range"
className="range"
min="1"
max={bids.length}
value={steps}
onChange={(e) => setSteps(parseInt(e.target.value))}
/>
</Col>
</div>
</Col>
)
}
function formatPercent(y: DatumValue) {
return `${Math.round(+y.toString())}%`
}

View File

@ -1,16 +1,10 @@
import { ClockIcon } from '@heroicons/react/outline'
import { UsersIcon } from '@heroicons/react/solid'
import {
BinaryContract,
Contract,
PseudoNumericContract,
} from 'common/contract'
import { Group } from 'common/group'
import dayjs, { Dayjs } from 'dayjs'
import dayjs from 'dayjs'
import customParseFormat from 'dayjs/plugin/customParseFormat'
import timezone from 'dayjs/plugin/timezone'
import utc from 'dayjs/plugin/utc'
import { keyBy, mapValues, sortBy } from 'lodash'
import { zip } from 'lodash'
import Image, { ImageProps, StaticImageData } from 'next/image'
import Link from 'next/link'
import { useState } from 'react'
@ -20,27 +14,33 @@ import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row'
import { Page } from 'web/components/page'
import { SEO } from 'web/components/SEO'
import { listContractsByGroupSlug } from 'web/lib/firebase/contracts'
import { tournamentContractsByGroupSlugQuery } from 'web/lib/firebase/contracts'
import { getGroup, groupPath } from 'web/lib/firebase/groups'
import elon_pic from './_cspi/Will_Elon_Buy_Twitter.png'
import china_pic from './_cspi/Chinese_Military_Action_against_Taiwan.png'
import mpox_pic from './_cspi/Monkeypox_Cases.png'
import race_pic from './_cspi/Supreme_Court_Ban_Race_in_College_Admissions.png'
import { SiteLink } from 'web/components/site-link'
import { getProbability } from 'common/calculate'
import { Carousel } from 'web/components/carousel'
import { usePagination } from 'web/hooks/use-pagination'
import { LoadingIndicator } from 'web/components/loading-indicator'
dayjs.extend(utc)
dayjs.extend(timezone)
dayjs.extend(customParseFormat)
const toDate = (d: string) => dayjs(d, 'MMM D, YYYY').tz('America/Los_Angeles')
const toDate = (d: string) =>
dayjs(d, 'MMM D, YYYY').tz('America/Los_Angeles').valueOf()
type MarketImage = {
marketUrl: string
image: StaticImageData
}
type Tourney = {
title: string
url?: string
blurb: string // actual description in the click-through
award?: string
endTime?: Dayjs
endTime?: number
groupId: string
}
@ -50,7 +50,7 @@ const Salem = {
url: 'https://salemcenter.manifold.markets/',
award: '$25,000',
endTime: toDate('Jul 31, 2023'),
markets: [],
contractIds: [],
images: [
{
marketUrl:
@ -107,33 +107,27 @@ const tourneys: Tourney[] = [
// },
]
export async function getStaticProps() {
const groupIds = tourneys
.map((data) => data.groupId)
.filter((id) => id != undefined) as string[]
const groups = (await Promise.all(groupIds.map(getGroup)))
// Then remove undefined groups
.filter(Boolean) as Group[]
const contracts = await Promise.all(
groups.map((g) => listContractsByGroupSlug(g?.slug ?? ''))
)
const markets = Object.fromEntries(groups.map((g, i) => [g.id, contracts[i]]))
const groupMap = keyBy(groups, 'id')
const numPeople = mapValues(groupMap, (g) => g?.memberIds.length)
const slugs = mapValues(groupMap, 'slug')
return { props: { markets, numPeople, slugs }, revalidate: 60 * 10 }
type SectionInfo = {
tourney: Tourney
slug: string
numPeople: number
}
export default function TournamentPage(props: {
markets: { [groupId: string]: Contract[] }
numPeople: { [groupId: string]: number }
slugs: { [groupId: string]: string }
}) {
const { markets = {}, numPeople = {}, slugs = {} } = props
export async function getStaticProps() {
const groupIds = tourneys.map((data) => data.groupId)
const groups = await Promise.all(groupIds.map(getGroup))
const sections = zip(tourneys, groups)
.filter(([_tourney, group]) => group != null)
.map(([tourney, group]) => ({
tourney,
slug: group!.slug, // eslint-disable-line
numPeople: group!.totalMembers, // eslint-disable-line
}))
return { props: { sections } }
}
export default function TournamentPage(props: { sections: SectionInfo[] }) {
const { sections } = props
return (
<Page>
@ -141,96 +135,114 @@ export default function TournamentPage(props: {
title="Tournaments"
description="Win money by betting in forecasting touraments on current events, sports, science, and more"
/>
<Col className="mx-4 mt-4 gap-20 sm:mx-10 xl:w-[125%]">
{tourneys.map(({ groupId, ...data }) => (
<Section
key={groupId}
{...data}
url={groupPath(slugs[groupId])}
ppl={numPeople[groupId] ?? 0}
markets={markets[groupId] ?? []}
/>
<Col className="mx-4 mt-4 gap-10 sm:mx-10 xl:w-[125%]">
{sections.map(({ tourney, slug, numPeople }) => (
<div key={slug}>
<SectionHeader
url={groupPath(slug)}
title={tourney.title}
ppl={numPeople}
award={tourney.award}
endTime={tourney.endTime}
/>
<span>{tourney.blurb}</span>
<MarketCarousel slug={slug} />
</div>
))}
<Section {...Salem} />
<div>
<SectionHeader
url={Salem.url}
title={Salem.title}
award={Salem.award}
endTime={Salem.endTime}
/>
<span>{Salem.blurb}</span>
<ImageCarousel url={Salem.url} images={Salem.images} />
</div>
</Col>
</Page>
)
}
function Section(props: {
title: string
const SectionHeader = (props: {
url: string
blurb: string
award?: string
title: string
ppl?: number
endTime?: Dayjs
markets: Contract[]
images?: { marketUrl: string; image: StaticImageData }[] // hack for cspi
}) {
const { title, url, blurb, award, ppl, endTime, images } = props
// Sort markets by probability, highest % first
const markets = sortBy(props.markets, (c) =>
getProbability(c as BinaryContract | PseudoNumericContract)
)
.reverse()
.filter((c) => !c.isResolved)
award?: string
endTime?: number
}) => {
const { url, title, ppl, award, endTime } = props
return (
<div>
<Link href={url}>
<a className="group mb-3 flex flex-wrap justify-between">
<h2 className="text-xl font-semibold group-hover:underline md:text-3xl">
{title}
</h2>
<Row className="my-2 items-center gap-4 whitespace-nowrap rounded-full bg-gray-200 px-6">
{!!award && <span className="flex items-center">🏆 {award}</span>}
{!!ppl && (
<Link href={url}>
<a className="group mb-3 flex flex-wrap justify-between">
<h2 className="text-xl font-semibold group-hover:underline md:text-3xl">
{title}
</h2>
<Row className="my-2 items-center gap-4 whitespace-nowrap rounded-full bg-gray-200 px-6">
{!!award && <span className="flex items-center">🏆 {award}</span>}
{!!ppl && (
<span className="flex items-center gap-1">
<UsersIcon className="h-4" />
{ppl}
</span>
)}
{endTime && (
<DateTimeTooltip time={endTime} text="Ends">
<span className="flex items-center gap-1">
<UsersIcon className="h-4" />
{ppl}
<ClockIcon className="h-4" />
{dayjs(endTime).format('MMM D')}
</span>
)}
{endTime && (
<DateTimeTooltip time={endTime.valueOf()} text="Ends">
<span className="flex items-center gap-1">
<ClockIcon className="h-4" />
{endTime.format('MMM D')}
</span>
</DateTimeTooltip>
)}
</Row>
</DateTimeTooltip>
)}
</Row>
</a>
</Link>
)
}
const ImageCarousel = (props: { images: MarketImage[]; url: string }) => {
const { images, url } = props
return (
<Carousel className="-mx-4 mt-4 sm:-mx-10">
<div className="shrink-0 sm:w-6" />
{images.map(({ marketUrl, image }) => (
<a key={marketUrl} href={marketUrl} className="hover:brightness-95">
<NaturalImage src={image} />
</a>
</Link>
<span>{blurb}</span>
<Carousel className="-mx-4 mt-2 sm:-mx-10">
<div className="shrink-0 sm:w-6" />
{markets.length ? (
markets.map((m) => (
<ContractCard
contract={m}
hideGroupLink
className="mb-2 max-h-[200px] w-96 shrink-0"
questionClass="line-clamp-3"
trackingPostfix=" tournament"
/>
))
) : (
<>
{images?.map(({ marketUrl, image }) => (
<a href={marketUrl} className="hover:brightness-95">
<NaturalImage src={image} />
</a>
))}
<SiteLink
className="ml-6 mr-10 flex shrink-0 items-center text-indigo-700"
href={url}
>
See more
</SiteLink>
</>
)}
</Carousel>
</div>
))}
<SiteLink
className="ml-6 mr-10 flex shrink-0 items-center text-indigo-700"
href={url}
>
See more
</SiteLink>
</Carousel>
)
}
const MarketCarousel = (props: { slug: string }) => {
const { slug } = props
const q = tournamentContractsByGroupSlugQuery(slug)
const { allItems, getNext } = usePagination({ q, pageSize: 6 })
const items = allItems()
// todo: would be nice to have indicator somewhere when it loads next page
return items.length === 0 ? (
<LoadingIndicator className="mt-10" />
) : (
<Carousel className="-mx-4 mt-4 sm:-mx-10" loadMore={getNext}>
<div className="shrink-0 sm:w-6" />
{items.map((m) => (
<ContractCard
key={m.id}
contract={m}
hideGroupLink
className="mb-2 max-h-[200px] w-96 shrink-0"
questionClass="line-clamp-3"
trackingPostfix=" tournament"
/>
))}
</Carousel>
)
}