Merge branch 'main' into mod-enter

This commit is contained in:
Austin Chen 2022-09-09 09:54:52 -07:00
commit 90bf5cc39f
173 changed files with 3877 additions and 3709 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 = calculatePortfolioProfit(startingPortfolio)
return currentProfit - startingProfit
}
export const calculatePortfolioProfit = (portfolio: PortfolioMetrics) => {
return portfolio.investmentValue + portfolio.balance - portfolio.totalDeposits
}
export const calculateNewProfit = (
portfolioHistory: PortfolioMetrics[],
newPortfolio: PortfolioMetrics
) => {
const allTimeProfit = calculatePortfolioProfit(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

@ -1,6 +1,6 @@
import type { JSONContent } from '@tiptap/core' import type { JSONContent } from '@tiptap/core'
export type AnyCommentType = OnContract | OnGroup export type AnyCommentType = OnContract | OnGroup | OnPost
// Currently, comments are created after the bet, not atomically with the bet. // Currently, comments are created after the bet, not atomically with the bet.
// They're uniquely identified by the pair contractId/betId. // They're uniquely identified by the pair contractId/betId.
@ -20,19 +20,31 @@ export type Comment<T extends AnyCommentType = AnyCommentType> = {
userAvatarUrl?: string userAvatarUrl?: string
} & T } & T
type OnContract = { export type OnContract = {
commentType: 'contract' commentType: 'contract'
contractId: string contractId: string
contractSlug: string
contractQuestion: string
answerOutcome?: string answerOutcome?: string
betId?: string betId?: string
// denormalized from contract
contractSlug: string
contractQuestion: string
// denormalized from bet
betAmount?: number
betOutcome?: string
} }
type OnGroup = { export type OnGroup = {
commentType: 'group' commentType: 'group'
groupId: string groupId: string
} }
export type OnPost = {
commentType: 'post'
postId: string
}
export type ContractComment = Comment<OnContract> export type ContractComment = Comment<OnContract>
export type GroupComment = Comment<OnGroup> export type GroupComment = Comment<OnGroup>
export type PostComment = Comment<OnPost>

View File

@ -27,10 +27,10 @@ export function contractMetrics(contract: Contract) {
export function contractTextDetails(contract: Contract) { export function contractTextDetails(contract: Contract) {
// eslint-disable-next-line @typescript-eslint/no-var-requires // eslint-disable-next-line @typescript-eslint/no-var-requires
const dayjs = require('dayjs') const dayjs = require('dayjs')
const { closeTime, tags } = contract const { closeTime, groupLinks } = contract
const { createdDate, resolvedDate, volumeLabel } = contractMetrics(contract) const { createdDate, resolvedDate, volumeLabel } = contractMetrics(contract)
const hashtags = tags.map((tag) => `#${tag}`) const groupHashtags = groupLinks?.slice(0, 5).map((g) => `#${g.name}`)
return ( return (
`${resolvedDate ? `${createdDate} - ${resolvedDate}` : createdDate}` + `${resolvedDate ? `${createdDate} - ${resolvedDate}` : createdDate}` +
@ -40,7 +40,7 @@ export function contractTextDetails(contract: Contract) {
).format('MMM D, h:mma')}` ).format('MMM D, h:mma')}`
: '') + : '') +
`${volumeLabel}` + `${volumeLabel}` +
(hashtags.length > 0 ? `${hashtags.join(' ')}` : '') (groupHashtags ? `${groupHashtags.join(' ')}` : '')
) )
} }
@ -92,6 +92,7 @@ export const getOpenGraphProps = (contract: Contract) => {
creatorAvatarUrl, creatorAvatarUrl,
description, description,
numericValue, numericValue,
resolution,
} }
} }
@ -103,6 +104,7 @@ export type OgCardProps = {
creatorUsername: string creatorUsername: string
creatorAvatarUrl?: string creatorAvatarUrl?: string
numericValue?: string numericValue?: string
resolution?: string
} }
export function buildCardUrl(props: OgCardProps, challenge?: Challenge) { export function buildCardUrl(props: OgCardProps, challenge?: Challenge) {
@ -113,22 +115,32 @@ export function buildCardUrl(props: OgCardProps, challenge?: Challenge) {
creatorOutcome, creatorOutcome,
acceptorOutcome, acceptorOutcome,
} = challenge || {} } = challenge || {}
const {
probability,
numericValue,
resolution,
creatorAvatarUrl,
question,
metadata,
creatorUsername,
creatorName,
} = props
const { userName, userAvatarUrl } = acceptances?.[0] ?? {} const { userName, userAvatarUrl } = acceptances?.[0] ?? {}
const probabilityParam = const probabilityParam =
props.probability === undefined probability === undefined
? '' ? ''
: `&probability=${encodeURIComponent(props.probability ?? '')}` : `&probability=${encodeURIComponent(probability ?? '')}`
const numericValueParam = const numericValueParam =
props.numericValue === undefined numericValue === undefined
? '' ? ''
: `&numericValue=${encodeURIComponent(props.numericValue ?? '')}` : `&numericValue=${encodeURIComponent(numericValue ?? '')}`
const creatorAvatarUrlParam = const creatorAvatarUrlParam =
props.creatorAvatarUrl === undefined creatorAvatarUrl === undefined
? '' ? ''
: `&creatorAvatarUrl=${encodeURIComponent(props.creatorAvatarUrl ?? '')}` : `&creatorAvatarUrl=${encodeURIComponent(creatorAvatarUrl ?? '')}`
const challengeUrlParams = challenge const challengeUrlParams = challenge
? `&creatorAmount=${creatorAmount}&creatorOutcome=${creatorOutcome}` + ? `&creatorAmount=${creatorAmount}&creatorOutcome=${creatorOutcome}` +
@ -136,16 +148,21 @@ export function buildCardUrl(props: OgCardProps, challenge?: Challenge) {
`&acceptedName=${userName ?? ''}&acceptedAvatarUrl=${userAvatarUrl ?? ''}` `&acceptedName=${userName ?? ''}&acceptedAvatarUrl=${userAvatarUrl ?? ''}`
: '' : ''
const resolutionUrlParam = resolution
? `&resolution=${encodeURIComponent(resolution)}`
: ''
// URL encode each of the props, then add them as query params // URL encode each of the props, then add them as query params
return ( return (
`https://manifold-og-image.vercel.app/m.png` + `https://manifold-og-image.vercel.app/m.png` +
`?question=${encodeURIComponent(props.question)}` + `?question=${encodeURIComponent(question)}` +
probabilityParam + probabilityParam +
numericValueParam + numericValueParam +
`&metadata=${encodeURIComponent(props.metadata)}` + `&metadata=${encodeURIComponent(metadata)}` +
`&creatorName=${encodeURIComponent(props.creatorName)}` + `&creatorName=${encodeURIComponent(creatorName)}` +
creatorAvatarUrlParam + creatorAvatarUrlParam +
`&creatorUsername=${encodeURIComponent(props.creatorUsername)}` + `&creatorUsername=${encodeURIComponent(creatorUsername)}` +
challengeUrlParams challengeUrlParams +
resolutionUrlParam
) )
} }

View File

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

View File

@ -34,6 +34,11 @@ export const FIREBASE_CONFIG = ENV_CONFIG.firebaseConfig
export const PROJECT_ID = ENV_CONFIG.firebaseConfig.projectId export const PROJECT_ID = ENV_CONFIG.firebaseConfig.projectId
export const IS_PRIVATE_MANIFOLD = ENV_CONFIG.visibility === 'PRIVATE' export const IS_PRIVATE_MANIFOLD = ENV_CONFIG.visibility === 'PRIVATE'
export const AUTH_COOKIE_NAME = `FBUSER_${PROJECT_ID.toUpperCase().replace(
/-/g,
'_'
)}`
// Manifold's domain or any subdomains thereof // Manifold's domain or any subdomains thereof
export const CORS_ORIGIN_MANIFOLD = new RegExp( export const CORS_ORIGIN_MANIFOLD = new RegExp(
'^https?://(?:[a-zA-Z0-9\\-]+\\.)*' + escapeRegExp(ENV_CONFIG.domain) + '$' '^https?://(?:[a-zA-Z0-9\\-]+\\.)*' + escapeRegExp(ENV_CONFIG.domain) + '$'

View File

@ -73,6 +73,7 @@ export const PROD_CONFIG: EnvConfig = {
'manticmarkets@gmail.com', // Manifold 'manticmarkets@gmail.com', // Manifold
'iansphilips@gmail.com', // Ian 'iansphilips@gmail.com', // Ian
'd4vidchee@gmail.com', // D4vid 'd4vidchee@gmail.com', // D4vid
'federicoruizcassarino@gmail.com', // Fede
], ],
visibility: 'PUBLIC', visibility: 'PUBLIC',

View File

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

View File

@ -118,7 +118,7 @@ const getFreeResponseContractLoanUpdate = (
contract: FreeResponseContract | MultipleChoiceContract, contract: FreeResponseContract | MultipleChoiceContract,
bets: Bet[] bets: Bet[]
) => { ) => {
const openBets = bets.filter((bet) => bet.isSold || bet.sale) const openBets = bets.filter((bet) => !bet.isSold && !bet.sale)
return openBets.map((bet) => { return openBets.map((bet) => {
const loanAmount = bet.loanAmount ?? 0 const loanAmount = bet.loanAmount ?? 0

View File

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

View File

@ -15,6 +15,7 @@ export type Notification = {
sourceUserUsername?: string sourceUserUsername?: string
sourceUserAvatarUrl?: string sourceUserAvatarUrl?: string
sourceText?: string sourceText?: string
data?: string
sourceContractTitle?: string sourceContractTitle?: string
sourceContractCreatorUsername?: string sourceContractCreatorUsername?: string

View File

@ -13,7 +13,10 @@ export const getRedeemableAmount = (bets: RedeemableBet[]) => {
const yesShares = sumBy(yesBets, (b) => b.shares) const yesShares = sumBy(yesBets, (b) => b.shares)
const noShares = sumBy(noBets, (b) => b.shares) const noShares = sumBy(noBets, (b) => b.shares)
const shares = Math.max(Math.min(yesShares, noShares), 0) const shares = Math.max(Math.min(yesShares, noShares), 0)
const soldFrac = shares > 0 ? Math.min(yesShares, noShares) / shares : 0 const soldFrac =
shares > 0
? Math.min(yesShares, noShares) / Math.max(yesShares, noShares)
: 0
const loanAmount = sumBy(bets, (bet) => bet.loanAmount ?? 0) const loanAmount = sumBy(bets, (bet) => bet.loanAmount ?? 0)
const loanPayment = loanAmount * soldFrac const loanPayment = loanAmount * soldFrac
const netAmount = shares - loanPayment const netAmount = shares - loanPayment

View File

@ -35,7 +35,7 @@ export default Node.create<IframeOptions>({
HTMLAttributes: { HTMLAttributes: {
class: 'iframe-wrapper' + ' ' + wrapperClasses, class: 'iframe-wrapper' + ' ' + wrapperClasses,
// Tailwind JIT doesn't seem to pick up `pb-[20rem]`, so we hack this in: // Tailwind JIT doesn't seem to pick up `pb-[20rem]`, so we hack this in:
style: 'padding-bottom: 20rem;', style: 'padding-bottom: 20rem; ',
}, },
} }
}, },
@ -48,6 +48,9 @@ export default Node.create<IframeOptions>({
frameborder: { frameborder: {
default: 0, default: 0,
}, },
height: {
default: 0,
},
allowfullscreen: { allowfullscreen: {
default: this.options.allowFullscreen, default: this.options.allowFullscreen,
parseHTML: () => this.options.allowFullscreen, parseHTML: () => this.options.allowFullscreen,
@ -60,6 +63,11 @@ export default Node.create<IframeOptions>({
}, },
renderHTML({ HTMLAttributes }) { renderHTML({ HTMLAttributes }) {
this.options.HTMLAttributes.style =
this.options.HTMLAttributes.style +
' height: ' +
HTMLAttributes.height +
';'
return [ return [
'div', 'div',
this.options.HTMLAttributes, this.options.HTMLAttributes,

View File

@ -54,19 +54,33 @@ Returns the authenticated user.
Gets all groups, in no particular order. 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. Requires no authorization.
### `GET /v0/groups/[slug]` ### `GET /v0/group/[slug]`
Gets a group by its slug. Gets a group by its slug.
Requires no authorization. Requires no authorization.
Note: group is singular in the URL.
### `GET /v0/groups/by-id/[id]` ### `GET /v0/group/by-id/[id]`
Gets a group by its unique ID. Gets a group by its unique ID.
Requires no authorization. Requires no authorization.
Note: group is singular in the URL.
### `GET /v0/group/by-id/[id]/markets`
Gets a group's markets by its unique ID.
Requires no authorization.
Note: group is singular in the URL.
### `GET /v0/markets` ### `GET /v0/markets`

View File

@ -12,7 +12,9 @@ service cloud.firestore {
'taowell@gmail.com', 'taowell@gmail.com',
'abc.sinclair@gmail.com', 'abc.sinclair@gmail.com',
'manticmarkets@gmail.com', 'manticmarkets@gmail.com',
'iansphilips@gmail.com' 'iansphilips@gmail.com',
'd4vidchee@gmail.com',
'federicoruizcassarino@gmail.com'
] ]
} }
@ -160,25 +162,40 @@ service cloud.firestore {
.hasOnly(['isSeen', 'viewTime']); .hasOnly(['isSeen', 'viewTime']);
} }
match /groups/{groupId} { match /{somePath=**}/groupMembers/{memberId} {
allow read;
}
match /{somePath=**}/groupContracts/{contractId} {
allow read;
}
match /groups/{groupId} {
allow read; allow read;
allow update: if (request.auth.uid == resource.data.creatorId || isAdmin()) allow update: if (request.auth.uid == resource.data.creatorId || isAdmin())
&& request.resource.data.diff(resource.data) && request.resource.data.diff(resource.data)
.affectedKeys() .affectedKeys()
.hasOnly(['name', 'about', 'contractIds', 'memberIds', 'anyoneCanJoin', 'aboutPostId' ]); .hasOnly(['name', 'about', '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' ]);
allow delete: if request.auth.uid == resource.data.creatorId; allow delete: if request.auth.uid == resource.data.creatorId;
function isMember() { match /groupContracts/{contractId} {
return request.auth.uid in get(/databases/$(database)/documents/groups/$(groupId)).data.memberIds; 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} { match /comments/{commentId} {
allow read; 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} { match /posts/{postId} {
@ -188,6 +205,10 @@ service cloud.firestore {
.affectedKeys() .affectedKeys()
.hasOnly(['name', 'content']); .hasOnly(['name', 'content']);
allow delete: if isAdmin() || request.auth.uid == resource.data.creatorId; allow delete: if isAdmin() || request.auth.uid == resource.data.creatorId;
match /comments/{commentId} {
allow read;
allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) ;
}
} }
} }
} }

View File

@ -37,6 +37,45 @@ export const changeUser = async (
avatarUrl?: string avatarUrl?: string
} }
) => { ) => {
// Update contracts, comments, and answers outside of a transaction to avoid contention.
// Using bulkWriter to supports >500 writes at a time
const contractsRef = firestore
.collection('contracts')
.where('creatorId', '==', user.id)
const contracts = await contractsRef.get()
const contractUpdate: Partial<Contract> = removeUndefinedProps({
creatorName: update.name,
creatorUsername: update.username,
creatorAvatarUrl: update.avatarUrl,
})
const commentSnap = await firestore
.collectionGroup('comments')
.where('userUsername', '==', user.username)
.get()
const commentUpdate: Partial<Comment> = removeUndefinedProps({
userName: update.name,
userUsername: update.username,
userAvatarUrl: update.avatarUrl,
})
const answerSnap = await firestore
.collectionGroup('answers')
.where('username', '==', user.username)
.get()
const answerUpdate: Partial<Answer> = removeUndefinedProps(update)
const bulkWriter = firestore.bulkWriter()
commentSnap.docs.forEach((d) => bulkWriter.update(d.ref, commentUpdate))
answerSnap.docs.forEach((d) => bulkWriter.update(d.ref, answerUpdate))
contracts.docs.forEach((d) => bulkWriter.update(d.ref, contractUpdate))
await bulkWriter.flush()
console.log('Done writing!')
// Update the username inside a transaction
return await firestore.runTransaction(async (transaction) => { return await firestore.runTransaction(async (transaction) => {
if (update.username) { if (update.username) {
update.username = cleanUsername(update.username) update.username = cleanUsername(update.username)
@ -58,42 +97,7 @@ export const changeUser = async (
const userRef = firestore.collection('users').doc(user.id) const userRef = firestore.collection('users').doc(user.id)
const userUpdate: Partial<User> = removeUndefinedProps(update) const userUpdate: Partial<User> = removeUndefinedProps(update)
const contractsRef = firestore
.collection('contracts')
.where('creatorId', '==', user.id)
const contracts = await transaction.get(contractsRef)
const contractUpdate: Partial<Contract> = removeUndefinedProps({
creatorName: update.name,
creatorUsername: update.username,
creatorAvatarUrl: update.avatarUrl,
})
const commentSnap = await transaction.get(
firestore
.collectionGroup('comments')
.where('userUsername', '==', user.username)
)
const commentUpdate: Partial<Comment> = removeUndefinedProps({
userName: update.name,
userUsername: update.username,
userAvatarUrl: update.avatarUrl,
})
const answerSnap = await transaction.get(
firestore
.collectionGroup('answers')
.where('username', '==', user.username)
)
const answerUpdate: Partial<Answer> = removeUndefinedProps(update)
transaction.update(userRef, userUpdate) transaction.update(userRef, userUpdate)
commentSnap.docs.forEach((d) => transaction.update(d.ref, commentUpdate))
answerSnap.docs.forEach((d) => transaction.update(d.ref, answerUpdate))
contracts.docs.forEach((d) => transaction.update(d.ref, contractUpdate))
}) })
} }

View File

@ -58,13 +58,23 @@ export const creategroup = newEndpoint({}, async (req, auth) => {
createdTime: Date.now(), createdTime: Date.now(),
mostRecentActivityTime: Date.now(), mostRecentActivityTime: Date.now(),
// TODO: allow users to add contract ids on group creation // TODO: allow users to add contract ids on group creation
contractIds: [],
anyoneCanJoin, anyoneCanJoin,
memberIds, totalContracts: 0,
totalMembers: memberIds.length,
} }
await groupRef.create(group) 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 } return { status: 'success', group: group }
}) })

View File

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

View File

@ -151,15 +151,6 @@ export const createNotification = async (
} }
} }
const notifyContractCreatorOfUniqueBettorsBonus = async (
userToReasonTexts: user_to_reason_texts,
userId: string
) => {
userToReasonTexts[userId] = {
reason: 'unique_bettors_on_your_contract',
}
}
const userToReasonTexts: user_to_reason_texts = {} const userToReasonTexts: user_to_reason_texts = {}
// The following functions modify the userToReasonTexts object in place. // The following functions modify the userToReasonTexts object in place.
@ -192,16 +183,6 @@ export const createNotification = async (
sourceContract sourceContract
) { ) {
await notifyContractCreator(userToReasonTexts, sourceContract) await notifyContractCreator(userToReasonTexts, sourceContract)
} else if (
sourceType === 'bonus' &&
sourceUpdateType === 'created' &&
sourceContract
) {
// Note: the daily bonus won't have a contract attached to it
await notifyContractCreatorOfUniqueBettorsBonus(
userToReasonTexts,
sourceContract.creatorId
)
} }
await createUsersNotifications(userToReasonTexts) await createUsersNotifications(userToReasonTexts)
@ -737,3 +718,38 @@ export async function filterUserIdsForOnlyFollowerIds(
) )
return userIds.filter((id) => contractFollowersIds.includes(id)) return userIds.filter((id) => contractFollowersIds.includes(id))
} }
export const createUniqueBettorBonusNotification = async (
contractCreatorId: string,
bettor: User,
txnId: string,
contract: Contract,
amount: number,
idempotencyKey: string
) => {
const notificationRef = firestore
.collection(`/users/${contractCreatorId}/notifications`)
.doc(idempotencyKey)
const notification: Notification = {
id: idempotencyKey,
userId: contractCreatorId,
reason: 'unique_bettors_on_your_contract',
createdTime: Date.now(),
isSeen: false,
sourceId: txnId,
sourceType: 'bonus',
sourceUpdateType: 'created',
sourceUserName: bettor.name,
sourceUserUsername: bettor.username,
sourceUserAvatarUrl: bettor.avatarUrl,
sourceText: amount.toString(),
sourceSlug: contract.slug,
sourceTitle: contract.question,
// Perhaps not necessary, but just in case
sourceContractSlug: contract.slug,
sourceContractId: contract.id,
sourceContractTitle: contract.question,
sourceContractCreatorUsername: contract.creatorUsername,
}
return await notificationRef.set(removeUndefinedProps(notification))
}

View File

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

View File

@ -351,8 +351,7 @@
font-size: 16px; font-size: 16px;
margin: 0; margin: 0;
" /> " />
Resolve your market to earn {{creatorFee}} as the Please resolve your market.
creator commission.
<br style=" <br style="
font-family: 'Helvetica Neue', Helvetica, font-family: 'Helvetica Neue', Helvetica,
Arial, sans-serif; Arial, sans-serif;

View File

@ -4,7 +4,6 @@ import { Bet } from '../../common/bet'
import { getProbability } from '../../common/calculate' import { getProbability } from '../../common/calculate'
import { Comment } from '../../common/comment' import { Comment } from '../../common/comment'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { DPM_CREATOR_FEE } from '../../common/fees'
import { PrivateUser, User } from '../../common/user' import { PrivateUser, User } from '../../common/user'
import { import {
formatLargeNumber, formatLargeNumber,
@ -187,7 +186,7 @@ export const sendPersonalFollowupEmail = async (
const emailBody = `Hi ${firstName}, 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). 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).
@ -322,7 +321,7 @@ export const sendMarketCloseEmail = async (
const { username, name, id: userId } = user const { username, name, id: userId } = user
const firstName = name.split(' ')[0] const firstName = name.split(' ')[0]
const { question, slug, volume, mechanism, collectedFees } = contract const { question, slug, volume } = contract
const url = `https://${DOMAIN}/${username}/${slug}` const url = `https://${DOMAIN}/${username}/${slug}`
const emailType = 'market-resolve' const emailType = 'market-resolve'
@ -339,10 +338,6 @@ export const sendMarketCloseEmail = async (
userId, userId,
name: firstName, name: firstName,
volume: formatMoney(volume), volume: formatMoney(volume),
creatorFee:
mechanism === 'dpm-2'
? `${DPM_CREATOR_FEE * 100}% of the profits`
: formatMoney(collectedFees.creatorFee),
} }
) )
} }

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

View File

@ -7,7 +7,7 @@ import { getUser, getValues, isProd, log } from './utils'
import { import {
createBetFillNotification, createBetFillNotification,
createBettingStreakBonusNotification, createBettingStreakBonusNotification,
createNotification, createUniqueBettorBonusNotification,
} from './create-notification' } from './create-notification'
import { filterDefined } from '../../common/util/array' import { filterDefined } from '../../common/util/array'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
@ -54,11 +54,11 @@ export const onCreateBet = functions.firestore
log(`Could not find contract ${contractId}`) log(`Could not find contract ${contractId}`)
return return
} }
await updateUniqueBettorsAndGiveCreatorBonus(contract, eventId, bet.userId)
const bettor = await getUser(bet.userId) const bettor = await getUser(bet.userId)
if (!bettor) return if (!bettor) return
await updateUniqueBettorsAndGiveCreatorBonus(contract, eventId, bettor)
await notifyFills(bet, contract, eventId, bettor) await notifyFills(bet, contract, eventId, bettor)
await updateBettingStreak(bettor, bet, contract, eventId) await updateBettingStreak(bettor, bet, contract, eventId)
@ -126,7 +126,7 @@ const updateBettingStreak = async (
const updateUniqueBettorsAndGiveCreatorBonus = async ( const updateUniqueBettorsAndGiveCreatorBonus = async (
contract: Contract, contract: Contract,
eventId: string, eventId: string,
bettorId: string bettor: User
) => { ) => {
let previousUniqueBettorIds = contract.uniqueBettorIds let previousUniqueBettorIds = contract.uniqueBettorIds
@ -147,13 +147,13 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
) )
} }
const isNewUniqueBettor = !previousUniqueBettorIds.includes(bettorId) const isNewUniqueBettor = !previousUniqueBettorIds.includes(bettor.id)
const newUniqueBettorIds = uniq([...previousUniqueBettorIds, bettorId]) const newUniqueBettorIds = uniq([...previousUniqueBettorIds, bettor.id])
// Update contract unique bettors // Update contract unique bettors
if (!contract.uniqueBettorIds || isNewUniqueBettor) { if (!contract.uniqueBettorIds || isNewUniqueBettor) {
log(`Got ${previousUniqueBettorIds} unique bettors`) log(`Got ${previousUniqueBettorIds} unique bettors`)
isNewUniqueBettor && log(`And a new unique bettor ${bettorId}`) isNewUniqueBettor && log(`And a new unique bettor ${bettor.id}`)
await firestore.collection(`contracts`).doc(contract.id).update({ await firestore.collection(`contracts`).doc(contract.id).update({
uniqueBettorIds: newUniqueBettorIds, uniqueBettorIds: newUniqueBettorIds,
uniqueBettorCount: newUniqueBettorIds.length, uniqueBettorCount: newUniqueBettorIds.length,
@ -161,7 +161,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
} }
// No need to give a bonus for the creator's bet // No need to give a bonus for the creator's bet
if (!isNewUniqueBettor || bettorId == contract.creatorId) return if (!isNewUniqueBettor || bettor.id == contract.creatorId) return
// Create combined txn for all new unique bettors // Create combined txn for all new unique bettors
const bonusTxnDetails = { const bonusTxnDetails = {
@ -192,18 +192,13 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
log(`No bonus for user: ${contract.creatorId} - reason:`, result.status) log(`No bonus for user: ${contract.creatorId} - reason:`, result.status)
} else { } else {
log(`Bonus txn for user: ${contract.creatorId} completed:`, result.txn?.id) log(`Bonus txn for user: ${contract.creatorId} completed:`, result.txn?.id)
await createNotification( await createUniqueBettorBonusNotification(
contract.creatorId,
bettor,
result.txn.id, result.txn.id,
'bonus', contract,
'created', result.txn.amount,
fromUser, eventId + '-unique-bettor-bonus'
eventId + '-bonus',
result.txn.amount + '',
{
contract,
slug: contract.slug,
title: contract.question,
}
) )
} }
} }

View File

@ -63,11 +63,15 @@ export const onCreateCommentOnContract = functions
.doc(comment.betId) .doc(comment.betId)
.get() .get()
bet = betSnapshot.data() as Bet bet = betSnapshot.data() as Bet
answer = answer =
contract.outcomeType === 'FREE_RESPONSE' && contract.answers contract.outcomeType === 'FREE_RESPONSE' && contract.answers
? contract.answers.find((answer) => answer.id === bet?.outcome) ? contract.answers.find((answer) => answer.id === bet?.outcome)
: undefined : undefined
await change.ref.update({
betOutcome: bet.outcome,
betAmount: bet.amount,
})
} }
const comments = await getValues<ContractComment>( 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) if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime)
return 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 await firestore
.collection('groups') .collection('groups')
.doc(group.id) .doc(group.id)
.update({ mostRecentActivityTime: Date.now() }) .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[]) { export async function removeGroupLinks(group: Group, contractIds: string[]) {
for (const contractId of contractIds) { for (const contractId of contractIds) {
const contract = await getContract(contractId) const contract = await getContract(contractId)

View File

@ -135,7 +135,7 @@ export const placebet = newEndpoint({}, async (req, auth) => {
!isFinite(newP) || !isFinite(newP) ||
Math.min(...Object.values(newPool ?? {})) < CPMM_MIN_POOL_QTY) Math.min(...Object.values(newPool ?? {})) < CPMM_MIN_POOL_QTY)
) { ) {
throw new APIError(400, 'Bet too large for current liquidity pool.') throw new APIError(400, 'Trade too large for current liquidity pool.')
} }
const betDoc = contractDoc.collection('bets').doc() const betDoc = contractDoc.collection('bets').doc()

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 { initAdmin } from './script-init'
import { isProd, log } from '../utils' import { isProd, log } from '../utils'
import { getSlug } from '../create-group' 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 firestore = admin.firestore()
const results = await firestore const results = await firestore
.collection('contracts') .collection('contracts')
.where('lowercaseTags', 'array-contains', tag.toLowerCase()) .where('lowercaseTags', 'array-contains', tag.toLowerCase())
.get() .get()
return results.docs.map((d) => d.id) return results.docs.map((d) => d.data() as Contract)
} }
const createGroup = async ( const createGroup = async (
name: string, name: string,
about: string, about: string,
contractIds: string[] contracts: Contract[]
) => { ) => {
const firestore = admin.firestore() const firestore = admin.firestore()
const creatorId = isProd() const creatorId = isProd()
@ -36,21 +38,60 @@ const createGroup = async (
about, about,
createdTime: now, createdTime: now,
mostRecentActivityTime: now, mostRecentActivityTime: now,
contractIds: contractIds,
anyoneCanJoin: true, 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) => { const convertTagToGroup = async (tag: string, groupName: string) => {
log(`Looking up contract IDs with tag ${tag}...`) log(`Looking up contract IDs with tag ${tag}...`)
const contractIds = await getTaggedContractIds(tag) const contracts = await getTaggedContracts(tag)
log(`${contractIds.length} contracts found.`) log(`${contracts.length} contracts found.`)
if (contractIds.length > 0) { if (contracts.length > 0) {
log(`Creating group ${groupName}...`) log(`Creating group ${groupName}...`)
const about = `Contracts that used to be tagged ${tag}.` 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) 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 { unsubscribe } from './unsubscribe'
import { stripewebhook, createcheckoutsession } from './stripe' import { stripewebhook, createcheckoutsession } from './stripe'
import { getcurrentuser } from './get-current-user' import { getcurrentuser } from './get-current-user'
import { getcustomtoken } from './get-custom-token'
import { createpost } from './create-post' import { createpost } from './create-post'
type Middleware = (req: Request, res: Response, next: NextFunction) => void type Middleware = (req: Request, res: Response, next: NextFunction) => void
@ -66,7 +65,6 @@ addJsonEndpointRoute('/resolvemarket', resolvemarket)
addJsonEndpointRoute('/unsubscribe', unsubscribe) addJsonEndpointRoute('/unsubscribe', unsubscribe)
addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession) addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession)
addJsonEndpointRoute('/getcurrentuser', getcurrentuser) addJsonEndpointRoute('/getcurrentuser', getcurrentuser)
addEndpointRoute('/getcustomtoken', getcustomtoken)
addEndpointRoute('/stripewebhook', stripewebhook, express.raw()) addEndpointRoute('/stripewebhook', stripewebhook, express.raw())
addEndpointRoute('/createpost', createpost) addEndpointRoute('/createpost', createpost)

View File

@ -1,43 +1,29 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' 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 { getValues, log, logMemory, writeAsync } from './utils'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
import { Contract } from '../../common/contract' import { Contract, CPMM } from '../../common/contract'
import { PortfolioMetrics, User } from '../../common/user' import { PortfolioMetrics, User } from '../../common/user'
import { calculatePayout } from '../../common/calculate'
import { DAY_MS } from '../../common/util/time' import { DAY_MS } from '../../common/util/time'
import { last } from 'lodash'
import { getLoanUpdates } from '../../common/loans' 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 firestore = admin.firestore()
const computeInvestmentValue = ( export const updateMetrics = functions
bets: Bet[], .runWith({ memory: '2GB', timeoutSeconds: 540 })
contractsDict: { [k: string]: Contract } .pubsub.schedule('every 15 minutes')
) => { .onRun(updateMetricsCore)
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') export async function updateMetricsCore() {
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 () => {
const [users, contracts, bets, allPortfolioHistories] = await Promise.all([ const [users, contracts, bets, allPortfolioHistories] = await Promise.all([
getValues<User>(firestore.collection('users')), getValues<User>(firestore.collection('users')),
getValues<Contract>(firestore.collection('contracts')), getValues<Contract>(firestore.collection('contracts')),
@ -59,11 +45,29 @@ export const updateMetricsCore = async () => {
.filter((contract) => contract.id) .filter((contract) => contract.id)
.map((contract) => { .map((contract) => {
const contractBets = betsByContract[contract.id] ?? [] 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 { return {
doc: firestore.collection('contracts').doc(contract.id), doc: firestore.collection('contracts').doc(contract.id),
fields: { fields: {
volume24Hours: computeVolume(contractBets, now - DAY_MS), volume24Hours: computeVolume(contractBets, now - DAY_MS),
volume7Days: computeVolume(contractBets, now - DAY_MS * 7), volume7Days: computeVolume(contractBets, now - DAY_MS * 7),
...cpmmFields,
}, },
} }
}) })
@ -88,23 +92,20 @@ export const updateMetricsCore = async () => {
currentBets currentBets
) )
const lastPortfolio = last(portfolioHistory) const lastPortfolio = last(portfolioHistory)
const didProfitChange = const didPortfolioChange =
lastPortfolio === undefined || lastPortfolio === undefined ||
lastPortfolio.balance !== newPortfolio.balance || lastPortfolio.balance !== newPortfolio.balance ||
lastPortfolio.totalDeposits !== newPortfolio.totalDeposits || lastPortfolio.totalDeposits !== newPortfolio.totalDeposits ||
lastPortfolio.investmentValue !== newPortfolio.investmentValue lastPortfolio.investmentValue !== newPortfolio.investmentValue
const newProfit = calculateNewProfit( const newProfit = calculateNewProfit(portfolioHistory, newPortfolio)
portfolioHistory,
newPortfolio,
didProfitChange
)
return { return {
user, user,
newCreatorVolume, newCreatorVolume,
newPortfolio, newPortfolio,
newProfit, newProfit,
didProfitChange, didPortfolioChange,
} }
}) })
@ -120,16 +121,20 @@ export const updateMetricsCore = async () => {
const nextLoanByUser = keyBy(userPayouts, (payout) => payout.user.id) const nextLoanByUser = keyBy(userPayouts, (payout) => payout.user.id)
const userUpdates = userMetrics.map( const userUpdates = userMetrics.map(
({ user, newCreatorVolume, newPortfolio, newProfit, didProfitChange }) => { ({
user,
newCreatorVolume,
newPortfolio,
newProfit,
didPortfolioChange,
}) => {
const nextLoanCached = nextLoanByUser[user.id]?.payout ?? 0 const nextLoanCached = nextLoanByUser[user.id]?.payout ?? 0
return { return {
fieldUpdates: { fieldUpdates: {
doc: firestore.collection('users').doc(user.id), doc: firestore.collection('users').doc(user.id),
fields: { fields: {
creatorVolumeCached: newCreatorVolume, creatorVolumeCached: newCreatorVolume,
...(didProfitChange && { profitCached: newProfit,
profitCached: newProfit,
}),
nextLoanCached, nextLoanCached,
}, },
}, },
@ -140,11 +145,7 @@ export const updateMetricsCore = async () => {
.doc(user.id) .doc(user.id)
.collection('portfolioHistory') .collection('portfolioHistory')
.doc(), .doc(),
fields: { fields: didPortfolioChange ? newPortfolio : {},
...(didProfitChange && {
...newPortfolio,
}),
},
}, },
} }
} }
@ -162,108 +163,3 @@ export const updateMetricsCore = async () => {
) )
log(`Updated metrics for ${users.length} users.`) 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

@ -1,85 +1,5 @@
import { sanitizeHtml } from './sanitizer'
import { ParsedRequest } from './types' import { ParsedRequest } from './types'
import { getTemplateCss } from './template-css'
function getCss(theme: string, fontSize: string) {
let background = 'white'
let foreground = 'black'
let radial = 'lightgray'
if (theme === 'dark') {
background = 'black'
foreground = 'white'
radial = 'dimgray'
}
// To use Readex Pro: `font-family: 'Readex Pro', sans-serif;`
return `
@import url('https://fonts.googleapis.com/css2?family=Major+Mono+Display&family=Readex+Pro:wght@400;700&display=swap');
body {
background: ${background};
background-image: radial-gradient(circle at 25px 25px, ${radial} 2%, transparent 0%), radial-gradient(circle at 75px 75px, ${radial} 2%, transparent 0%);
background-size: 100px 100px;
height: 100vh;
font-family: "Readex Pro", sans-serif;
}
code {
color: #D400FF;
font-family: 'Vera';
white-space: pre-wrap;
letter-spacing: -5px;
}
code:before, code:after {
content: '\`';
}
.logo-wrapper {
display: flex;
align-items: center;
align-content: center;
justify-content: center;
justify-items: center;
}
.logo {
margin: 0 75px;
}
.plus {
color: #BBB;
font-family: Times New Roman, Verdana;
font-size: 100px;
}
.spacer {
margin: 150px;
}
.emoji {
height: 1em;
width: 1em;
margin: 0 .05em 0 .1em;
vertical-align: -0.1em;
}
.heading {
font-family: 'Major Mono Display', monospace;
font-size: ${sanitizeHtml(fontSize)};
font-style: normal;
color: ${foreground};
line-height: 1.8;
}
.font-major-mono {
font-family: "Major Mono Display", monospace;
}
.text-primary {
color: #11b981;
}
`
}
export function getChallengeHtml(parsedReq: ParsedRequest) { export function getChallengeHtml(parsedReq: ParsedRequest) {
const { const {
@ -112,7 +32,7 @@ export function getChallengeHtml(parsedReq: ParsedRequest) {
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
</head> </head>
<style> <style>
${getCss(theme, fontSize)} ${getTemplateCss(theme, fontSize)}
</style> </style>
<body> <body>
<div class="px-24"> <div class="px-24">

View File

@ -21,6 +21,7 @@ export function parseRequest(req: IncomingMessage) {
creatorName, creatorName,
creatorUsername, creatorUsername,
creatorAvatarUrl, creatorAvatarUrl,
resolution,
// Challenge attributes: // Challenge attributes:
challengerAmount, challengerAmount,
@ -71,6 +72,7 @@ export function parseRequest(req: IncomingMessage) {
question: question:
getString(question) || 'Will you create a prediction market on Manifold?', getString(question) || 'Will you create a prediction market on Manifold?',
resolution: getString(resolution),
probability: getString(probability), probability: getString(probability),
numericValue: getString(numericValue) || '', numericValue: getString(numericValue) || '',
metadata: getString(metadata) || 'Jan 1 &nbsp;•&nbsp; M$ 123 pool', metadata: getString(metadata) || 'Jan 1 &nbsp;•&nbsp; M$ 123 pool',

View File

@ -0,0 +1,81 @@
import { sanitizeHtml } from './sanitizer'
export function getTemplateCss(theme: string, fontSize: string) {
let background = 'white'
let foreground = 'black'
let radial = 'lightgray'
if (theme === 'dark') {
background = 'black'
foreground = 'white'
radial = 'dimgray'
}
// To use Readex Pro: `font-family: 'Readex Pro', sans-serif;`
return `
@import url('https://fonts.googleapis.com/css2?family=Major+Mono+Display&family=Readex+Pro:wght@400;700&display=swap');
body {
background: ${background};
background-image: radial-gradient(circle at 25px 25px, ${radial} 2%, transparent 0%), radial-gradient(circle at 75px 75px, ${radial} 2%, transparent 0%);
background-size: 100px 100px;
height: 100vh;
font-family: "Readex Pro", sans-serif;
}
code {
color: #D400FF;
font-family: 'Vera';
white-space: pre-wrap;
letter-spacing: -5px;
}
code:before, code:after {
content: '\`';
}
.logo-wrapper {
display: flex;
align-items: center;
align-content: center;
justify-content: center;
justify-items: center;
}
.logo {
margin: 0 75px;
}
.plus {
color: #BBB;
font-family: Times New Roman, Verdana;
font-size: 100px;
}
.spacer {
margin: 150px;
}
.emoji {
height: 1em;
width: 1em;
margin: 0 .05em 0 .1em;
vertical-align: -0.1em;
}
.heading {
font-family: 'Major Mono Display', monospace;
font-size: ${sanitizeHtml(fontSize)};
font-style: normal;
color: ${foreground};
line-height: 1.8;
}
.font-major-mono {
font-family: "Major Mono Display", monospace;
}
.text-primary {
color: #11b981;
}
`
}

View File

@ -1,85 +1,5 @@
import { sanitizeHtml } from './sanitizer'
import { ParsedRequest } from './types' import { ParsedRequest } from './types'
import { getTemplateCss } from './template-css'
function getCss(theme: string, fontSize: string) {
let background = 'white'
let foreground = 'black'
let radial = 'lightgray'
if (theme === 'dark') {
background = 'black'
foreground = 'white'
radial = 'dimgray'
}
// To use Readex Pro: `font-family: 'Readex Pro', sans-serif;`
return `
@import url('https://fonts.googleapis.com/css2?family=Major+Mono+Display&family=Readex+Pro:wght@400;700&display=swap');
body {
background: ${background};
background-image: radial-gradient(circle at 25px 25px, ${radial} 2%, transparent 0%), radial-gradient(circle at 75px 75px, ${radial} 2%, transparent 0%);
background-size: 100px 100px;
height: 100vh;
font-family: "Readex Pro", sans-serif;
}
code {
color: #D400FF;
font-family: 'Vera';
white-space: pre-wrap;
letter-spacing: -5px;
}
code:before, code:after {
content: '\`';
}
.logo-wrapper {
display: flex;
align-items: center;
align-content: center;
justify-content: center;
justify-items: center;
}
.logo {
margin: 0 75px;
}
.plus {
color: #BBB;
font-family: Times New Roman, Verdana;
font-size: 100px;
}
.spacer {
margin: 150px;
}
.emoji {
height: 1em;
width: 1em;
margin: 0 .05em 0 .1em;
vertical-align: -0.1em;
}
.heading {
font-family: 'Major Mono Display', monospace;
font-size: ${sanitizeHtml(fontSize)};
font-style: normal;
color: ${foreground};
line-height: 1.8;
}
.font-major-mono {
font-family: "Major Mono Display", monospace;
}
.text-primary {
color: #11b981;
}
`
}
export function getHtml(parsedReq: ParsedRequest) { export function getHtml(parsedReq: ParsedRequest) {
const { const {
@ -92,6 +12,7 @@ export function getHtml(parsedReq: ParsedRequest) {
creatorUsername, creatorUsername,
creatorAvatarUrl, creatorAvatarUrl,
numericValue, numericValue,
resolution,
} = parsedReq } = parsedReq
const MAX_QUESTION_CHARS = 100 const MAX_QUESTION_CHARS = 100
const truncatedQuestion = const truncatedQuestion =
@ -99,6 +20,49 @@ export function getHtml(parsedReq: ParsedRequest) {
? question.slice(0, MAX_QUESTION_CHARS) + '...' ? question.slice(0, MAX_QUESTION_CHARS) + '...'
: question : question
const hideAvatar = creatorAvatarUrl ? '' : 'hidden' const hideAvatar = creatorAvatarUrl ? '' : 'hidden'
let resolutionColor = 'text-primary'
let resolutionString = 'YES'
switch (resolution) {
case 'YES':
break
case 'NO':
resolutionColor = 'text-red-500'
resolutionString = 'NO'
break
case 'CANCEL':
resolutionColor = 'text-yellow-500'
resolutionString = 'N/A'
break
case 'MKT':
resolutionColor = 'text-blue-500'
resolutionString = numericValue ? numericValue : probability
break
}
const resolutionDiv = `
<span class='text-center ${resolutionColor}'>
<div class="text-8xl">
${resolutionString}
</div>
<div class="text-4xl">${
resolution === 'CANCEL' ? '' : 'resolved'
}</div>
</span>`
const probabilityDiv = `
<span class='text-primary text-center'>
<div class="text-8xl">${probability}</div>
<div class="text-4xl">chance</div>
</span>`
const numericValueDiv = `
<span class='text-blue-500 text-center'>
<div class="text-8xl ">${numericValue}</div>
<div class="text-4xl">expected</div>
</span>
`
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html> <html>
<head> <head>
@ -108,7 +72,7 @@ export function getHtml(parsedReq: ParsedRequest) {
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
</head> </head>
<style> <style>
${getCss(theme, fontSize)} ${getTemplateCss(theme, fontSize)}
</style> </style>
<body> <body>
<div class="px-24"> <div class="px-24">
@ -148,21 +112,22 @@ export function getHtml(parsedReq: ParsedRequest) {
<div class="text-indigo-700 text-6xl leading-tight"> <div class="text-indigo-700 text-6xl leading-tight">
${truncatedQuestion} ${truncatedQuestion}
</div> </div>
<div class="flex flex-col text-primary"> <div class="flex flex-col">
<div class="text-8xl">${probability}</div> ${
<div class="text-4xl">${probability !== '' ? 'chance' : ''}</div> resolution
<span class='text-blue-500 text-center'> ? resolutionDiv
<div class="text-8xl ">${ : numericValue
numericValue !== '' && probability === '' ? numericValue : '' ? numericValueDiv
}</div> : probability
<div class="text-4xl">${numericValue !== '' ? 'expected' : ''}</div> ? probabilityDiv
</span> : ''
}
</div> </div>
</div> </div>
<!-- Metadata --> <!-- Metadata -->
<div class="absolute bottom-16"> <div class="absolute bottom-16">
<div class="text-gray-500 text-3xl"> <div class="text-gray-500 text-3xl max-w-[80vw]">
${metadata} ${metadata}
</div> </div>
</div> </div>

View File

@ -19,6 +19,7 @@ export interface ParsedRequest {
creatorName: string creatorName: string
creatorUsername: string creatorUsername: string
creatorAvatarUrl: string creatorAvatarUrl: string
resolution: string
// Challenge attributes: // Challenge attributes:
challengerAmount: string challengerAmount: string
challengerOutcome: string challengerOutcome: string

View File

@ -84,6 +84,7 @@ export function BuyAmountInput(props: {
setError: (error: string | undefined) => void setError: (error: string | undefined) => void
minimumAmount?: number minimumAmount?: number
disabled?: boolean disabled?: boolean
showSliderOnMobile?: boolean
className?: string className?: string
inputClassName?: string inputClassName?: string
// Needed to focus the amount input // Needed to focus the amount input
@ -94,6 +95,7 @@ export function BuyAmountInput(props: {
onChange, onChange,
error, error,
setError, setError,
showSliderOnMobile: showSlider,
disabled, disabled,
className, className,
inputClassName, inputClassName,
@ -120,16 +122,41 @@ export function BuyAmountInput(props: {
} }
} }
const parseRaw = (x: number) => {
if (x <= 100) return x
if (x <= 130) return 100 + (x - 100) * 5
return 250 + (x - 130) * 10
}
const getRaw = (x: number) => {
if (x <= 100) return x
if (x <= 250) return 100 + (x - 100) / 5
return 130 + (x - 250) / 10
}
return ( return (
<AmountInput <>
amount={amount} <AmountInput
onChange={onAmountChange} amount={amount}
label={ENV_CONFIG.moneyMoniker} onChange={onAmountChange}
error={error} label={ENV_CONFIG.moneyMoniker}
disabled={disabled} error={error}
className={className} disabled={disabled}
inputClassName={inputClassName} className={className}
inputRef={inputRef} inputClassName={inputClassName}
/> inputRef={inputRef}
/>
{showSlider && (
<input
type="range"
min="0"
max="205"
value={getRaw(amount ?? 0)}
onChange={(e) => onAmountChange(parseRaw(parseInt(e.target.value)))}
className="range range-lg only-thumb z-40 mb-2 xl:hidden"
step="5"
/>
)}
</>
) )
} }

View File

@ -1,5 +1,5 @@
import clsx from 'clsx' import clsx from 'clsx'
import { useEffect, useRef, useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import { XIcon } from '@heroicons/react/solid' import { XIcon } from '@heroicons/react/solid'
import { Answer } from 'common/answer' import { Answer } from 'common/answer'
@ -26,7 +26,7 @@ import { Bet } from 'common/bet'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { BetSignUpPrompt } from '../sign-up-prompt' import { BetSignUpPrompt } from '../sign-up-prompt'
import { isIOS } from 'web/lib/util/device' import { isIOS } from 'web/lib/util/device'
import { AlertBox } from '../alert-box' import { WarningConfirmationButton } from '../warning-confirmation-button'
export function AnswerBetPanel(props: { export function AnswerBetPanel(props: {
answer: Answer answer: Answer
@ -116,11 +116,20 @@ export function AnswerBetPanel(props: {
const bankrollFraction = (betAmount ?? 0) / (user?.balance ?? 1e9) const bankrollFraction = (betAmount ?? 0) / (user?.balance ?? 1e9)
const warning =
(betAmount ?? 0) > 10 && bankrollFraction >= 0.5 && bankrollFraction <= 1
? `You might not want to spend ${formatPercent(
bankrollFraction
)} of your balance on a single bet. \n\nCurrent balance: ${formatMoney(
user?.balance ?? 0
)}`
: undefined
return ( return (
<Col className={clsx('px-2 pb-2 pt-4 sm:pt-0', className)}> <Col className={clsx('px-2 pb-2 pt-4 sm:pt-0', className)}>
<Row className="items-center justify-between self-stretch"> <Row className="items-center justify-between self-stretch">
<div className="text-xl"> <div className="text-xl">
Bet on {isModal ? `"${answer.text}"` : 'this answer'} Buy answer: {isModal ? `"${answer.text}"` : 'this answer'}
</div> </div>
{!isModal && ( {!isModal && (
@ -132,7 +141,11 @@ export function AnswerBetPanel(props: {
</button> </button>
)} )}
</Row> </Row>
<div className="my-3 text-left text-sm text-gray-500">Amount </div> <Row className="my-3 justify-between text-left text-sm text-gray-500">
Amount
<span>Balance: {formatMoney(user?.balance ?? 0)}</span>
</Row>
<BuyAmountInput <BuyAmountInput
inputClassName="w-full max-w-none" inputClassName="w-full max-w-none"
amount={betAmount} amount={betAmount}
@ -141,23 +154,9 @@ export function AnswerBetPanel(props: {
setError={setError} setError={setError}
disabled={isSubmitting} disabled={isSubmitting}
inputRef={inputRef} 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
)}`}
/>
) : (
''
)}
<Col className="mt-3 w-full gap-3"> <Col className="mt-3 w-full gap-3">
<Row className="items-center justify-between text-sm"> <Row className="items-center justify-between text-sm">
<div className="text-gray-500">Probability</div> <div className="text-gray-500">Probability</div>
@ -193,16 +192,17 @@ export function AnswerBetPanel(props: {
<Spacer h={6} /> <Spacer h={6} />
{user ? ( {user ? (
<button <WarningConfirmationButton
className={clsx( warning={warning}
onSubmit={submitBet}
isSubmitting={isSubmitting}
disabled={!!betDisabled}
openModalButtonClass={clsx(
'btn self-stretch', 'btn self-stretch',
betDisabled ? 'btn-disabled' : 'btn-primary', betDisabled ? 'btn-disabled' : 'btn-primary',
isSubmitting ? 'loading' : '' isSubmitting ? 'loading' : ''
)} )}
onClick={betDisabled ? undefined : submitBet} />
>
{isSubmitting ? 'Submitting...' : 'Submit trade'}
</button>
) : ( ) : (
<BetSignUpPrompt /> <BetSignUpPrompt />
)} )}

View File

@ -18,19 +18,20 @@ export const AnswersGraph = memo(function AnswersGraph(props: {
}) { }) {
const { contract, bets, height } = props const { contract, bets, height } = props
const { createdTime, resolutionTime, closeTime, answers } = contract const { createdTime, resolutionTime, closeTime, answers } = contract
const now = Date.now()
const { probsByOutcome, sortedOutcomes } = computeProbsByOutcome( const { probsByOutcome, sortedOutcomes } = computeProbsByOutcome(
bets, bets,
contract contract
) )
const isClosed = !!closeTime && Date.now() > closeTime const isClosed = !!closeTime && now > closeTime
const latestTime = dayjs( const latestTime = dayjs(
resolutionTime && isClosed resolutionTime && isClosed
? Math.min(resolutionTime, closeTime) ? Math.min(resolutionTime, closeTime)
: isClosed : isClosed
? closeTime ? closeTime
: resolutionTime ?? Date.now() : resolutionTime ?? now
) )
const { width } = useWindowSize() const { width } = useWindowSize()
@ -71,14 +72,14 @@ export const AnswersGraph = memo(function AnswersGraph(props: {
const yTickValues = [0, 25, 50, 75, 100] const yTickValues = [0, 25, 50, 75, 100]
const numXTickValues = isLargeWidth ? 5 : 2 const numXTickValues = isLargeWidth ? 5 : 2
const startDate = new Date(contract.createdTime) const startDate = dayjs(contract.createdTime)
const endDate = dayjs(startDate).add(1, 'hour').isAfter(latestTime) const endDate = startDate.add(1, 'hour').isAfter(latestTime)
? latestTime.add(1, 'hours').toDate() ? latestTime.add(1, 'hours')
: latestTime.toDate() : latestTime
const includeMinute = dayjs(endDate).diff(startDate, 'hours') < 2 const includeMinute = endDate.diff(startDate, 'hours') < 2
const multiYear = !dayjs(startDate).isSame(latestTime, 'year') const multiYear = !startDate.isSame(latestTime, 'year')
const lessThanAWeek = dayjs(startDate).add(1, 'week').isAfter(latestTime) const lessThanAWeek = startDate.add(1, 'week').isAfter(latestTime)
return ( return (
<div <div
@ -96,16 +97,16 @@ export const AnswersGraph = memo(function AnswersGraph(props: {
}} }}
xScale={{ xScale={{
type: 'time', type: 'time',
min: startDate, min: startDate.toDate(),
max: endDate, max: endDate.toDate(),
}} }}
xFormat={(d) => xFormat={(d) =>
formatTime(+d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek) formatTime(now, +d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek)
} }
axisBottom={{ axisBottom={{
tickValues: numXTickValues, tickValues: numXTickValues,
format: (time) => format: (time) =>
formatTime(+time, multiYear, lessThanAWeek, includeMinute), formatTime(now, +time, multiYear, lessThanAWeek, includeMinute),
}} }}
colors={[ colors={[
'#fca5a5', // red-300 '#fca5a5', // red-300
@ -158,23 +159,20 @@ function formatPercent(y: DatumValue) {
} }
function formatTime( function formatTime(
now: number,
time: number, time: number,
includeYear: boolean, includeYear: boolean,
includeHour: boolean, includeHour: boolean,
includeMinute: boolean includeMinute: boolean
) { ) {
const d = dayjs(time) const d = dayjs(time)
if (d.add(1, 'minute').isAfter(now) && d.subtract(1, 'minute').isBefore(now))
if (
d.add(1, 'minute').isAfter(Date.now()) &&
d.subtract(1, 'minute').isBefore(Date.now())
)
return 'Now' return 'Now'
let format: string let format: string
if (d.isSame(Date.now(), 'day')) { if (d.isSame(now, 'day')) {
format = '[Today]' format = '[Today]'
} else if (d.add(1, 'day').isSame(Date.now(), 'day')) { } else if (d.add(1, 'day').isSame(now, 'day')) {
format = '[Yesterday]' format = '[Yesterday]'
} else { } else {
format = 'MMM D' format = 'MMM D'

View File

@ -1,5 +1,5 @@
import clsx from 'clsx' import clsx from 'clsx'
import { useState } from 'react' import React, { useState } from 'react'
import Textarea from 'react-expanding-textarea' import Textarea from 'react-expanding-textarea'
import { findBestMatch } from 'string-similarity' import { findBestMatch } from 'string-similarity'
@ -120,7 +120,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
return ( return (
<Col className="gap-4 rounded"> <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> <div className="mb-1">Add your answer</div>
<Textarea <Textarea
value={text} value={text}
@ -149,7 +149,12 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
{text && ( {text && (
<> <>
<Col className="mt-1 gap-2"> <Col className="mt-1 gap-2">
<div className="text-sm text-gray-500">Bet amount</div> <Row className="my-3 justify-between text-left text-sm text-gray-500">
Bet Amount
<span className={'sm:hidden'}>
Balance: {formatMoney(user?.balance ?? 0)}
</span>
</Row>{' '}
<BuyAmountInput <BuyAmountInput
amount={betAmount} amount={betAmount}
onChange={setBetAmount} onChange={setBetAmount}
@ -157,6 +162,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
setError={setAmountError} setError={setAmountError}
minimumAmount={1} minimumAmount={1}
disabled={isSubmitting} disabled={isSubmitting}
showSliderOnMobile
/> />
</Col> </Col>
<Col className="gap-3"> <Col className="gap-3">
@ -200,7 +206,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
disabled={!canSubmit} disabled={!canSubmit}
onClick={withTracking(submitAnswer, 'submit answer')} onClick={withTracking(submitAnswer, 'submit answer')}
> >
Submit answer & buy Submit
</button> </button>
) : ( ) : (
text && ( text && (

View File

@ -12,7 +12,7 @@ import { User } from 'common/user'
import { Group } from 'common/group' import { Group } from 'common/group'
export function ArrangeHome(props: { export function ArrangeHome(props: {
user: User | null user: User | null | undefined
homeSections: { visible: string[]; hidden: string[] } homeSections: { visible: string[]; hidden: string[] }
setHomeSections: (homeSections: { setHomeSections: (homeSections: {
visible: string[] visible: string[]
@ -30,7 +30,6 @@ export function ArrangeHome(props: {
return ( return (
<DragDropContext <DragDropContext
onDragEnd={(e) => { onDragEnd={(e) => {
console.log('drag end', e)
const { destination, source, draggableId } = e const { destination, source, draggableId } = e
if (!destination) return if (!destination) return
@ -112,7 +111,7 @@ export const getHomeItems = (
{ label: 'Trending', id: 'score' }, { label: 'Trending', id: 'score' },
{ label: 'Newest', id: 'newest' }, { label: 'Newest', id: 'newest' },
{ label: 'Close date', id: 'close-date' }, { label: 'Close date', id: 'close-date' },
{ label: 'Your bets', id: 'your-bets' }, { label: 'Your trades', id: 'your-bets' },
...groups.map((g) => ({ ...groups.map((g) => ({
label: g.name, label: g.name,
id: g.id, id: g.id,

View File

@ -8,17 +8,20 @@ import {
getUserAndPrivateUser, getUserAndPrivateUser,
setCachedReferralInfoForUser, setCachedReferralInfoForUser,
} from 'web/lib/firebase/users' } from 'web/lib/firebase/users'
import { deleteTokenCookies, setTokenCookies } from 'web/lib/firebase/auth'
import { createUser } from 'web/lib/firebase/api' import { createUser } from 'web/lib/firebase/api'
import { randomString } from 'common/util/random' import { randomString } from 'common/util/random'
import { identifyUser, setUserProperty } from 'web/lib/service/analytics' import { identifyUser, setUserProperty } from 'web/lib/service/analytics'
import { useStateCheckEquality } from 'web/hooks/use-state-check-equality' import { useStateCheckEquality } from 'web/hooks/use-state-check-equality'
import { AUTH_COOKIE_NAME } from 'common/envs/constants'
import { setCookie } from 'web/lib/util/cookie'
// Either we haven't looked up the logged in user yet (undefined), or we know // Either we haven't looked up the logged in user yet (undefined), or we know
// the user is not logged in (null), or we know the user is logged in. // the user is not logged in (null), or we know the user is logged in.
type AuthUser = undefined | null | UserAndPrivateUser type AuthUser = undefined | null | UserAndPrivateUser
const TEN_YEARS_SECS = 60 * 60 * 24 * 365 * 10
const CACHED_USER_KEY = 'CACHED_USER_KEY_V2' const CACHED_USER_KEY = 'CACHED_USER_KEY_V2'
// Proxy localStorage in case it's not available (eg in incognito iframe) // Proxy localStorage in case it's not available (eg in incognito iframe)
const localStorage = const localStorage =
typeof window !== 'undefined' typeof window !== 'undefined'
@ -38,6 +41,16 @@ const ensureDeviceToken = () => {
return deviceToken return deviceToken
} }
export const setUserCookie = (cookie: string | undefined) => {
const data = setCookie(AUTH_COOKIE_NAME, cookie ?? '', [
['path', '/'],
['max-age', (cookie === undefined ? 0 : TEN_YEARS_SECS).toString()],
['samesite', 'lax'],
['secure'],
])
document.cookie = data
}
export const AuthContext = createContext<AuthUser>(undefined) export const AuthContext = createContext<AuthUser>(undefined)
export function AuthProvider(props: { export function AuthProvider(props: {
@ -54,30 +67,33 @@ export function AuthProvider(props: {
} }
}, [setAuthUser, serverUser]) }, [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(() => { useEffect(() => {
return onIdTokenChanged( return onIdTokenChanged(
auth, auth,
async (fbUser) => { async (fbUser) => {
if (fbUser) { if (fbUser) {
setTokenCookies({ setUserCookie(JSON.stringify(fbUser.toJSON()))
id: await fbUser.getIdToken(),
refresh: fbUser.refreshToken,
})
let current = await getUserAndPrivateUser(fbUser.uid) let current = await getUserAndPrivateUser(fbUser.uid)
if (!current.user || !current.privateUser) { if (!current.user || !current.privateUser) {
const deviceToken = ensureDeviceToken() const deviceToken = ensureDeviceToken()
current = (await createUser({ deviceToken })) as UserAndPrivateUser current = (await createUser({ deviceToken })) as UserAndPrivateUser
setCachedReferralInfoForUser(current.user)
} }
setAuthUser(current) 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 { } else {
// User logged out; reset to null // User logged out; reset to null
deleteTokenCookies() setUserCookie(undefined)
setAuthUser(null) setAuthUser(null)
localStorage.removeItem(CACHED_USER_KEY)
} }
}, },
(e) => { (e) => {
@ -87,29 +103,32 @@ export function AuthProvider(props: {
}, [setAuthUser]) }, [setAuthUser])
const uid = authUser?.user.id const uid = authUser?.user.id
const username = authUser?.user.username
useEffect(() => { useEffect(() => {
if (uid && username) { if (uid) {
identifyUser(uid) identifyUser(uid)
setUserProperty('username', username) const userListener = listenForUser(uid, (user) => {
const userListener = listenForUser(uid, (user) => setAuthUser((currAuthUser) =>
setAuthUser((authUser) => { currAuthUser && user ? { ...currAuthUser, user } : null
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */ )
return { ...authUser!, user: user! } })
})
)
const privateUserListener = listenForPrivateUser(uid, (privateUser) => { const privateUserListener = listenForPrivateUser(uid, (privateUser) => {
setAuthUser((authUser) => { setAuthUser((currAuthUser) =>
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */ currAuthUser && privateUser ? { ...currAuthUser, privateUser } : null
return { ...authUser!, privateUser: privateUser! } )
})
}) })
return () => { return () => {
userListener() userListener()
privateUserListener() privateUserListener()
} }
} }
}, [uid, username, setAuthUser]) }, [uid, setAuthUser])
const username = authUser?.user.username
useEffect(() => {
if (username != null) {
setUserProperty('username', username)
}
}, [username])
return ( return (
<AuthContext.Provider value={authUser}>{children}</AuthContext.Provider> <AuthContext.Provider value={authUser}>{children}</AuthContext.Provider>

View File

@ -2,6 +2,7 @@ import Router from 'next/router'
import clsx from 'clsx' import clsx from 'clsx'
import { MouseEvent, useEffect, useState } from 'react' import { MouseEvent, useEffect, useState } from 'react'
import { UserCircleIcon, UserIcon, UsersIcon } from '@heroicons/react/solid' import { UserCircleIcon, UserIcon, UsersIcon } from '@heroicons/react/solid'
import Image from 'next/future/image'
export function Avatar(props: { export function Avatar(props: {
username?: string username?: string
@ -14,6 +15,7 @@ export function Avatar(props: {
const [avatarUrl, setAvatarUrl] = useState(props.avatarUrl) const [avatarUrl, setAvatarUrl] = useState(props.avatarUrl)
useEffect(() => setAvatarUrl(props.avatarUrl), [props.avatarUrl]) useEffect(() => setAvatarUrl(props.avatarUrl), [props.avatarUrl])
const s = size == 'xs' ? 6 : size === 'sm' ? 8 : size || 10 const s = size == 'xs' ? 6 : size === 'sm' ? 8 : size || 10
const sizeInPx = s * 4
const onClick = const onClick =
noLink && username noLink && username
@ -26,7 +28,9 @@ export function Avatar(props: {
// there can be no avatar URL or username in the feed, we show a "submit comment" // there can be no avatar URL or username in the feed, we show a "submit comment"
// item with a fake grey user circle guy even if you aren't signed in // item with a fake grey user circle guy even if you aren't signed in
return avatarUrl ? ( return avatarUrl ? (
<img <Image
width={sizeInPx}
height={sizeInPx}
className={clsx( className={clsx(
'flex-shrink-0 rounded-full bg-white object-cover', 'flex-shrink-0 rounded-full bg-white object-cover',
`w-${s} h-${s}`, `w-${s} h-${s}`,

View File

@ -35,10 +35,13 @@ export default function BetButton(props: {
{user ? ( {user ? (
<Button <Button
size="lg" size="lg"
className={clsx('my-auto inline-flex min-w-[75px] ', btnClassName)} className={clsx(
'my-auto inline-flex min-w-[75px] whitespace-nowrap',
btnClassName
)}
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
> >
Bet Predict
</Button> </Button>
) : ( ) : (
<BetSignUpPrompt /> <BetSignUpPrompt />

View File

@ -8,6 +8,7 @@ import { Col } from './layout/col'
import { Row } from './layout/row' import { Row } from './layout/row'
import { Spacer } from './layout/spacer' import { Spacer } from './layout/spacer'
import { import {
formatLargeNumber,
formatMoney, formatMoney,
formatPercent, formatPercent,
formatWithCommas, formatWithCommas,
@ -28,7 +29,7 @@ import { getProbability } from 'common/calculate'
import { useFocus } from 'web/hooks/use-focus' import { useFocus } from 'web/hooks/use-focus'
import { useUserContractBets } from 'web/hooks/use-user-bets' import { useUserContractBets } from 'web/hooks/use-user-bets'
import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm' 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 { SellRow } from './sell-row'
import { useSaveBinaryShares } from './use-save-binary-shares' import { useSaveBinaryShares } from './use-save-binary-shares'
import { BetSignUpPrompt } from './sign-up-prompt' import { BetSignUpPrompt } from './sign-up-prompt'
@ -39,7 +40,8 @@ import { LimitBets } from './limit-bets'
import { PillButton } from './buttons/pill-button' import { PillButton } from './buttons/pill-button'
import { YesNoSelector } from './yes-no-selector' import { YesNoSelector } from './yes-no-selector'
import { PlayMoneyDisclaimer } from './play-money-disclaimer' import { PlayMoneyDisclaimer } from './play-money-disclaimer'
import { AlertBox } from './alert-box' import { isAndroid, isIOS } from 'web/lib/util/device'
import { WarningConfirmationButton } from './warning-confirmation-button'
export function BetPanel(props: { export function BetPanel(props: {
contract: CPMMBinaryContract | PseudoNumericContract contract: CPMMBinaryContract | PseudoNumericContract
@ -67,27 +69,32 @@ export function BetPanel(props: {
className className
)} )}
> >
<QuickOrLimitBet {user ? (
isLimitOrder={isLimitOrder} <>
setIsLimitOrder={setIsLimitOrder} <QuickOrLimitBet
hideToggle={!user} isLimitOrder={isLimitOrder}
/> setIsLimitOrder={setIsLimitOrder}
<BuyPanel hideToggle={!user}
hidden={isLimitOrder} />
contract={contract} <BuyPanel
user={user} hidden={isLimitOrder}
unfilledBets={unfilledBets} contract={contract}
/> user={user}
<LimitOrderPanel unfilledBets={unfilledBets}
hidden={!isLimitOrder} />
contract={contract} <LimitOrderPanel
user={user} hidden={!isLimitOrder}
unfilledBets={unfilledBets} contract={contract}
/> user={user}
unfilledBets={unfilledBets}
<BetSignUpPrompt /> />
</>
{!user && <PlayMoneyDisclaimer />} ) : (
<>
<BetSignUpPrompt />
<PlayMoneyDisclaimer />
</>
)}
</Col> </Col>
{user && unfilledBets.length > 0 && ( {user && unfilledBets.length > 0 && (
@ -178,17 +185,13 @@ function BuyPanel(props: {
const [inputRef, focusAmountInput] = useFocus() const [inputRef, focusAmountInput] = useFocus()
// useEffect(() => {
// if (selected) {
// if (isIOS()) window.scrollTo(0, window.scrollY + 200)
// focusAmountInput()
// }
// }, [selected, focusAmountInput])
function onBetChoice(choice: 'YES' | 'NO') { function onBetChoice(choice: 'YES' | 'NO') {
setOutcome(choice) setOutcome(choice)
setWasSubmitted(false) setWasSubmitted(false)
focusAmountInput()
if (!isIOS() && !isAndroid()) {
focusAmountInput()
}
} }
function onBetChange(newAmount: number | undefined) { function onBetChange(newAmount: number | undefined) {
@ -251,17 +254,33 @@ function BuyPanel(props: {
const resultProb = getCpmmProbability(newPool, newP) const resultProb = getCpmmProbability(newPool, newP)
const probStayedSame = const probStayedSame =
formatPercent(resultProb) === formatPercent(initialProb) formatPercent(resultProb) === formatPercent(initialProb)
const probChange = Math.abs(resultProb - initialProb) const probChange = Math.abs(resultProb - initialProb)
const currentPayout = newBet.shares const currentPayout = newBet.shares
const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0 const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0
const currentReturnPercent = formatPercent(currentReturn) const currentReturnPercent = formatPercent(currentReturn)
const format = getFormattedMappedValue(contract) 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 bankrollFraction = (betAmount ?? 0) / (user?.balance ?? 1e9)
const warning =
(betAmount ?? 0) > 10 && bankrollFraction >= 0.5 && bankrollFraction <= 1
? `You might not want to spend ${formatPercent(
bankrollFraction
)} of your balance on a single trade. \n\nCurrent balance: ${formatMoney(
user?.balance ?? 0
)}`
: (betAmount ?? 0) > 10 && probChange >= 0.3 && bankrollFraction <= 1
? `Are you sure you want to move the market by ${displayedDifference}?`
: undefined
return ( return (
<Col className={hidden ? 'hidden' : ''}> <Col className={hidden ? 'hidden' : ''}>
<div className="my-3 text-left text-sm text-gray-500"> <div className="my-3 text-left text-sm text-gray-500">
@ -275,7 +294,13 @@ function BuyPanel(props: {
isPseudoNumeric={isPseudoNumeric} isPseudoNumeric={isPseudoNumeric}
/> />
<div className="my-3 text-left text-sm text-gray-500">Amount</div> <Row className="my-3 justify-between text-left text-sm text-gray-500">
Amount
<span className={'xl:hidden'}>
Balance: {formatMoney(user?.balance ?? 0)}
</span>
</Row>
<BuyAmountInput <BuyAmountInput
inputClassName="w-full max-w-none" inputClassName="w-full max-w-none"
amount={betAmount} amount={betAmount}
@ -284,36 +309,9 @@ function BuyPanel(props: {
setError={setError} setError={setError}
disabled={isSubmitting} disabled={isSubmitting}
inputRef={inputRef} 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)
}?`}
/>
) : (
''
)}
<Col className="mt-3 w-full gap-3"> <Col className="mt-3 w-full gap-3">
<Row className="items-center justify-between text-sm"> <Row className="items-center justify-between text-sm">
<div className="text-gray-500"> <div className="text-gray-500">
@ -341,9 +339,6 @@ function BuyPanel(props: {
</> </>
)} )}
</div> </div>
{/* <InfoTooltip
text={`Includes ${formatMoneyWithDecimals(totalFees)} in fees`}
/> */}
</Row> </Row>
<div> <div>
<span className="mr-2 whitespace-nowrap"> <span className="mr-2 whitespace-nowrap">
@ -357,23 +352,23 @@ function BuyPanel(props: {
<Spacer h={8} /> <Spacer h={8} />
{user && ( {user && (
<button <WarningConfirmationButton
className={clsx( warning={warning}
onSubmit={submitBet}
isSubmitting={isSubmitting}
disabled={!!betDisabled}
openModalButtonClass={clsx(
'btn mb-2 flex-1', 'btn mb-2 flex-1',
betDisabled betDisabled
? 'btn-disabled' ? 'btn-disabled'
: outcome === 'YES' : outcome === 'YES'
? 'btn-primary' ? 'btn-primary'
: 'border-none bg-red-400 hover:bg-red-500', : 'border-none bg-red-400 hover:bg-red-500'
isSubmitting ? 'loading' : ''
)} )}
onClick={betDisabled ? undefined : submitBet} />
>
{isSubmitting ? 'Submitting...' : 'Submit bet'}
</button>
)} )}
{wasSubmitted && <div className="mt-4">Bet submitted!</div>} {wasSubmitted && <div className="mt-4">Trade submitted!</div>}
</Col> </Col>
) )
} }
@ -559,7 +554,7 @@ function LimitOrderPanel(props: {
<Row className="mt-1 items-center gap-4"> <Row className="mt-1 items-center gap-4">
<Col className="gap-2"> <Col className="gap-2">
<div className="relative ml-1 text-sm text-gray-500"> <div className="relative ml-1 text-sm text-gray-500">
Bet {isPseudoNumeric ? <HigherLabel /> : <YesLabel />} up to Buy {isPseudoNumeric ? <HigherLabel /> : <YesLabel />} up to
</div> </div>
<ProbabilityOrNumericInput <ProbabilityOrNumericInput
contract={contract} contract={contract}
@ -570,7 +565,7 @@ function LimitOrderPanel(props: {
</Col> </Col>
<Col className="gap-2"> <Col className="gap-2">
<div className="ml-1 text-sm text-gray-500"> <div className="ml-1 text-sm text-gray-500">
Bet {isPseudoNumeric ? <LowerLabel /> : <NoLabel />} down to Buy {isPseudoNumeric ? <LowerLabel /> : <NoLabel />} down to
</div> </div>
<ProbabilityOrNumericInput <ProbabilityOrNumericInput
contract={contract} contract={contract}
@ -593,9 +588,15 @@ function LimitOrderPanel(props: {
</div> </div>
)} )}
<div className="mt-1 mb-3 text-left text-sm text-gray-500"> <Row className="mt-1 mb-3 justify-between text-left text-sm text-gray-500">
Max amount<span className="ml-1 text-red-500">*</span> <span>
</div> Max amount<span className="ml-1 text-red-500">*</span>
</span>
<span className={'xl:hidden'}>
Balance: {formatMoney(user?.balance ?? 0)}
</span>
</Row>
<BuyAmountInput <BuyAmountInput
inputClassName="w-full max-w-none" inputClassName="w-full max-w-none"
amount={betAmount} amount={betAmount}
@ -603,6 +604,7 @@ function LimitOrderPanel(props: {
error={error} error={error}
setError={setError} setError={setError}
disabled={isSubmitting} disabled={isSubmitting}
showSliderOnMobile
/> />
<Col className="mt-3 w-full gap-3"> <Col className="mt-3 w-full gap-3">
@ -733,15 +735,16 @@ function QuickOrLimitBet(props: {
return ( return (
<Row className="align-center mb-4 justify-between"> <Row className="align-center mb-4 justify-between">
<div className="text-4xl">Bet</div> <div className="mr-2 -ml-2 shrink-0 text-3xl sm:-ml-0">Predict</div>
{!hideToggle && ( {!hideToggle && (
<Row className="mt-1 items-center gap-2"> <Row className="mt-1 ml-1 items-center gap-1.5 sm:ml-0 sm:gap-2">
<PillButton <PillButton
selected={!isLimitOrder} selected={!isLimitOrder}
onSelect={() => { onSelect={() => {
setIsLimitOrder(false) setIsLimitOrder(false)
track('select quick order') track('select quick order')
}} }}
xs={true}
> >
Quick Quick
</PillButton> </PillButton>
@ -751,6 +754,7 @@ function QuickOrLimitBet(props: {
setIsLimitOrder(true) setIsLimitOrder(true)
track('select limit order') track('select limit order')
}} }}
xs={true}
> >
Limit Limit
</PillButton> </PillButton>

View File

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

View File

@ -1,4 +1,4 @@
import { ReactNode } from 'react' import { MouseEventHandler, ReactNode } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
export type SizeType = '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' export type SizeType = '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
@ -14,7 +14,7 @@ export type ColorType =
export function Button(props: { export function Button(props: {
className?: string className?: string
onClick?: () => void onClick?: MouseEventHandler<any> | undefined
children?: ReactNode children?: ReactNode
size?: SizeType size?: SizeType
color?: ColorType color?: ColorType

View File

@ -5,19 +5,19 @@ export function PillButton(props: {
selected: boolean selected: boolean
onSelect: () => void onSelect: () => void
color?: string color?: string
big?: boolean xs?: boolean
children: ReactNode children: ReactNode
}) { }) {
const { children, selected, onSelect, color, big } = props const { children, selected, onSelect, color, xs } = props
return ( return (
<button <button
className={clsx( className={clsx(
'cursor-pointer select-none whitespace-nowrap rounded-full', 'cursor-pointer select-none whitespace-nowrap rounded-full px-3 py-1.5 text-sm',
xs ? 'text-xs' : '',
selected selected
? ['text-white', color ?? 'bg-greyscale-6'] ? ['text-white', color ?? 'bg-greyscale-6']
: 'bg-greyscale-2 hover:bg-greyscale-3', : 'bg-greyscale-2 hover:bg-greyscale-3'
big ? 'px-8 py-2' : 'px-3 py-1.5 text-sm'
)} )}
onClick={onSelect} onClick={onSelect}
> >

View File

@ -33,12 +33,12 @@ export function Carousel(props: {
}, 500) }, 500)
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(onScroll, []) useEffect(onScroll, [children])
return ( return (
<div className={clsx('relative', className)}> <div className={clsx('relative', className)}>
<Row <Row
className="scrollbar-hide w-full gap-4 overflow-x-auto scroll-smooth" className="scrollbar-hide w-full snap-x gap-4 overflow-x-auto scroll-smooth"
ref={ref} ref={ref}
onScroll={onScroll} onScroll={onScroll}
> >

View File

@ -27,7 +27,8 @@ export function AcceptChallengeButton(props: {
setErrorText('') setErrorText('')
}, [open]) }, [open])
if (!user) return <BetSignUpPrompt label="Accept this bet" className="mt-4" /> if (!user)
return <BetSignUpPrompt label="Sign up to accept" className="mt-4" />
const iAcceptChallenge = () => { const iAcceptChallenge = () => {
setLoading(true) setLoading(true)

View File

@ -147,7 +147,7 @@ function CreateChallengeForm(props: {
setFinishedCreating(true) setFinishedCreating(true)
}} }}
> >
<Title className="!mt-2" text="Challenge bet " /> <Title className="!mt-2 hidden sm:block" text="Challenge bet " />
<div className="mb-8"> <div className="mb-8">
Challenge a friend to bet on{' '} Challenge a friend to bet on{' '}
@ -170,72 +170,76 @@ function CreateChallengeForm(props: {
)} )}
</div> </div>
<div className="mt-2 flex flex-col flex-wrap justify-center gap-x-5 gap-y-2"> <Col className="mt-2 flex-wrap justify-center gap-x-5 sm:gap-y-2">
<div>You'll bet:</div> <Col>
<Row <div>You'll bet:</div>
className={ <Row
'form-control w-full max-w-xs items-center justify-between gap-4 pr-3' className={
} 'form-control w-full max-w-xs items-center justify-between gap-4 pr-3'
>
<AmountInput
amount={challengeInfo.amount || undefined}
onChange={(newAmount) =>
setChallengeInfo((m: challengeInfo) => {
return {
...m,
amount: newAmount ?? 0,
acceptorAmount: editingAcceptorAmount
? m.acceptorAmount
: newAmount ?? 0,
}
})
}
error={undefined}
label={'M$'}
inputClassName="w-24"
/>
<span className={''}>on</span>
{challengeInfo.outcome === 'YES' ? <YesLabel /> : <NoLabel />}
</Row>
<Row className={'mt-3 max-w-xs justify-end'}>
<Button
color={'gray-white'}
onClick={() =>
setChallengeInfo((m: challengeInfo) => {
return {
...m,
outcome: m.outcome === 'YES' ? 'NO' : 'YES',
}
})
} }
> >
<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 <AmountInput
amount={challengeInfo.acceptorAmount || undefined} amount={challengeInfo.amount || undefined}
onChange={(newAmount) => { onChange={(newAmount) =>
setEditingAcceptorAmount(true)
setChallengeInfo((m: challengeInfo) => { setChallengeInfo((m: challengeInfo) => {
return { return {
...m, ...m,
acceptorAmount: newAmount ?? 0, amount: newAmount ?? 0,
acceptorAmount: editingAcceptorAmount
? m.acceptorAmount
: newAmount ?? 0,
} }
}) })
}} }
error={undefined} error={undefined}
label={'M$'} label={'M$'}
inputClassName="w-24" inputClassName="w-24"
/> />
</div> <span className={''}>on</span>
<span>on</span> {challengeInfo.outcome === 'YES' ? <YesLabel /> : <NoLabel />}
{challengeInfo.outcome === 'YES' ? <NoLabel /> : <YesLabel />} </Row>
</Row> <Row className={'mt-3 max-w-xs justify-end'}>
</div> <Button
color={'gray-white'}
onClick={() =>
setChallengeInfo((m: challengeInfo) => {
return {
...m,
outcome: m.outcome === 'YES' ? 'NO' : 'YES',
}
})
}
>
<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>
</Col>
{contract && ( {contract && (
<Button <Button
size="2xs" size="2xs"

View File

@ -0,0 +1,175 @@
import { PaperAirplaneIcon } from '@heroicons/react/solid'
import { Editor } from '@tiptap/react'
import clsx from 'clsx'
import { User } from 'common/user'
import { useEffect, useState } from 'react'
import { useUser } from 'web/hooks/use-user'
import { useWindowSize } from 'web/hooks/use-window-size'
import { MAX_COMMENT_LENGTH } from 'web/lib/firebase/comments'
import { Avatar } from './avatar'
import { TextEditor, useTextEditor } from './editor'
import { Row } from './layout/row'
import { LoadingIndicator } from './loading-indicator'
export function CommentInput(props: {
replyToUser?: { id: string; username: string }
// Reply to a free response answer
parentAnswerOutcome?: string
// Reply to another comment
parentCommentId?: string
onSubmitComment?: (editor: Editor, betId: string | undefined) => void
className?: string
presetId?: string
}) {
const {
parentAnswerOutcome,
parentCommentId,
replyToUser,
onSubmitComment,
presetId,
} = props
const user = useUser()
const { editor, upload } = useTextEditor({
simple: true,
max: MAX_COMMENT_LENGTH,
placeholder:
!!parentCommentId || !!parentAnswerOutcome
? 'Write a reply...'
: 'Write a comment...',
})
const [isSubmitting, setIsSubmitting] = useState(false)
async function submitComment(betId: string | undefined) {
if (!editor || editor.isEmpty || isSubmitting) return
setIsSubmitting(true)
onSubmitComment?.(editor, betId)
setIsSubmitting(false)
}
if (user?.isBannedFromPosting) return <></>
return (
<Row className={clsx(props.className, 'mb-2 gap-1 sm:gap-2')}>
<Avatar
avatarUrl={user?.avatarUrl}
username={user?.username}
size="sm"
className="mt-2"
/>
<div className="min-w-0 flex-1 pl-0.5 text-sm">
<CommentInputTextArea
editor={editor}
upload={upload}
replyToUser={replyToUser}
user={user}
submitComment={submitComment}
isSubmitting={isSubmitting}
presetId={presetId}
/>
</div>
</Row>
)
}
export function CommentInputTextArea(props: {
user: User | undefined | null
replyToUser?: { id: string; username: string }
editor: Editor | null
upload: Parameters<typeof TextEditor>[0]['upload']
submitComment: (id?: string) => void
isSubmitting: boolean
submitOnEnter?: boolean
presetId?: string
}) {
const {
user,
editor,
upload,
submitComment,
presetId,
isSubmitting,
submitOnEnter,
replyToUser,
} = props
const isMobile = (useWindowSize().width ?? 0) < 768 // TODO: base off input device (keybord vs touch)
useEffect(() => {
editor?.setEditable(!isSubmitting)
}, [isSubmitting, editor])
const submit = () => {
submitComment(presetId)
editor?.commands?.clearContent()
}
useEffect(() => {
if (!editor) {
return
}
// submit on Enter key
editor.setOptions({
editorProps: {
handleKeyDown: (view, event) => {
if (
submitOnEnter &&
event.key === 'Enter' &&
!event.shiftKey &&
(!isMobile || event.ctrlKey || event.metaKey) &&
// mention list is closed
!(view.state as any).mention$.active
) {
submit()
event.preventDefault()
return true
}
return false
},
},
})
// insert at mention and focus
if (replyToUser) {
editor
.chain()
.setContent({
type: 'mention',
attrs: { label: replyToUser.username, id: replyToUser.id },
})
.insertContent(' ')
.focus()
.run()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor])
return (
<>
<TextEditor editor={editor} upload={upload}>
{user && !isSubmitting && (
<button
className="btn btn-ghost btn-sm px-2 disabled:bg-inherit disabled:text-gray-300"
disabled={!editor || editor.isEmpty}
onClick={submit}
>
<PaperAirplaneIcon className="m-0 h-[25px] min-w-[22px] rotate-90 p-0" />
</button>
)}
{isSubmitting && (
<LoadingIndicator spinnerClassName={'border-gray-500'} />
)}
</TextEditor>
<Row>
{!user && (
<button
className={'btn btn-outline btn-sm mt-2 normal-case'}
onClick={() => submitComment(presetId)}
>
Add my comment
</button>
)}
</Row>
</>
)
}

View File

@ -47,13 +47,13 @@ export function ConfirmationButton(props: {
{children} {children}
<Row className="gap-4"> <Row className="gap-4">
<div <div
className={clsx('btn normal-case', cancelBtn?.className)} className={clsx('btn', cancelBtn?.className)}
onClick={() => updateOpen(false)} onClick={() => updateOpen(false)}
> >
{cancelBtn?.label ?? 'Cancel'} {cancelBtn?.label ?? 'Cancel'}
</div> </div>
<div <div
className={clsx('btn normal-case', submitBtn?.className)} className={clsx('btn', submitBtn?.className)}
onClick={ onClick={
onSubmitWithSuccess onSubmitWithSuccess
? () => ? () =>
@ -69,7 +69,7 @@ export function ConfirmationButton(props: {
</Col> </Col>
</Modal> </Modal>
<div <div
className={clsx('btn normal-case', openModalBtn.className)} className={clsx('btn', openModalBtn.className)}
onClick={() => updateOpen(true)} onClick={() => updateOpen(true)}
> >
{openModalBtn.icon} {openModalBtn.icon}

View File

@ -43,10 +43,13 @@ export const SORTS = [
{ label: 'Trending', value: 'score' }, { label: 'Trending', value: 'score' },
{ label: 'Most traded', value: 'most-traded' }, { label: 'Most traded', value: 'most-traded' },
{ label: '24h volume', value: '24-hour-vol' }, { label: '24h volume', value: '24-hour-vol' },
{ label: '24h change', value: 'prob-change-day' },
{ label: 'Last updated', value: 'last-updated' }, { label: 'Last updated', value: 'last-updated' },
{ label: 'Subsidy', value: 'liquidity' }, { label: 'Subsidy', value: 'liquidity' },
{ label: 'Close date', value: 'close-date' }, { label: 'Close date', value: 'close-date' },
{ label: 'Resolve date', value: 'resolve-date' }, { label: 'Resolve date', value: 'resolve-date' },
{ label: 'Highest %', value: 'prob-descending' },
{ label: 'Lowest %', value: 'prob-ascending' },
] as const ] as const
export type Sort = typeof SORTS[number]['value'] export type Sort = typeof SORTS[number]['value']
@ -66,6 +69,7 @@ type AdditionalFilter = {
excludeContractIds?: string[] excludeContractIds?: string[]
groupSlug?: string groupSlug?: string
yourBets?: boolean yourBets?: boolean
followed?: boolean
} }
export function ContractSearch(props: { export function ContractSearch(props: {
@ -85,6 +89,7 @@ export function ContractSearch(props: {
useQueryUrlParam?: boolean useQueryUrlParam?: boolean
isWholePage?: boolean isWholePage?: boolean
noControls?: boolean noControls?: boolean
maxResults?: number
renderContracts?: ( renderContracts?: (
contracts: Contract[] | undefined, contracts: Contract[] | undefined,
loadMore: () => void loadMore: () => void
@ -104,6 +109,7 @@ export function ContractSearch(props: {
useQueryUrlParam, useQueryUrlParam,
isWholePage, isWholePage,
noControls, noControls,
maxResults,
renderContracts, renderContracts,
} = props } = props
@ -186,7 +192,8 @@ export function ContractSearch(props: {
const contracts = state.pages const contracts = state.pages
.flat() .flat()
.filter((c) => !additionalFilter?.excludeContractIds?.includes(c.id)) .filter((c) => !additionalFilter?.excludeContractIds?.includes(c.id))
const renderedContracts = state.pages.length === 0 ? undefined : contracts const renderedContracts =
state.pages.length === 0 ? undefined : contracts.slice(0, maxResults)
if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) { if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) {
return <ContractSearchFirestore additionalFilter={additionalFilter} /> return <ContractSearchFirestore additionalFilter={additionalFilter} />
@ -282,13 +289,26 @@ function ContractSearchControls(props: {
: DEFAULT_CATEGORY_GROUPS.map((g) => g.slug) : DEFAULT_CATEGORY_GROUPS.map((g) => g.slug)
const memberPillGroups = sortBy( const memberPillGroups = sortBy(
memberGroups.filter((group) => group.contractIds.length > 0), memberGroups.filter((group) => group.totalContracts > 0),
(group) => group.contractIds.length (group) => group.totalContracts
).reverse() ).reverse()
const pillGroups: { name: string; slug: string }[] = const pillGroups: { name: string; slug: string }[] =
memberPillGroups.length > 0 ? memberPillGroups : DEFAULT_CATEGORY_GROUPS memberPillGroups.length > 0 ? memberPillGroups : DEFAULT_CATEGORY_GROUPS
const personalFilters = user
? [
// Show contracts in groups that the user is a member of.
memberGroupSlugs
.map((slug) => `groupLinks.slug:${slug}`)
// Or, show contracts created by users the user follows
.concat(follows?.map((followId) => `creatorId:${followId}`) ?? []),
// Subtract contracts you bet on, to show new ones.
`uniqueBettorIds:-${user.id}`,
]
: []
const additionalFilters = [ const additionalFilters = [
additionalFilter?.creatorId additionalFilter?.creatorId
? `creatorId:${additionalFilter.creatorId}` ? `creatorId:${additionalFilter.creatorId}`
@ -301,6 +321,7 @@ function ContractSearchControls(props: {
? // Show contracts bet on by the user ? // Show contracts bet on by the user
`uniqueBettorIds:${user.id}` `uniqueBettorIds:${user.id}`
: '', : '',
...(additionalFilter?.followed ? personalFilters : []),
] ]
const facetFilters = query const facetFilters = query
? additionalFilters ? additionalFilters
@ -317,21 +338,7 @@ function ContractSearchControls(props: {
state.pillFilter !== 'your-bets' state.pillFilter !== 'your-bets'
? `groupLinks.slug:${state.pillFilter}` ? `groupLinks.slug:${state.pillFilter}`
: '', : '',
state.pillFilter === 'personal' ...(state.pillFilter === 'personal' ? personalFilters : []),
? // Show contracts in groups that the user is a member of
memberGroupSlugs
.map((slug) => `groupLinks.slug:${slug}`)
// Show contracts created by users the user follows
.concat(follows?.map((followId) => `creatorId:${followId}`) ?? [])
// Show contracts bet on by users the user follows
.concat(
follows?.map((followId) => `uniqueBettorIds:${followId}`) ?? []
)
: '',
// Subtract contracts you bet on from For you.
state.pillFilter === 'personal' && user
? `uniqueBettorIds:-${user.id}`
: '',
state.pillFilter === 'your-bets' && user state.pillFilter === 'your-bets' && user
? // Show contracts bet on by the user ? // Show contracts bet on by the user
`uniqueBettorIds:${user.id}` `uniqueBettorIds:${user.id}`
@ -442,7 +449,7 @@ function ContractSearchControls(props: {
selected={state.pillFilter === 'your-bets'} selected={state.pillFilter === 'your-bets'}
onSelect={selectPill('your-bets')} onSelect={selectPill('your-bets')}
> >
Your bets Your trades
</PillButton> </PillButton>
)} )}

View File

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

View File

@ -6,6 +6,7 @@ import Textarea from 'react-expanding-textarea'
import { Contract, MAX_DESCRIPTION_LENGTH } from 'common/contract' import { Contract, MAX_DESCRIPTION_LENGTH } from 'common/contract'
import { exhibitExts, parseTags } from 'common/util/parse' import { exhibitExts, parseTags } from 'common/util/parse'
import { useAdmin } from 'web/hooks/use-admin' import { useAdmin } from 'web/hooks/use-admin'
import { useUser } from 'web/hooks/use-user'
import { updateContract } from 'web/lib/firebase/contracts' import { updateContract } from 'web/lib/firebase/contracts'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { Content } from '../editor' import { Content } from '../editor'
@ -17,11 +18,12 @@ import { insertContent } from '../editor/utils'
export function ContractDescription(props: { export function ContractDescription(props: {
contract: Contract contract: Contract
isCreator: boolean
className?: string className?: string
}) { }) {
const { contract, isCreator, className } = props const { contract, className } = props
const isAdmin = useAdmin() const isAdmin = useAdmin()
const user = useUser()
const isCreator = user?.id === contract.creatorId
return ( return (
<div className={clsx('mt-2 text-gray-700', className)}> <div className={clsx('mt-2 text-gray-700', className)}>
{isCreator || isAdmin ? ( {isCreator || isAdmin ? (

View File

@ -2,12 +2,12 @@ import {
ClockIcon, ClockIcon,
DatabaseIcon, DatabaseIcon,
PencilIcon, PencilIcon,
TrendingUpIcon,
UserGroupIcon, UserGroupIcon,
} from '@heroicons/react/outline' } from '@heroicons/react/outline'
import clsx from 'clsx' import clsx from 'clsx'
import { Editor } from '@tiptap/react' import { Editor } from '@tiptap/react'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import Link from 'next/link'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { formatMoney } from 'common/util/format' import { formatMoney } from 'common/util/format'
@ -26,11 +26,10 @@ import { Button } from 'web/components/button'
import { Modal } from 'web/components/layout/modal' import { Modal } from 'web/components/layout/modal'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { ContractGroupsList } from 'web/components/groups/contract-groups-list' import { ContractGroupsList } from 'web/components/groups/contract-groups-list'
import { SiteLink } from 'web/components/site-link' import { linkClass } from 'web/components/site-link'
import { groupPath } from 'web/lib/firebase/groups' import { getGroupLinkToDisplay, groupPath } from 'web/lib/firebase/groups'
import { insertContent } from '../editor/utils' import { insertContent } from '../editor/utils'
import { contractMetrics } from 'common/contract-details' import { contractMetrics } from 'common/contract-details'
import { User } from 'common/user'
import { UserLink } from 'web/components/user-link' import { UserLink } from 'web/components/user-link'
import { FeaturedContractBadge } from 'web/components/contract/featured-contract-badge' import { FeaturedContractBadge } from 'web/components/contract/featured-contract-badge'
import { Tooltip } from 'web/components/tooltip' import { Tooltip } from 'web/components/tooltip'
@ -40,30 +39,19 @@ export type ShowTime = 'resolve-date' | 'close-date'
export function MiscDetails(props: { export function MiscDetails(props: {
contract: Contract contract: Contract
showHotVolume?: boolean
showTime?: ShowTime showTime?: ShowTime
hideGroupLink?: boolean hideGroupLink?: boolean
}) { }) {
const { contract, showHotVolume, showTime, hideGroupLink } = props const { contract, showTime, hideGroupLink } = props
const { const { volume, closeTime, isResolved, createdTime, resolutionTime } =
volume, contract
volume24Hours,
closeTime,
isResolved,
createdTime,
resolutionTime,
groupLinks,
} = contract
const isNew = createdTime > Date.now() - DAY_MS && !isResolved const isNew = createdTime > Date.now() - DAY_MS && !isResolved
const groupToDisplay = getGroupLinkToDisplay(contract)
return ( return (
<Row className="items-center gap-3 truncate text-sm text-gray-400"> <Row className="items-center gap-3 truncate text-sm text-gray-400">
{showHotVolume ? ( {showTime === 'close-date' ? (
<Row className="gap-0.5">
<TrendingUpIcon className="h-5 w-5" /> {formatMoney(volume24Hours)}
</Row>
) : showTime === 'close-date' ? (
<Row className="gap-0.5 whitespace-nowrap"> <Row className="gap-0.5 whitespace-nowrap">
<ClockIcon className="h-5 w-5" /> <ClockIcon className="h-5 w-5" />
{(closeTime || 0) < Date.now() ? 'Closed' : 'Closes'}{' '} {(closeTime || 0) < Date.now() ? 'Closed' : 'Closes'}{' '}
@ -83,13 +71,12 @@ export function MiscDetails(props: {
<NewContractBadge /> <NewContractBadge />
)} )}
{!hideGroupLink && groupLinks && groupLinks.length > 0 && ( {!hideGroupLink && groupToDisplay && (
<SiteLink <Link prefetch={false} href={groupPath(groupToDisplay.slug)}>
href={groupPath(groupLinks[0].slug)} <a className={clsx(linkClass, 'truncate text-sm text-gray-400')}>
className="truncate text-sm text-gray-400" {groupToDisplay.name}
> </a>
{groupLinks[0].name} </Link>
</SiteLink>
)} )}
</Row> </Row>
) )
@ -117,64 +104,39 @@ export function AvatarDetails(props: {
) )
} }
export function AbbrContractDetails(props: {
contract: Contract
showHotVolume?: boolean
showTime?: ShowTime
}) {
const { contract, showHotVolume, showTime } = props
return (
<Row className="items-center justify-between">
<AvatarDetails contract={contract} />
<MiscDetails
contract={contract}
showHotVolume={showHotVolume}
showTime={showTime}
/>
</Row>
)
}
export function ContractDetails(props: { export function ContractDetails(props: {
contract: Contract contract: Contract
user: User | null | undefined
isCreator?: boolean
disabled?: boolean disabled?: boolean
}) { }) {
const { contract, isCreator, disabled } = props const { contract, disabled } = props
const { const {
closeTime, closeTime,
creatorName, creatorName,
creatorUsername, creatorUsername,
creatorId, creatorId,
groupLinks,
creatorAvatarUrl, creatorAvatarUrl,
resolutionTime, resolutionTime,
} = contract } = contract
const { volumeLabel, resolvedDate } = contractMetrics(contract) const { volumeLabel, resolvedDate } = contractMetrics(contract)
const groupToDisplay =
groupLinks?.sort((a, b) => a.createdTime - b.createdTime)[0] ?? null
const user = useUser() const user = useUser()
const isCreator = user?.id === creatorId
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const { width } = useWindowSize() const { width } = useWindowSize()
const isMobile = (width ?? 0) < 600 const isMobile = (width ?? 0) < 600
const groupToDisplay = getGroupLinkToDisplay(contract)
const groupInfo = groupToDisplay ? ( const groupInfo = groupToDisplay ? (
<Row <Link prefetch={false} href={groupPath(groupToDisplay.slug)}>
className={clsx( <a
'items-center pr-2', className={clsx(
isMobile ? 'max-w-[140px]' : 'max-w-[250px]' linkClass,
)} 'flex flex-row items-center truncate pr-0 sm:pr-2',
> isMobile ? 'max-w-[140px]' : 'max-w-[250px]'
<SiteLink href={groupPath(groupToDisplay.slug)} className={'truncate'}> )}
<Row> >
<UserGroupIcon className="mx-1 inline h-5 w-5 shrink-0" /> <UserGroupIcon className="mx-1 inline h-5 w-5 shrink-0" />
<span className="items-center truncate">{groupToDisplay.name}</span> <span className="items-center truncate">{groupToDisplay.name}</span>
</Row> </a>
</SiteLink> </Link>
</Row>
) : ( ) : (
<Button <Button
size={'xs'} size={'xs'}
@ -236,11 +198,7 @@ export function ContractDetails(props: {
'max-h-[70vh] min-h-[20rem] overflow-auto rounded bg-white p-6' 'max-h-[70vh] min-h-[20rem] overflow-auto rounded bg-white p-6'
} }
> >
<ContractGroupsList <ContractGroupsList contract={contract} user={user} />
groupLinks={groupLinks ?? []}
contract={contract}
user={user}
/>
</Col> </Col>
</Modal> </Modal>
@ -287,18 +245,18 @@ export function ContractDetails(props: {
export function ExtraMobileContractDetails(props: { export function ExtraMobileContractDetails(props: {
contract: Contract contract: Contract
user: User | null | undefined
forceShowVolume?: boolean forceShowVolume?: boolean
}) { }) {
const { contract, user, forceShowVolume } = props const { contract, forceShowVolume } = props
const { volume, resolutionTime, closeTime, creatorId, uniqueBettorCount } = const { volume, resolutionTime, closeTime, creatorId, uniqueBettorCount } =
contract contract
const user = useUser()
const uniqueBettors = uniqueBettorCount ?? 0 const uniqueBettors = uniqueBettorCount ?? 0
const { resolvedDate } = contractMetrics(contract) const { resolvedDate } = contractMetrics(contract)
const volumeTranslation = const volumeTranslation =
volume > 800 || uniqueBettors > 20 volume > 800 || uniqueBettors >= 20
? 'High' ? 'High'
: volume > 300 || uniqueBettors > 10 : volume > 300 || uniqueBettors >= 10
? 'Medium' ? 'Medium'
: 'Low' : 'Low'
@ -336,7 +294,7 @@ export function ExtraMobileContractDetails(props: {
<Tooltip <Tooltip
text={`${formatMoney( text={`${formatMoney(
volume volume
)} bet - ${uniqueBettors} unique bettors`} )} bet - ${uniqueBettors} unique traders`}
> >
{volumeTranslation} {volumeTranslation}
</Tooltip> </Tooltip>
@ -399,7 +357,7 @@ function EditableCloseDate(props: {
return ( return (
<> <>
{isEditingCloseTime ? ( {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 <input
type="date" type="date"
className="input input-bordered shrink-0" className="input input-bordered shrink-0"

View File

@ -135,7 +135,7 @@ export function ContractInfoDialog(props: {
</tr> */} </tr> */}
<tr> <tr>
<td>Bettors</td> <td>Traders</td>
<td>{bettorsCount}</td> <td>{bettorsCount}</td>
</tr> </tr>

View File

@ -49,7 +49,7 @@ export function ContractLeaderboard(props: {
return users && users.length > 0 ? ( return users && users.length > 0 ? (
<Leaderboard <Leaderboard
title="🏅 Top bettors" title="🏅 Top traders"
users={users || []} users={users || []}
columns={[ columns={[
{ {
@ -109,10 +109,6 @@ export function ContractTopTrades(props: {
betsBySameUser={[betsById[topCommentId]]} betsBySameUser={[betsById[topCommentId]]}
/> />
</div> </div>
<div className="mt-2 text-sm text-gray-500">
{commentsById[topCommentId].userName} made{' '}
{formatMoney(profitById[topCommentId] || 0)}!
</div>
<Spacer h={16} /> <Spacer h={16} />
</> </>
)} )}
@ -120,11 +116,11 @@ export function ContractTopTrades(props: {
{/* If they're the same, only show the comment; otherwise show both */} {/* If they're the same, only show the comment; otherwise show both */}
{topBettor && topBetId !== topCommentId && profitById[topBetId] > 0 && ( {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"> <div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">
<FeedBet contract={contract} bet={betsById[topBetId]} /> <FeedBet contract={contract} bet={betsById[topBetId]} />
</div> </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)}! {topBettor?.name} made {formatMoney(profitById[topBetId] || 0)}!
</div> </div>
</> </>

View File

@ -1,5 +1,4 @@
import React from 'react' import React from 'react'
import clsx from 'clsx'
import { tradingAllowed } from 'web/lib/firebase/contracts' import { tradingAllowed } from 'web/lib/firebase/contracts'
import { Col } from '../layout/col' import { Col } from '../layout/col'
@ -16,136 +15,154 @@ import {
import { Bet } from 'common/bet' import { Bet } from 'common/bet'
import BetButton from '../bet-button' import BetButton from '../bet-button'
import { AnswersGraph } from '../answers/answers-graph' import { AnswersGraph } from '../answers/answers-graph'
import { Contract, CPMMBinaryContract } from 'common/contract' import {
import { ContractDescription } from './contract-description' Contract,
BinaryContract,
CPMMContract,
CPMMBinaryContract,
FreeResponseContract,
MultipleChoiceContract,
NumericContract,
PseudoNumericContract,
} from 'common/contract'
import { ContractDetails, ExtraMobileContractDetails } from './contract-details' import { ContractDetails, ExtraMobileContractDetails } from './contract-details'
import { NumericGraph } from './numeric-graph' import { NumericGraph } from './numeric-graph'
import { ExtraContractActionsRow } from 'web/components/contract/extra-contract-actions-row'
const OverviewQuestion = (props: { text: string }) => (
<Linkify className="text-2xl text-indigo-700 md:text-3xl" text={props.text} />
)
const BetWidget = (props: { contract: CPMMContract }) => {
const user = useUser()
return (
<Col>
<BetButton contract={props.contract} />
{!user && (
<div className="mt-1 text-center text-sm text-gray-500">
(with play money!)
</div>
)}
</Col>
)
}
const NumericOverview = (props: { contract: NumericContract }) => {
const { contract } = props
return (
<Col className="gap-1 md:gap-2">
<Col className="gap-3 px-2 sm:gap-4">
<ContractDetails contract={contract} />
<Row className="justify-between gap-4">
<OverviewQuestion text={contract.question} />
<NumericResolutionOrExpectation
contract={contract}
className="hidden items-end xl:flex"
/>
</Row>
<NumericResolutionOrExpectation
className="items-center justify-between gap-4 xl:hidden"
contract={contract}
/>
</Col>
<NumericGraph contract={contract} />
</Col>
)
}
const BinaryOverview = (props: { contract: BinaryContract; bets: Bet[] }) => {
const { contract, bets } = props
return (
<Col className="gap-1 md:gap-2">
<Col className="gap-3 px-2 sm:gap-4">
<ContractDetails contract={contract} />
<Row className="justify-between gap-4">
<OverviewQuestion text={contract.question} />
<BinaryResolutionOrChance
className="hidden items-end xl:flex"
contract={contract}
large
/>
</Row>
<Row className="items-center justify-between gap-4 xl:hidden">
<BinaryResolutionOrChance contract={contract} />
<ExtraMobileContractDetails contract={contract} />
{tradingAllowed(contract) && (
<BetWidget contract={contract as CPMMBinaryContract} />
)}
</Row>
</Col>
<ContractProbGraph contract={contract} bets={[...bets].reverse()} />
</Col>
)
}
const ChoiceOverview = (props: {
contract: FreeResponseContract | MultipleChoiceContract
bets: Bet[]
}) => {
const { contract, bets } = props
const { question, resolution } = contract
return (
<Col className="gap-1 md:gap-2">
<Col className="gap-3 px-2 sm:gap-4">
<ContractDetails contract={contract} />
<OverviewQuestion text={question} />
{resolution && (
<FreeResponseResolutionOrChance contract={contract} truncate="none" />
)}
</Col>
<Col className={'mb-1 gap-y-2'}>
<AnswersGraph contract={contract} bets={[...bets].reverse()} />
<ExtraMobileContractDetails
contract={contract}
forceShowVolume={true}
/>
</Col>
</Col>
)
}
const PseudoNumericOverview = (props: {
contract: PseudoNumericContract
bets: Bet[]
}) => {
const { contract, bets } = props
return (
<Col className="gap-1 md:gap-2">
<Col className="gap-3 px-2 sm:gap-4">
<ContractDetails contract={contract} />
<Row className="justify-between gap-4">
<OverviewQuestion text={contract.question} />
<PseudoNumericResolutionOrExpectation
contract={contract}
className="hidden items-end xl:flex"
/>
</Row>
<Row className="items-center justify-between gap-4 xl:hidden">
<PseudoNumericResolutionOrExpectation contract={contract} />
<ExtraMobileContractDetails contract={contract} />
{tradingAllowed(contract) && <BetWidget contract={contract} />}
</Row>
</Col>
<ContractProbGraph contract={contract} bets={[...bets].reverse()} />
</Col>
)
}
export const ContractOverview = (props: { export const ContractOverview = (props: {
contract: Contract contract: Contract
bets: Bet[] bets: Bet[]
className?: string
}) => { }) => {
const { contract, bets, className } = props const { contract, bets } = props
const { question, creatorId, outcomeType, resolution } = contract switch (contract.outcomeType) {
case 'BINARY':
const user = useUser() return <BinaryOverview contract={contract} bets={bets} />
const isCreator = user?.id === creatorId case 'NUMERIC':
return <NumericOverview contract={contract} />
const isBinary = outcomeType === 'BINARY' case 'PSEUDO_NUMERIC':
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC' return <PseudoNumericOverview contract={contract} bets={bets} />
case 'FREE_RESPONSE':
return ( case 'MULTIPLE_CHOICE':
<Col className={clsx('mb-6', className)}> return <ChoiceOverview contract={contract} bets={bets} />
<Col className="gap-3 px-2 sm:gap-4"> }
<ContractDetails
contract={contract}
user={user}
isCreator={isCreator}
/>
<Row className="justify-between gap-4">
<div className="text-2xl text-indigo-700 md:text-3xl">
<Linkify text={question} />
</div>
<Row className={'hidden gap-3 xl:flex'}>
{isBinary && (
<BinaryResolutionOrChance
className="items-end"
contract={contract}
large
/>
)}
{isPseudoNumeric && (
<PseudoNumericResolutionOrExpectation
contract={contract}
className="items-end"
/>
)}
{outcomeType === 'NUMERIC' && (
<NumericResolutionOrExpectation
contract={contract}
className="items-end"
/>
)}
</Row>
</Row>
{isBinary ? (
<Row className="items-center justify-between gap-4 xl:hidden">
<BinaryResolutionOrChance contract={contract} />
<ExtraMobileContractDetails contract={contract} user={user} />
{tradingAllowed(contract) && (
<Row>
<Col>
<BetButton contract={contract as CPMMBinaryContract} />
{!user && (
<div className="mt-1 text-center text-sm text-gray-500">
(with play money!)
</div>
)}
</Col>
</Row>
)}
</Row>
) : isPseudoNumeric ? (
<Row className="items-center justify-between gap-4 xl:hidden">
<PseudoNumericResolutionOrExpectation contract={contract} />
<ExtraMobileContractDetails contract={contract} user={user} />
{tradingAllowed(contract) && (
<Row>
<Col>
<BetButton contract={contract} />
{!user && (
<div className="mt-1 text-center text-sm text-gray-500">
(with play money!)
</div>
)}
</Col>
</Row>
)}
</Row>
) : (
(outcomeType === 'FREE_RESPONSE' ||
outcomeType === 'MULTIPLE_CHOICE') &&
resolution && (
<FreeResponseResolutionOrChance
contract={contract}
truncate="none"
/>
)
)}
{outcomeType === 'NUMERIC' && (
<Row className="items-center justify-between gap-4 xl:hidden">
<NumericResolutionOrExpectation contract={contract} />
</Row>
)}
</Col>
<div className={'my-1 md:my-2'}></div>
{(isBinary || isPseudoNumeric) && (
<ContractProbGraph contract={contract} bets={[...bets].reverse()} />
)}{' '}
{(outcomeType === 'FREE_RESPONSE' ||
outcomeType === 'MULTIPLE_CHOICE') && (
<Col className={'mb-1 gap-y-2'}>
<AnswersGraph contract={contract} bets={[...bets].reverse()} />
<ExtraMobileContractDetails
contract={contract}
user={user}
forceShowVolume={true}
/>
</Col>
)}
{outcomeType === 'NUMERIC' && <NumericGraph contract={contract} />}
<ExtraContractActionsRow user={user} contract={contract} />
<ContractDescription
className="px-2"
contract={contract}
isCreator={isCreator}
/>
</Col>
)
} }

View File

@ -13,7 +13,6 @@ import { Tabs } from '../layout/tabs'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { tradingAllowed } from 'web/lib/firebase/contracts' import { tradingAllowed } from 'web/lib/firebase/contracts'
import { CommentTipMap } from 'web/hooks/use-tip-txns' import { CommentTipMap } from 'web/hooks/use-tip-txns'
import { useBets } from 'web/hooks/use-bets'
import { useComments } from 'web/hooks/use-comments' import { useComments } from 'web/hooks/use-comments'
import { useLiquidity } from 'web/hooks/use-liquidity' import { useLiquidity } from 'web/hooks/use-liquidity'
import { BetSignUpPrompt } from '../sign-up-prompt' import { BetSignUpPrompt } from '../sign-up-prompt'
@ -27,24 +26,23 @@ export function ContractTabs(props: {
comments: ContractComment[] comments: ContractComment[]
tips: CommentTipMap tips: CommentTipMap
}) { }) {
const { contract, user, tips } = props const { contract, user, bets, tips } = props
const { outcomeType } = contract const { outcomeType } = contract
const bets = useBets(contract.id) ?? props.bets const lps = useLiquidity(contract.id)
const lps = useLiquidity(contract.id) ?? []
const userBets = const userBets =
user && bets.filter((bet) => !bet.isAnte && bet.userId === user.id) user && bets.filter((bet) => !bet.isAnte && bet.userId === user.id)
const visibleBets = bets.filter( const visibleBets = bets.filter(
(bet) => !bet.isAnte && !bet.isRedemption && bet.amount !== 0 (bet) => !bet.isAnte && !bet.isRedemption && bet.amount !== 0
) )
const visibleLps = lps.filter((l) => !l.isAnte && l.amount > 0) const visibleLps = lps?.filter((l) => !l.isAnte && l.amount > 0)
// Load comments here, so the badge count will be correct // Load comments here, so the badge count will be correct
const updatedComments = useComments(contract.id) const updatedComments = useComments(contract.id)
const comments = updatedComments ?? props.comments const comments = updatedComments ?? props.comments
const betActivity = ( const betActivity = visibleLps && (
<ContractBetsActivity <ContractBetsActivity
contract={contract} contract={contract}
bets={visibleBets} bets={visibleBets}
@ -116,13 +114,13 @@ export function ContractTabs(props: {
badge: `${comments.length}`, badge: `${comments.length}`,
}, },
{ {
title: 'Bets', title: 'Trades',
content: betActivity, content: betActivity,
badge: `${visibleBets.length}`, badge: `${visibleBets.length}`,
}, },
...(!user || !userBets?.length ...(!user || !userBets?.length
? [] ? []
: [{ title: 'Your bets', content: yourTrades }]), : [{ title: 'Your trades', content: yourTrades }]),
]} ]}
/> />
{!user ? ( {!user ? (

View File

@ -27,6 +27,7 @@ export function ContractsGrid(props: {
} }
highlightOptions?: ContractHighlightOptions highlightOptions?: ContractHighlightOptions
trackingPostfix?: string trackingPostfix?: string
breakpointColumns?: { [key: string]: number }
}) { }) {
const { const {
contracts, contracts,
@ -67,7 +68,7 @@ export function ContractsGrid(props: {
<Col className="gap-8"> <Col className="gap-8">
<Masonry <Masonry
// Show only 1 column on tailwind's md breakpoint (768px) // Show only 1 column on tailwind's md breakpoint (768px)
breakpointCols={{ default: 2, 768: 1 }} breakpointCols={props.breakpointColumns ?? { default: 2, 768: 1 }}
className="-ml-4 flex w-auto" className="-ml-4 flex w-auto"
columnClassName="pl-4 bg-clip-padding" columnClassName="pl-4 bg-clip-padding"
> >
@ -113,6 +114,7 @@ export function CreatorContractsList(props: {
additionalFilter={{ additionalFilter={{
creatorId: creator.id, creatorId: creator.id,
}} }}
persistPrefix={`user-${creator.id}`}
/> />
) )
} }

View File

@ -5,20 +5,26 @@ import { Row } from '../layout/row'
import { Contract } from 'web/lib/firebase/contracts' import { Contract } from 'web/lib/firebase/contracts'
import React, { useState } from 'react' import React, { useState } from 'react'
import { Button } from 'web/components/button' import { Button } from 'web/components/button'
import { User } from 'common/user' import { useUser } from 'web/hooks/use-user'
import { ShareModal } from './share-modal' import { ShareModal } from './share-modal'
import { FollowMarketButton } from 'web/components/follow-market-button' import { FollowMarketButton } from 'web/components/follow-market-button'
import { LikeMarketButton } from 'web/components/contract/like-market-button' import { LikeMarketButton } from 'web/components/contract/like-market-button'
import { ContractInfoDialog } from 'web/components/contract/contract-info-dialog' import { ContractInfoDialog } from 'web/components/contract/contract-info-dialog'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { withTracking } from 'web/lib/service/analytics'
import { CreateChallengeModal } from 'web/components/challenges/create-challenge-modal'
import { CHALLENGES_ENABLED } from 'common/challenge'
import ChallengeIcon from 'web/lib/icons/challenge-icon'
export function ExtraContractActionsRow(props: { export function ExtraContractActionsRow(props: { contract: Contract }) {
contract: Contract const { contract } = props
user: User | undefined | null const { outcomeType, resolution } = contract
}) { const user = useUser()
const { user, contract } = props
const [isShareOpen, setShareOpen] = useState(false) const [isShareOpen, setShareOpen] = useState(false)
const [openCreateChallengeModal, setOpenCreateChallengeModal] =
useState(false)
const showChallenge =
user && outcomeType === 'BINARY' && !resolution && CHALLENGES_ENABLED
return ( return (
<Row className={'mt-0.5 justify-around sm:mt-2 lg:justify-start'}> <Row className={'mt-0.5 justify-around sm:mt-2 lg:justify-start'}>
@ -37,7 +43,6 @@ export function ExtraContractActionsRow(props: {
/> />
<span>Share</span> <span>Share</span>
</Col> </Col>
<ShareModal <ShareModal
isOpen={isShareOpen} isOpen={isShareOpen}
setOpen={setShareOpen} setOpen={setShareOpen}
@ -46,6 +51,29 @@ export function ExtraContractActionsRow(props: {
/> />
</Button> </Button>
{showChallenge && (
<Button
size="lg"
color="gray-white"
className="max-w-xs self-center"
onClick={withTracking(
() => setOpenCreateChallengeModal(true),
'click challenge button'
)}
>
<Col className="items-center sm:flex-row">
<ChallengeIcon className="mx-auto h-[24px] w-5 text-gray-500 sm:mr-2" />
<span>Challenge</span>
</Col>
<CreateChallengeModal
isOpen={openCreateChallengeModal}
setOpen={setOpenCreateChallengeModal}
user={user}
contract={contract}
/>
</Button>
)}
<FollowMarketButton contract={contract} user={user} /> <FollowMarketButton contract={contract} user={user} />
{user?.id !== contract.creatorId && ( {user?.id !== contract.creatorId && (
<LikeMarketButton contract={contract} user={user} /> <LikeMarketButton contract={contract} user={user} />

View File

@ -39,14 +39,14 @@ export function LikeMarketButton(props: {
return ( return (
<Button <Button
size={'lg'} size={'lg'}
className={'mb-1'} className={'max-w-xs self-center'}
color={'gray-white'} color={'gray-white'}
onClick={onLike} onClick={onLike}
> >
<Col className={'items-center sm:flex-row sm:gap-x-2'}> <Col className={'items-center sm:flex-row'}>
<HeartIcon <HeartIcon
className={clsx( className={clsx(
'h-6 w-6', 'h-[24px] w-5 sm:mr-2',
user && user &&
(userLikedContractIds?.includes(contract.id) || (userLikedContractIds?.includes(contract.id) ||
(!likes && contract.likedByUserIds?.includes(user.id))) (!likes && contract.likedByUserIds?.includes(user.id)))

View File

@ -0,0 +1,98 @@
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 { linkClass, SiteLink } from '../site-link'
import { Col } from '../layout/col'
import { Row } from '../layout/row'
import { useState } from 'react'
export function ProbChangeTable(props: { userId: string | undefined }) {
const { userId } = props
const changes = useProbChanges(userId ?? '')
const [expanded, setExpanded] = useState(false)
if (!changes) {
return null
}
const count = expanded ? 16 : 4
const { positiveChanges, negativeChanges } = changes
const filteredPositiveChanges = positiveChanges.slice(0, count / 2)
const filteredNegativeChanges = negativeChanges.slice(0, count / 2)
const filteredChanges = [
...filteredPositiveChanges,
...filteredNegativeChanges,
]
return (
<Col>
<Col className="mb-4 w-full divide-x-2 divide-y rounded-lg bg-white shadow-md md:flex-row md:divide-y-0">
<Col className="flex-1 divide-y">
{filteredChanges.slice(0, count / 2).map((contract) => (
<Row className="items-center hover:bg-gray-100">
<ProbChange
className="p-4 text-right text-xl"
contract={contract}
/>
<SiteLink
className="p-4 pl-2 font-semibold text-indigo-700"
href={contractPath(contract)}
>
<span className="line-clamp-2">{contract.question}</span>
</SiteLink>
</Row>
))}
</Col>
<Col className="flex-1 divide-y">
{filteredChanges.slice(count / 2).map((contract) => (
<Row className="items-center hover:bg-gray-100">
<ProbChange
className="p-4 text-right text-xl"
contract={contract}
/>
<SiteLink
className="p-4 pl-2 font-semibold text-indigo-700"
href={contractPath(contract)}
>
<span className="line-clamp-2">{contract.question}</span>
</SiteLink>
</Row>
))}
</Col>
</Col>
<div
className={clsx(linkClass, 'cursor-pointer self-end')}
onClick={() => setExpanded(!expanded)}
>
{expanded ? 'Show less' : 'Show more'}
</div>
</Col>
)
}
export function ProbChange(props: {
contract: CPMMContract
className?: string
}) {
const { contract, className } = props
const {
probChanges: { day: change },
} = contract
const color =
change > 0
? 'text-green-500'
: change < 0
? 'text-red-500'
: 'text-gray-600'
const str =
change === 0
? '+0%'
: `${change > 0 ? '+' : '-'}${formatPercent(Math.abs(change))}`
return <div className={clsx(className, color)}>{str}</div>
}

View File

@ -45,7 +45,7 @@ export function ShareModal(props: {
return ( return (
<Modal open={isOpen} setOpen={setOpen} size="md"> <Modal open={isOpen} setOpen={setOpen} size="md">
<Col className="gap-4 rounded bg-white p-4"> <Col className="gap-2.5 rounded bg-white p-4 sm:gap-4">
<Title className="!mt-0 !mb-2" text="Share this market" /> <Title className="!mt-0 !mb-2" text="Share this market" />
<p> <p>
Earn{' '} Earn{' '}
@ -57,7 +57,7 @@ export function ShareModal(props: {
<Button <Button
size="2xl" size="2xl"
color="gradient" color="gradient"
className={'mb-2 flex max-w-xs self-center'} className={'flex max-w-xs self-center'}
onClick={() => { onClick={() => {
copyToClipboard(shareUrl) copyToClipboard(shareUrl)
toast.success('Link copied!', { toast.success('Link copied!', {
@ -68,17 +68,18 @@ export function ShareModal(props: {
> >
{linkIcon} Copy link {linkIcon} Copy link
</Button> </Button>
<Row className={'justify-center'}>or</Row>
{showChallenge && ( {showChallenge && (
<Button <Button
size="lg" size="2xl"
color="gray-white" color="gradient"
className={'mb-2 flex max-w-xs self-center'} className={'mb-2 flex max-w-xs self-center'}
onClick={withTracking( onClick={withTracking(
() => setOpenCreateChallengeModal(true), () => setOpenCreateChallengeModal(true),
'click challenge button' 'click challenge button'
)} )}
> >
<span> Challenge a friend</span> <span> Challenge</span>
<CreateChallengeModal <CreateChallengeModal
isOpen={openCreateChallengeModal} isOpen={openCreateChallengeModal}
setOpen={(open) => { setOpen={(open) => {

View File

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

View File

@ -7,7 +7,6 @@ import { Col } from 'web/components/layout/col'
export function DoubleCarousel(props: { export function DoubleCarousel(props: {
contracts: Contract[] contracts: Contract[]
seeMoreUrl?: string
showTime?: ShowTime showTime?: ShowTime
loadMore?: () => void loadMore?: () => void
}) { }) {
@ -19,7 +18,7 @@ export function DoubleCarousel(props: {
? range(0, Math.floor(contracts.length / 2)).map((col) => { ? range(0, Math.floor(contracts.length / 2)).map((col) => {
const i = col * 2 const i = col * 2
return ( return (
<Col key={contracts[i].id}> <Col className="snap-start scroll-m-4" key={contracts[i].id}>
<ContractCard <ContractCard
contract={contracts[i]} contract={contracts[i]}
className="mb-2 w-96 shrink-0" className="mb-2 w-96 shrink-0"

View File

@ -18,7 +18,6 @@ import { uploadImage } from 'web/lib/firebase/storage'
import { useMutation } from 'react-query' import { useMutation } from 'react-query'
import { FileUploadButton } from './file-upload-button' import { FileUploadButton } from './file-upload-button'
import { linkClass } from './site-link' import { linkClass } from './site-link'
import { useUsers } from 'web/hooks/use-users'
import { mentionSuggestion } from './editor/mention-suggestion' import { mentionSuggestion } from './editor/mention-suggestion'
import { DisplayMention } from './editor/mention' import { DisplayMention } from './editor/mention'
import Iframe from 'common/util/tiptap-iframe' import Iframe from 'common/util/tiptap-iframe'
@ -68,50 +67,42 @@ export function useTextEditor(props: {
}) { }) {
const { placeholder, max, defaultValue = '', disabled, simple } = props const { placeholder, max, defaultValue = '', disabled, simple } = props
const users = useUsers()
const editorClass = clsx( const editorClass = clsx(
proseClass, proseClass,
!simple && 'min-h-[6em]', !simple && 'min-h-[6em]',
'outline-none pt-2 px-4' 'outline-none pt-2 px-4',
'prose-img:select-auto',
'[&_.ProseMirror-selectednode]:outline-dotted [&_*]:outline-indigo-300' // selected img, emebeds
) )
const editor = useEditor( const editor = useEditor({
{ editorProps: { attributes: { class: editorClass } },
editorProps: { attributes: { class: editorClass } }, extensions: [
extensions: [ StarterKit.configure({
StarterKit.configure({ heading: simple ? false : { levels: [1, 2, 3] },
heading: simple ? false : { levels: [1, 2, 3] }, horizontalRule: simple ? false : {},
horizontalRule: simple ? false : {}, }),
}), Placeholder.configure({
Placeholder.configure({ placeholder,
placeholder, emptyEditorClass:
emptyEditorClass: 'before:content-[attr(data-placeholder)] before:text-slate-500 before:float-left before:h-0 cursor-text',
'before:content-[attr(data-placeholder)] before:text-slate-500 before:float-left before:h-0 cursor-text', }),
}), CharacterCount.configure({ limit: max }),
CharacterCount.configure({ limit: max }), simple ? DisplayImage : Image,
simple ? DisplayImage : Image, DisplayLink,
DisplayLink, DisplayMention.configure({ suggestion: mentionSuggestion }),
DisplayMention.configure({ Iframe,
suggestion: mentionSuggestion(users), TiptapTweet,
}), ],
Iframe, content: defaultValue,
TiptapTweet, })
],
content: defaultValue,
},
[!users.length] // passed as useEffect dependency. (re-render editor when users load, to update mention menu)
)
const upload = useUploadMutation(editor) const upload = useUploadMutation(editor)
editor?.setOptions({ editor?.setOptions({
editorProps: { editorProps: {
handlePaste(view, event) { handlePaste(view, event) {
const imageFiles = Array.from(event.clipboardData?.files ?? []).filter( const imageFiles = getImages(event.clipboardData)
(file) => file.type.startsWith('image')
)
if (imageFiles.length) { if (imageFiles.length) {
event.preventDefault() event.preventDefault()
upload.mutate(imageFiles) upload.mutate(imageFiles)
@ -126,6 +117,13 @@ export function useTextEditor(props: {
return // Otherwise, use default paste handler return // Otherwise, use default paste handler
}, },
handleDrop(_view, event, _slice, moved) {
// if dragged from outside
if (!moved) {
event.preventDefault()
upload.mutate(getImages(event.dataTransfer))
}
},
}, },
}) })
@ -136,6 +134,9 @@ export function useTextEditor(props: {
return { editor, upload } return { editor, upload }
} }
const getImages = (data: DataTransfer | null) =>
Array.from(data?.files ?? []).filter((file) => file.type.startsWith('image'))
function isValidIframe(text: string) { function isValidIframe(text: string) {
return /^<iframe.*<\/iframe>$/.test(text) return /^<iframe.*<\/iframe>$/.test(text)
} }
@ -157,7 +158,7 @@ export function TextEditor(props: {
<EditorContent editor={editor} /> <EditorContent editor={editor} />
{/* Toolbar, with buttons for images and embeds */} {/* Toolbar, with buttons for images and embeds */}
<div className="flex h-9 items-center gap-5 pl-4 pr-1"> <div className="flex h-9 items-center gap-5 pl-4 pr-1">
<Tooltip className="flex items-center" text="Add image" noTap> <Tooltip text="Add image" noTap noFade>
<FileUploadButton <FileUploadButton
onFiles={upload.mutate} onFiles={upload.mutate}
className="-m-2.5 flex h-10 w-10 items-center justify-center rounded-full text-gray-400 hover:text-gray-500" className="-m-2.5 flex h-10 w-10 items-center justify-center rounded-full text-gray-400 hover:text-gray-500"
@ -165,7 +166,7 @@ export function TextEditor(props: {
<PhotographIcon className="h-5 w-5" aria-hidden="true" /> <PhotographIcon className="h-5 w-5" aria-hidden="true" />
</FileUploadButton> </FileUploadButton>
</Tooltip> </Tooltip>
<Tooltip className="flex items-center" text="Add embed" noTap> <Tooltip text="Add embed" noTap noFade>
<button <button
type="button" type="button"
onClick={() => setIframeOpen(true)} onClick={() => setIframeOpen(true)}
@ -179,7 +180,7 @@ export function TextEditor(props: {
<CodeIcon className="h-5 w-5" aria-hidden="true" /> <CodeIcon className="h-5 w-5" aria-hidden="true" />
</button> </button>
</Tooltip> </Tooltip>
<Tooltip className="flex items-center" text="Add market" noTap> <Tooltip text="Add market" noTap noFade>
<button <button
type="button" type="button"
onClick={() => setMarketOpen(true)} onClick={() => setMarketOpen(true)}
@ -245,7 +246,7 @@ export function RichContent(props: {
extensions: [ extensions: [
StarterKit, StarterKit,
smallImage ? DisplayImage : Image, smallImage ? DisplayImage : Image,
DisplayLink, DisplayLink.configure({ openOnClick: false }), // stop link opening twice (browser still opens)
DisplayMention, DisplayMention,
Iframe, Iframe,
TiptapTweet, TiptapTweet,

View File

@ -7,7 +7,7 @@ import { Col } from '../layout/col'
import { Modal } from '../layout/modal' import { Modal } from '../layout/modal'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { LoadingIndicator } from '../loading-indicator' import { LoadingIndicator } from '../loading-indicator'
import { embedCode } from '../share-embed-button' import { embedContractCode, embedContractGridCode } from '../share-embed-button'
import { insertContent } from './utils' import { insertContent } from './utils'
export function MarketModal(props: { export function MarketModal(props: {
@ -28,7 +28,11 @@ export function MarketModal(props: {
async function doneAddingContracts() { async function doneAddingContracts() {
setLoading(true) setLoading(true)
insertContent(editor, ...contracts.map(embedCode)) if (contracts.length == 1) {
insertContent(editor, embedContractCode(contracts[0]))
} else if (contracts.length > 1) {
insertContent(editor, embedContractGridCode(contracts))
}
setLoading(false) setLoading(false)
setOpen(false) setOpen(false)
setContracts([]) setContracts([])
@ -42,9 +46,14 @@ export function MarketModal(props: {
{!loading && ( {!loading && (
<Row className="grow justify-end gap-4"> <Row className="grow justify-end gap-4">
{contracts.length > 0 && ( {contracts.length == 1 && (
<Button onClick={doneAddingContracts} color={'indigo'}> <Button onClick={doneAddingContracts} color={'indigo'}>
Embed {contracts.length} question Embed 1 question
</Button>
)}
{contracts.length > 1 && (
<Button onClick={doneAddingContracts} color={'indigo'}>
Embed grid of {contracts.length} question
{contracts.length > 1 && 's'} {contracts.length > 1 && 's'}
</Button> </Button>
)} )}

View File

@ -1,9 +1,9 @@
import type { MentionOptions } from '@tiptap/extension-mention' import type { MentionOptions } from '@tiptap/extension-mention'
import { ReactRenderer } from '@tiptap/react' import { ReactRenderer } from '@tiptap/react'
import { User } from 'common/user'
import { searchInAny } from 'common/util/parse' import { searchInAny } from 'common/util/parse'
import { orderBy } from 'lodash' import { orderBy } from 'lodash'
import tippy from 'tippy.js' import tippy from 'tippy.js'
import { getCachedUsers } from 'web/hooks/use-users'
import { MentionList } from './mention-list' import { MentionList } from './mention-list'
type Suggestion = MentionOptions['suggestion'] type Suggestion = MentionOptions['suggestion']
@ -12,10 +12,12 @@ const beginsWith = (text: string, query: string) =>
text.toLocaleLowerCase().startsWith(query.toLocaleLowerCase()) text.toLocaleLowerCase().startsWith(query.toLocaleLowerCase())
// copied from https://tiptap.dev/api/nodes/mention#usage // copied from https://tiptap.dev/api/nodes/mention#usage
export const mentionSuggestion = (users: User[]): Suggestion => ({ export const mentionSuggestion: Suggestion = {
items: ({ query }) => items: async ({ query }) =>
orderBy( orderBy(
users.filter((u) => searchInAny(query, u.username, u.name)), (await getCachedUsers()).filter((u) =>
searchInAny(query, u.username, u.name)
),
[ [
(u) => [u.name, u.username].some((s) => beginsWith(s, query)), (u) => [u.name, u.username].some((s) => beginsWith(s, query)),
'followerCountCached', 'followerCountCached',
@ -38,7 +40,7 @@ export const mentionSuggestion = (users: User[]): Suggestion => ({
popup = tippy('body', { popup = tippy('body', {
getReferenceClientRect: props.clientRect as any, getReferenceClientRect: props.clientRect as any,
appendTo: () => document.body, appendTo: () => document.body,
content: component.element, content: component?.element,
showOnCreate: true, showOnCreate: true,
interactive: true, interactive: true,
trigger: 'manual', trigger: 'manual',
@ -46,27 +48,27 @@ export const mentionSuggestion = (users: User[]): Suggestion => ({
}) })
}, },
onUpdate(props) { onUpdate(props) {
component.updateProps(props) component?.updateProps(props)
if (!props.clientRect) { if (!props.clientRect) {
return return
} }
popup[0].setProps({ popup?.[0].setProps({
getReferenceClientRect: props.clientRect as any, getReferenceClientRect: props.clientRect as any,
}) })
}, },
onKeyDown(props) { onKeyDown(props) {
if (props.event.key === 'Escape') { if (props.event.key === 'Escape') {
popup[0].hide() popup?.[0].hide()
return true return true
} }
return (component.ref as any)?.onKeyDown(props) return (component?.ref as any)?.onKeyDown(props)
}, },
onExit() { onExit() {
popup[0].destroy() popup?.[0].destroy()
component.destroy() component?.destroy()
}, },
} }
}, },
}) }

View File

@ -6,7 +6,7 @@ import { getOutcomeProbability } from 'common/calculate'
import { FeedBet } from './feed-bets' import { FeedBet } from './feed-bets'
import { FeedLiquidity } from './feed-liquidity' import { FeedLiquidity } from './feed-liquidity'
import { FeedAnswerCommentGroup } from './feed-answer-comment-group' import { FeedAnswerCommentGroup } from './feed-answer-comment-group'
import { FeedCommentThread, CommentInput } from './feed-comments' import { FeedCommentThread, ContractCommentInput } from './feed-comments'
import { User } from 'common/user' import { User } from 'common/user'
import { CommentTipMap } from 'web/hooks/use-tip-txns' import { CommentTipMap } from 'web/hooks/use-tip-txns'
import { LiquidityProvision } from 'common/liquidity-provision' import { LiquidityProvision } from 'common/liquidity-provision'
@ -72,7 +72,7 @@ export function ContractCommentsActivity(props: {
return ( return (
<> <>
<CommentInput <ContractCommentInput
className="mb-5" className="mb-5"
contract={contract} contract={contract}
betsByCurrentUser={(user && betsByUserId[user.id]) ?? []} betsByCurrentUser={(user && betsByUserId[user.id]) ?? []}

View File

@ -9,7 +9,7 @@ import { Avatar } from 'web/components/avatar'
import { Linkify } from 'web/components/linkify' import { Linkify } from 'web/components/linkify'
import clsx from 'clsx' import clsx from 'clsx'
import { import {
CommentInput, ContractCommentInput,
FeedComment, FeedComment,
getMostRecentCommentableBet, getMostRecentCommentableBet,
} from 'web/components/feed/feed-comments' } from 'web/components/feed/feed-comments'
@ -177,7 +177,7 @@ export function FeedAnswerCommentGroup(props: {
className="absolute -left-1 -ml-[1px] mt-[1.25rem] h-2 w-0.5 rotate-90 bg-gray-200" className="absolute -left-1 -ml-[1px] mt-[1.25rem] h-2 w-0.5 rotate-90 bg-gray-200"
aria-hidden="true" aria-hidden="true"
/> />
<CommentInput <ContractCommentInput
contract={contract} contract={contract}
betsByCurrentUser={betsByCurrentUser} betsByCurrentUser={betsByCurrentUser}
commentsByCurrentUser={commentsByCurrentUser} commentsByCurrentUser={commentsByCurrentUser}

View File

@ -13,21 +13,17 @@ import { Avatar } from 'web/components/avatar'
import { OutcomeLabel } from 'web/components/outcome-label' import { OutcomeLabel } from 'web/components/outcome-label'
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
import { firebaseLogin } from 'web/lib/firebase/users' import { firebaseLogin } from 'web/lib/firebase/users'
import { import { createCommentOnContract } from 'web/lib/firebase/comments'
createCommentOnContract,
MAX_COMMENT_LENGTH,
} from 'web/lib/firebase/comments'
import { BetStatusText } from 'web/components/feed/feed-bets' import { BetStatusText } from 'web/components/feed/feed-bets'
import { Col } from 'web/components/layout/col' import { Col } from 'web/components/layout/col'
import { getProbability } from 'common/calculate' import { getProbability } from 'common/calculate'
import { LoadingIndicator } from 'web/components/loading-indicator'
import { PaperAirplaneIcon } from '@heroicons/react/outline'
import { track } from 'web/lib/service/analytics' import { track } from 'web/lib/service/analytics'
import { Tipper } from '../tipper' import { Tipper } from '../tipper'
import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns' import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns'
import { Content, TextEditor, useTextEditor } from '../editor' import { Content } from '../editor'
import { Editor } from '@tiptap/react' import { Editor } from '@tiptap/react'
import { UserLink } from 'web/components/user-link' import { UserLink } from 'web/components/user-link'
import { CommentInput } from '../comment-input'
export function FeedCommentThread(props: { export function FeedCommentThread(props: {
user: User | null | undefined user: User | null | undefined
@ -89,14 +85,16 @@ export function FeedCommentThread(props: {
className="absolute -left-1 -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200" className="absolute -left-1 -ml-[1px] mt-[0.8rem] h-2 w-0.5 rotate-90 bg-gray-200"
aria-hidden="true" aria-hidden="true"
/> />
<CommentInput <ContractCommentInput
contract={contract} contract={contract}
betsByCurrentUser={(user && betsByUserId[user.id]) ?? []} betsByCurrentUser={(user && betsByUserId[user.id]) ?? []}
commentsByCurrentUser={(user && commentsByUserId[user.id]) ?? []} commentsByCurrentUser={(user && commentsByUserId[user.id]) ?? []}
parentCommentId={parentComment.id} parentCommentId={parentComment.id}
replyToUser={replyTo} replyToUser={replyTo}
parentAnswerOutcome={parentComment.answerOutcome} parentAnswerOutcome={parentComment.answerOutcome}
onSubmitComment={() => setShowReply(false)} onSubmitComment={() => {
setShowReply(false)
}}
/> />
</Col> </Col>
)} )}
@ -124,15 +122,12 @@ export function FeedComment(props: {
} = props } = props
const { text, content, userUsername, userName, userAvatarUrl, createdTime } = const { text, content, userUsername, userName, userAvatarUrl, createdTime } =
comment comment
let betOutcome: string | undefined, const betOutcome = comment.betOutcome
bought: string | undefined, let bought: string | undefined
money: string | undefined let money: string | undefined
if (comment.betAmount != null) {
const matchedBet = betsBySameUser.find((bet) => bet.id === comment.betId) bought = comment.betAmount >= 0 ? 'bought' : 'sold'
if (matchedBet) { money = formatMoney(Math.abs(comment.betAmount))
betOutcome = matchedBet.outcome
bought = matchedBet.amount >= 0 ? 'bought' : 'sold'
money = formatMoney(Math.abs(matchedBet.amount))
} }
const [highlighted, setHighlighted] = useState(false) const [highlighted, setHighlighted] = useState(false)
@ -147,7 +142,7 @@ export function FeedComment(props: {
const { userPosition, outcome } = getBettorsLargestPositionBeforeTime( const { userPosition, outcome } = getBettorsLargestPositionBeforeTime(
contract, contract,
comment.createdTime, comment.createdTime,
matchedBet ? [] : betsBySameUser comment.betId ? [] : betsBySameUser
) )
return ( return (
@ -174,7 +169,7 @@ export function FeedComment(props: {
username={userUsername} username={userUsername}
name={userName} name={userName}
/>{' '} />{' '}
{!matchedBet && {!comment.betId != null &&
userPosition > 0 && userPosition > 0 &&
contract.outcomeType !== 'NUMERIC' && ( contract.outcomeType !== 'NUMERIC' && (
<> <>
@ -193,7 +188,6 @@ export function FeedComment(props: {
of{' '} of{' '}
<OutcomeLabel <OutcomeLabel
outcome={betOutcome ? betOutcome : ''} outcome={betOutcome ? betOutcome : ''}
value={(matchedBet as any).value}
contract={contract} contract={contract}
truncate="short" truncate="short"
/> />
@ -270,67 +264,76 @@ function CommentStatus(props: {
) )
} }
//TODO: move commentinput and comment input text area into their own files export function ContractCommentInput(props: {
export function CommentInput(props: {
contract: Contract contract: Contract
betsByCurrentUser: Bet[] betsByCurrentUser: Bet[]
commentsByCurrentUser: ContractComment[] commentsByCurrentUser: ContractComment[]
className?: string className?: string
parentAnswerOutcome?: string | undefined
replyToUser?: { id: string; username: string } replyToUser?: { id: string; username: string }
// Reply to a free response answer
parentAnswerOutcome?: string
// Reply to another comment
parentCommentId?: string parentCommentId?: string
onSubmitComment?: () => void onSubmitComment?: () => void
}) { }) {
const {
contract,
betsByCurrentUser,
commentsByCurrentUser,
className,
parentAnswerOutcome,
parentCommentId,
replyToUser,
onSubmitComment,
} = props
const user = useUser() const user = useUser()
const { editor, upload } = useTextEditor({ async function onSubmitComment(editor: Editor, betId: string | undefined) {
simple: true,
max: MAX_COMMENT_LENGTH,
placeholder:
!!parentCommentId || !!parentAnswerOutcome
? 'Write a reply...'
: 'Write a comment...',
})
const [isSubmitting, setIsSubmitting] = useState(false)
const mostRecentCommentableBet = getMostRecentCommentableBet(
betsByCurrentUser,
commentsByCurrentUser,
user,
parentAnswerOutcome
)
const { id } = mostRecentCommentableBet || { id: undefined }
async function submitComment(betId: string | undefined) {
if (!user) { if (!user) {
track('sign in to comment') track('sign in to comment')
return await firebaseLogin() return await firebaseLogin()
} }
if (!editor || editor.isEmpty || isSubmitting) return
setIsSubmitting(true)
await createCommentOnContract( await createCommentOnContract(
contract.id, props.contract.id,
editor.getJSON(), editor.getJSON(),
user, user,
betId, betId,
parentAnswerOutcome, props.parentAnswerOutcome,
parentCommentId props.parentCommentId
) )
onSubmitComment?.() props.onSubmitComment?.()
setIsSubmitting(false)
} }
const mostRecentCommentableBet = getMostRecentCommentableBet(
props.betsByCurrentUser,
props.commentsByCurrentUser,
user,
props.parentAnswerOutcome
)
const { id } = mostRecentCommentableBet || { id: undefined }
return (
<Col>
<CommentBetArea
betsByCurrentUser={props.betsByCurrentUser}
contract={props.contract}
commentsByCurrentUser={props.commentsByCurrentUser}
parentAnswerOutcome={props.parentAnswerOutcome}
user={useUser()}
className={props.className}
mostRecentCommentableBet={mostRecentCommentableBet}
/>
<CommentInput
replyToUser={props.replyToUser}
parentAnswerOutcome={props.parentAnswerOutcome}
parentCommentId={props.parentCommentId}
onSubmitComment={onSubmitComment}
className={props.className}
presetId={id}
/>
</Col>
)
}
function CommentBetArea(props: {
betsByCurrentUser: Bet[]
contract: Contract
commentsByCurrentUser: ContractComment[]
parentAnswerOutcome?: string
user?: User | null
className?: string
mostRecentCommentableBet?: Bet
}) {
const { betsByCurrentUser, contract, user, mostRecentCommentableBet } = props
const { userPosition, outcome } = getBettorsLargestPositionBeforeTime( const { userPosition, outcome } = getBettorsLargestPositionBeforeTime(
contract, contract,
Date.now(), Date.now(),
@ -339,158 +342,36 @@ export function CommentInput(props: {
const isNumeric = contract.outcomeType === 'NUMERIC' const isNumeric = contract.outcomeType === 'NUMERIC'
if (user?.isBannedFromPosting) return <></>
return ( return (
<Row className={clsx(className, 'mb-2 gap-1 sm:gap-2')}> <Row className={clsx(props.className, 'mb-2 gap-1 sm:gap-2')}>
<Avatar <div className="mb-1 text-gray-500">
avatarUrl={user?.avatarUrl} {mostRecentCommentableBet && (
username={user?.username} <BetStatusText
size="sm" contract={contract}
className="mt-2" bet={mostRecentCommentableBet}
/> isSelf={true}
<div className="min-w-0 flex-1 pl-0.5 text-sm"> hideOutcome={isNumeric || contract.outcomeType === 'FREE_RESPONSE'}
<div className="mb-1 text-gray-500"> />
{mostRecentCommentableBet && ( )}
<BetStatusText {!mostRecentCommentableBet && user && userPosition > 0 && !isNumeric && (
<>
{"You're"}
<CommentStatus
outcome={outcome}
contract={contract} contract={contract}
bet={mostRecentCommentableBet} prob={
isSelf={true} contract.outcomeType === 'BINARY'
hideOutcome={ ? getProbability(contract)
isNumeric || contract.outcomeType === 'FREE_RESPONSE' : undefined
} }
/> />
)} </>
{!mostRecentCommentableBet && user && userPosition > 0 && !isNumeric && ( )}
<>
{"You're"}
<CommentStatus
outcome={outcome}
contract={contract}
prob={
contract.outcomeType === 'BINARY'
? getProbability(contract)
: undefined
}
/>
</>
)}
</div>
<CommentInputTextArea
editor={editor}
upload={upload}
replyToUser={replyToUser}
user={user}
submitComment={submitComment}
isSubmitting={isSubmitting}
submitOn={'mod-enter'}
presetId={id}
/>
</div> </div>
</Row> </Row>
) )
} }
export function CommentInputTextArea(props: {
user: User | undefined | null
replyToUser?: { id: string; username: string }
editor: Editor | null
upload: Parameters<typeof TextEditor>[0]['upload']
submitComment: (id?: string) => void
isSubmitting: boolean
// mod-enter = ctrl-enter or cmd-enter
submitOn?: 'enter' | 'mod-enter'
presetId?: string
}) {
const {
user,
editor,
upload,
submitComment,
presetId,
isSubmitting,
submitOn,
replyToUser,
} = props
useEffect(() => {
editor?.setEditable(!isSubmitting)
}, [isSubmitting, editor])
const submit = () => {
submitComment(presetId)
editor?.commands?.clearContent()
}
useEffect(() => {
if (!editor) {
return
}
// submit on Enter key
editor.setOptions({
editorProps: {
handleKeyDown: (view, event) => {
if (
submitOn &&
event.key === 'Enter' &&
!event.shiftKey &&
(submitOn === 'enter' || event.ctrlKey || event.metaKey) &&
// mention list is closed
!(view.state as any).mention$.active
) {
submit()
event.preventDefault()
return true
}
return false
},
},
})
// insert at mention and focus
if (replyToUser) {
editor
.chain()
.setContent({
type: 'mention',
attrs: { label: replyToUser.username, id: replyToUser.id },
})
.insertContent(' ')
.focus()
.run()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor])
return (
<>
<TextEditor editor={editor} upload={upload}>
{user && !isSubmitting && (
<button
className="btn btn-ghost btn-sm px-2 disabled:bg-inherit disabled:text-gray-300"
disabled={!editor || editor.isEmpty}
onClick={submit}
>
<PaperAirplaneIcon className="m-0 h-[25px] min-w-[22px] rotate-90 p-0" />
</button>
)}
{isSubmitting && (
<LoadingIndicator spinnerClassName={'border-gray-500'} />
)}
</TextEditor>
<Row>
{!user && (
<button
className={'btn btn-outline btn-sm mt-2 normal-case'}
onClick={() => submitComment(presetId)}
>
Add my comment
</button>
)}
</Row>
</>
)
}
function getBettorsLargestPositionBeforeTime( function getBettorsLargestPositionBeforeTime(
contract: Contract, contract: Contract,
createdTime: number, createdTime: number,

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

@ -2,9 +2,9 @@ import clsx from 'clsx'
import { PencilIcon } from '@heroicons/react/outline' import { PencilIcon } from '@heroicons/react/outline'
import { User } from 'common/user' import { User } from 'common/user'
import { useEffect, useState } from 'react' import { useState } from 'react'
import { useFollowers, useFollows } from 'web/hooks/use-follows' import { useFollowers, useFollows } from 'web/hooks/use-follows'
import { prefetchUsers, useUser } from 'web/hooks/use-user' import { usePrefetchUsers, useUser } from 'web/hooks/use-user'
import { FollowList } from './follow-list' import { FollowList } from './follow-list'
import { Col } from './layout/col' import { Col } from './layout/col'
import { Modal } from './layout/modal' import { Modal } from './layout/modal'
@ -105,16 +105,9 @@ function FollowsDialog(props: {
const { user, followingIds, followerIds, defaultTab, isOpen, setIsOpen } = const { user, followingIds, followerIds, defaultTab, isOpen, setIsOpen } =
props props
useEffect(() => {
prefetchUsers([...followingIds, ...followerIds])
}, [followingIds, followerIds])
const currentUser = useUser() const currentUser = useUser()
const discoverUserIds = useDiscoverUsers(user?.id) const discoverUserIds = useDiscoverUsers(user?.id)
useEffect(() => { usePrefetchUsers([...followingIds, ...followerIds, ...discoverUserIds])
prefetchUsers(discoverUserIds)
}, [discoverUserIds])
return ( return (
<Modal open={isOpen} setOpen={setIsOpen}> <Modal open={isOpen} setOpen={setIsOpen}>

View File

@ -7,22 +7,30 @@ import { Button } from 'web/components/button'
import { GroupSelector } from 'web/components/groups/group-selector' import { GroupSelector } from 'web/components/groups/group-selector'
import { import {
addContractToGroup, addContractToGroup,
canModifyGroupContracts,
removeContractFromGroup, removeContractFromGroup,
} from 'web/lib/firebase/groups' } from 'web/lib/firebase/groups'
import { User } from 'common/user' import { User } from 'common/user'
import { Contract } from 'common/contract' import { Contract } from 'common/contract'
import { SiteLink } from 'web/components/site-link' import { SiteLink } from 'web/components/site-link'
import { GroupLink } from 'common/group' import { useGroupsWithContract, useMemberGroupIds } from 'web/hooks/use-group'
import { useGroupsWithContract } from 'web/hooks/use-group' import { Group } from 'common/group'
export function ContractGroupsList(props: { export function ContractGroupsList(props: {
groupLinks: GroupLink[]
contract: Contract contract: Contract
user: User | null | undefined user: User | null | undefined
}) { }) {
const { groupLinks, user, contract } = props const { user, contract } = props
const { groupLinks } = contract
const groups = useGroupsWithContract(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 ( return (
<Col className={'gap-2'}> <Col className={'gap-2'}>
<span className={'text-xl text-indigo-700'}> <span className={'text-xl text-indigo-700'}>
@ -35,7 +43,7 @@ export function ContractGroupsList(props: {
options={{ options={{
showSelector: true, showSelector: true,
showLabel: false, showLabel: false,
ignoreGroupIds: groupLinks.map((g) => g.groupId), ignoreGroupIds: groupLinks?.map((g) => g.groupId),
}} }}
setSelectedGroup={(group) => setSelectedGroup={(group) =>
group && addContractToGroup(group, contract, user.id) group && addContractToGroup(group, contract, user.id)
@ -62,7 +70,7 @@ export function ContractGroupsList(props: {
<Button <Button
color={'gray-white'} color={'gray-white'}
size={'xs'} size={'xs'}
onClick={() => removeContractFromGroup(group, contract, user.id)} onClick={() => removeContractFromGroup(group, contract)}
> >
<XIcon className="h-4 w-4 text-gray-500" /> <XIcon className="h-4 w-4 text-gray-500" />
</Button> </Button>

View File

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

View File

@ -22,7 +22,7 @@ export function GroupAboutPost(props: {
const post = usePost(group.aboutPostId) ?? props.post const post = usePost(group.aboutPostId) ?? props.post
return ( return (
<div className="rounded-md bg-white p-4"> <div className="rounded-md bg-white p-4 ">
{isEditable ? ( {isEditable ? (
<RichEditGroupAboutPost group={group} post={post} /> <RichEditGroupAboutPost group={group} post={post} />
) : ( ) : (

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 { CommentInputTextArea } from 'web/components/feed/feed-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'
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}
submitOn={'enter'}
/>
</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, CheckIcon,
PlusCircleIcon, PlusCircleIcon,
SelectorIcon, SelectorIcon,
UserIcon,
} from '@heroicons/react/outline' } from '@heroicons/react/outline'
import clsx from 'clsx' import clsx from 'clsx'
import { CreateGroupButton } from 'web/components/groups/create-group-button' 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 { useMemberGroups, useOpenGroups } from 'web/hooks/use-group'
import { User } from 'common/user' import { User } from 'common/user'
import { searchInAny } from 'common/util/parse' import { searchInAny } from 'common/util/parse'
import { Row } from 'web/components/layout/row'
export function GroupSelector(props: { export function GroupSelector(props: {
selectedGroup: Group | undefined selectedGroup: Group | undefined
@ -28,13 +30,27 @@ export function GroupSelector(props: {
const { showSelector, showLabel, ignoreGroupIds } = options const { showSelector, showLabel, ignoreGroupIds } = options
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const openGroups = useOpenGroups() const openGroups = useOpenGroups()
const memberGroups = useMemberGroups(creator?.id)
const memberGroupIds = memberGroups?.map((g) => g.id) ?? []
const availableGroups = openGroups const availableGroups = openGroups
.concat( .concat(
(useMemberGroups(creator?.id) ?? []).filter( (memberGroups ?? []).filter(
(g) => !openGroups.map((og) => og.id).includes(g.id) (g) => !openGroups.map((og) => og.id).includes(g.id)
) )
) )
.filter((group) => !ignoreGroupIds?.includes(group.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) => const filteredGroups = availableGroups.filter((group) =>
searchInAny(query, group.name) searchInAny(query, group.name)
) )
@ -96,7 +112,7 @@ export function GroupSelector(props: {
value={group} value={group}
className={({ active }) => className={({ active }) =>
clsx( 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' active ? 'bg-indigo-500 text-white' : 'text-gray-900'
) )
} }
@ -115,11 +131,28 @@ export function GroupSelector(props: {
)} )}
<span <span
className={clsx( className={clsx(
'ml-5 mt-1 block truncate', 'ml-3 mt-1 block flex flex-row justify-between',
selected && 'font-semibold' 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> </span>
</> </>
)} )}

View File

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

View File

@ -27,23 +27,18 @@ export function LandingPagePanel(props: { hotContracts: Contract[] }) {
<div className="m-4 max-w-[550px] self-center"> <div className="m-4 max-w-[550px] self-center">
<h1 className="text-3xl sm:text-6xl xl:text-6xl"> <h1 className="text-3xl sm:text-6xl xl:text-6xl">
<div className="font-semibold sm:mb-2"> <div className="font-semibold sm:mb-2">
Predict{' '} A{' '}
<span className="bg-gradient-to-r from-indigo-500 to-blue-500 bg-clip-text font-bold text-transparent"> <span className="bg-gradient-to-r from-indigo-500 to-blue-500 bg-clip-text font-bold text-transparent">
anything! market
</span> </span>{' '}
for every question
</div> </div>
</h1> </h1>
<Spacer h={6} /> <Spacer h={6} />
<div className="mb-4 px-2 "> <div className="mb-4 px-2 ">
Create a play-money prediction market on any topic you care about Create a play-money prediction market on any topic you care about.
and bet with your friends on what will happen! Trade with your friends to forecast the future.
<br /> <br />
{/* <br />
Sign up and get {formatMoney(1000)} - worth $10 to your{' '}
<SiteLink className="font-semibold" href="/charity">
favorite charity.
</SiteLink>
<br /> */}
</div> </div>
</div> </div>
<Spacer h={6} /> <Spacer h={6} />

View File

@ -0,0 +1,75 @@
import { useState } from 'react'
import { Row } from 'web/components/layout/row'
import { Modal } from 'web/components/layout/modal'
import { Col } from 'web/components/layout/col'
import { formatMoney } from 'common/util/format'
import { Avatar } from 'web/components/avatar'
import { UserLink } from 'web/components/user-link'
import { Button } from 'web/components/button'
export type MultiUserLinkInfo = {
name: string
username: string
avatarUrl: string | undefined
amount: number
}
export function MultiUserTransactionLink(props: {
userInfos: MultiUserLinkInfo[]
modalLabel: string
}) {
const { userInfos, modalLabel } = props
const [open, setOpen] = useState(false)
const maxShowCount = 5
return (
<Row>
<Button
size={'xs'}
color={'gray-white'}
className={'z-10 mr-1 gap-1 bg-transparent'}
onClick={(e) => {
e.stopPropagation()
setOpen(true)
}}
>
<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'}
/>
)
)}
{userInfos.length > maxShowCount && (
<span>& {userInfos.length - maxShowCount} more</span>
)}
</Row>
</Button>
<Modal open={open} setOpen={setOpen} size={'sm'}>
<Col className="items-start gap-4 rounded-md bg-white p-6">
<span className={'text-xl'}>{modalLabel}</span>
{userInfos.map((userInfo) => (
<Row
key={userInfo.username + 'list'}
className="w-full items-center gap-2"
>
<span className="text-primary min-w-[3.5rem]">
+{formatMoney(userInfo.amount)}
</span>
<Avatar
username={userInfo.username}
avatarUrl={userInfo.avatarUrl}
/>
<UserLink name={userInfo.name} username={userInfo.username} />
</Row>
))}
</Col>
</Modal>
</Row>
)
}

View File

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

View File

@ -64,7 +64,7 @@ export function BottomNavBar() {
item={{ item={{
name: formatMoney(user.balance), name: formatMoney(user.balance),
trackingEventName: 'profile', trackingEventName: 'profile',
href: `/${user.username}?tab=bets`, href: `/${user.username}?tab=trades`,
icon: () => ( icon: () => (
<Avatar <Avatar
className="mx-auto my-1" className="mx-auto my-1"

View File

@ -8,10 +8,10 @@ import { trackCallback } from 'web/lib/service/analytics'
export function ProfileSummary(props: { user: User }) { export function ProfileSummary(props: { user: User }) {
const { user } = props const { user } = props
return ( return (
<Link href={`/${user.username}?tab=bets`}> <Link href={`/${user.username}?tab=trades`}>
<a <a
onClick={trackCallback('sidebar: profile')} 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 /> <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 && <SignInButton className="mb-4" />}
{user && ( {user && <ProfileSummary user={user} />}
<div className="min-h-[80px] w-full">
<ProfileSummary user={user} />
</div>
)}
{/* Mobile navigation */} {/* Mobile navigation */}
<div className="flex min-h-0 shrink flex-col gap-1 lg:hidden"> <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> </div>
{/* Desktop navigation */} {/* 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) => ( {navigationOptions.map((item) => (
<SidebarItem key={item.href} item={item} currentPage={currentPage} /> <SidebarItem key={item.href} item={item} currentPage={currentPage} />
))} ))}
@ -264,7 +260,7 @@ export default function Sidebar(props: { className?: string }) {
buttonContent={<MoreButton />} buttonContent={<MoreButton />}
/> />
{user && <CreateQuestionButton user={user} />} {user && !user.isBannedFromPosting && <CreateQuestionButton />}
</div> </div>
</nav> </nav>
) )

View File

@ -12,11 +12,9 @@ export default function NotificationsIcon(props: { className?: string }) {
const privateUser = usePrivateUser() const privateUser = usePrivateUser()
return ( return (
<Row className={clsx('justify-center')}> <Row className="relative justify-center">
<div className={'relative'}> {privateUser && <UnseenNotificationsBubble privateUser={privateUser} />}
{privateUser && <UnseenNotificationsBubble privateUser={privateUser} />} <BellIcon className={clsx(props.className)} />
<BellIcon className={clsx(props.className)} />
</div>
</Row> </Row>
) )
} }
@ -32,11 +30,11 @@ function UnseenNotificationsBubble(props: { privateUser: PrivateUser }) {
const notifications = useUnseenGroupedNotification(privateUser) const notifications = useUnseenGroupedNotification(privateUser)
if (!notifications || notifications.length === 0 || seen) { if (!notifications || notifications.length === 0 || seen) {
return <div /> return null
} }
return ( 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.length > NOTIFICATIONS_PER_PAGE
? `${NOTIFICATIONS_PER_PAGE}+` ? `${NOTIFICATIONS_PER_PAGE}+`
: notifications.length} : notifications.length}

View File

@ -203,7 +203,7 @@ function NumericBuyPanel(props: {
)} )}
onClick={betDisabled ? undefined : submitBet} onClick={betDisabled ? undefined : submitBet}
> >
{isSubmitting ? 'Submitting...' : 'Submit bet'} {isSubmitting ? 'Submitting...' : 'Submit'}
</button> </button>
)} )}

View File

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

View File

@ -2,8 +2,8 @@ import { InfoBox } from './info-box'
export const PlayMoneyDisclaimer = () => ( export const PlayMoneyDisclaimer = () => (
<InfoBox <InfoBox
title="Play-money betting" title="Play-money trading"
className="mt-4 max-w-md" className="mt-4 max-w-md"
text="Mana (M$) is the play-money used by our platform to keep track of your bets. It's completely free for you and your friends to get started!" text="Mana (M$) is the play-money used by our platform to keep track of your trades. It's completely free for you and your friends to get started!"
/> />
) )

View File

@ -8,16 +8,21 @@ import { formatTime } from 'web/lib/util/time'
export const PortfolioValueGraph = memo(function PortfolioValueGraph(props: { export const PortfolioValueGraph = memo(function PortfolioValueGraph(props: {
portfolioHistory: PortfolioMetrics[] portfolioHistory: PortfolioMetrics[]
mode: 'value' | 'profit'
height?: number height?: number
includeTime?: boolean includeTime?: boolean
}) { }) {
const { portfolioHistory, height, includeTime } = props const { portfolioHistory, height, includeTime, mode } = props
const { width } = useWindowSize() const { width } = useWindowSize()
const points = portfolioHistory.map((p) => { const points = portfolioHistory.map((p) => {
const { timestamp, balance, investmentValue, totalDeposits } = p
const value = balance + investmentValue
const profit = value - totalDeposits
return { return {
x: new Date(p.timestamp), x: new Date(timestamp),
y: p.balance + p.investmentValue, y: mode === 'value' ? value : profit,
} }
}) })
const data = [{ id: 'Value', data: points, color: '#11b981' }] 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 { Period } from 'web/lib/firebase/users'
import { Col } from '../layout/col' import { Col } from '../layout/col'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { Spacer } from '../layout/spacer'
import { PortfolioValueGraph } from './portfolio-value-graph' import { PortfolioValueGraph } from './portfolio-value-graph'
export const PortfolioValueSection = memo( export const PortfolioValueSection = memo(
@ -24,15 +25,16 @@ export const PortfolioValueSection = memo(
return <></> return <></>
} }
const { balance, investmentValue } = lastPortfolioMetrics const { balance, investmentValue, totalDeposits } = lastPortfolioMetrics
const totalValue = balance + investmentValue const totalValue = balance + investmentValue
const totalProfit = totalValue - totalDeposits
return ( return (
<> <>
<Row className="gap-8"> <Row className="gap-8">
<Col className="flex-1 justify-center"> <Col className="flex-1 justify-center">
<div className="text-sm text-gray-500">Portfolio value</div> <div className="text-sm text-gray-500">Profit</div>
<div className="text-lg">{formatMoney(totalValue)}</div> <div className="text-lg">{formatMoney(totalProfit)}</div>
</Col> </Col>
<select <select
className="select select-bordered self-start" className="select select-bordered self-start"
@ -42,6 +44,7 @@ export const PortfolioValueSection = memo(
}} }}
> >
<option value="allTime">All time</option> <option value="allTime">All time</option>
<option value="monthly">Last Month</option>
<option value="weekly">Last 7d</option> <option value="weekly">Last 7d</option>
<option value="daily">Last 24h</option> <option value="daily">Last 24h</option>
</select> </select>
@ -49,6 +52,17 @@ export const PortfolioValueSection = memo(
<PortfolioValueGraph <PortfolioValueGraph
portfolioHistory={currPortfolioHistory} portfolioHistory={currPortfolioHistory}
includeTime={portfolioPeriod == 'daily'} 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

@ -11,7 +11,7 @@ export function LoansModal(props: {
<Modal open={isOpen} setOpen={setOpen}> <Modal open={isOpen} setOpen={setOpen}>
<Col className="items-center gap-4 rounded-md bg-white px-8 py-6"> <Col className="items-center gap-4 rounded-md bg-white px-8 py-6">
<span className={'text-8xl'}>🏦</span> <span className={'text-8xl'}>🏦</span>
<span className="text-xl">Daily loans on your bets</span> <span className="text-xl">Daily loans on your trades</span>
<Col className={'gap-2'}> <Col className={'gap-2'}>
<span className={'text-indigo-700'}> What are daily loans?</span> <span className={'text-indigo-700'}> What are daily loans?</span>
<span className={'ml-2'}> <span className={'ml-2'}>

View File

@ -1,7 +1,7 @@
import clsx from 'clsx' import clsx from 'clsx'
import { User } from 'common/user' import { User } from 'common/user'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { prefetchUsers, useUserById } from 'web/hooks/use-user' import { usePrefetchUsers, useUserById } from 'web/hooks/use-user'
import { Col } from './layout/col' import { Col } from './layout/col'
import { Modal } from './layout/modal' import { Modal } from './layout/modal'
import { Tabs } from './layout/tabs' import { Tabs } from './layout/tabs'
@ -56,9 +56,7 @@ function ReferralsDialog(props: {
} }
}, [isOpen, referredByUser, user.referredByUserId]) }, [isOpen, referredByUser, user.referredByUserId])
useEffect(() => { usePrefetchUsers(referralIds)
prefetchUsers(referralIds)
}, [referralIds])
return ( return (
<Modal open={isOpen} setOpen={setIsOpen}> <Modal open={isOpen} setOpen={setIsOpen}>

Some files were not shown because too many files have changed in this diff Show More