Merge branch 'main' into mod-enter
This commit is contained in:
commit
90bf5cc39f
158
common/calculate-metrics.ts
Normal file
158
common/calculate-metrics.ts
Normal 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
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
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.
|
||||
// They're uniquely identified by the pair contractId/betId.
|
||||
|
@ -20,19 +20,31 @@ export type Comment<T extends AnyCommentType = AnyCommentType> = {
|
|||
userAvatarUrl?: string
|
||||
} & T
|
||||
|
||||
type OnContract = {
|
||||
export type OnContract = {
|
||||
commentType: 'contract'
|
||||
contractId: string
|
||||
contractSlug: string
|
||||
contractQuestion: string
|
||||
answerOutcome?: string
|
||||
betId?: string
|
||||
|
||||
// denormalized from contract
|
||||
contractSlug: string
|
||||
contractQuestion: string
|
||||
|
||||
// denormalized from bet
|
||||
betAmount?: number
|
||||
betOutcome?: string
|
||||
}
|
||||
|
||||
type OnGroup = {
|
||||
export type OnGroup = {
|
||||
commentType: 'group'
|
||||
groupId: string
|
||||
}
|
||||
|
||||
export type OnPost = {
|
||||
commentType: 'post'
|
||||
postId: string
|
||||
}
|
||||
|
||||
export type ContractComment = Comment<OnContract>
|
||||
export type GroupComment = Comment<OnGroup>
|
||||
export type PostComment = Comment<OnPost>
|
||||
|
|
|
@ -27,10 +27,10 @@ export function contractMetrics(contract: Contract) {
|
|||
export function contractTextDetails(contract: Contract) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const dayjs = require('dayjs')
|
||||
const { closeTime, tags } = contract
|
||||
const { closeTime, groupLinks } = contract
|
||||
const { createdDate, resolvedDate, volumeLabel } = contractMetrics(contract)
|
||||
|
||||
const hashtags = tags.map((tag) => `#${tag}`)
|
||||
const groupHashtags = groupLinks?.slice(0, 5).map((g) => `#${g.name}`)
|
||||
|
||||
return (
|
||||
`${resolvedDate ? `${createdDate} - ${resolvedDate}` : createdDate}` +
|
||||
|
@ -40,7 +40,7 @@ export function contractTextDetails(contract: Contract) {
|
|||
).format('MMM D, h:mma')}`
|
||||
: '') +
|
||||
` • ${volumeLabel}` +
|
||||
(hashtags.length > 0 ? ` • ${hashtags.join(' ')}` : '')
|
||||
(groupHashtags ? ` • ${groupHashtags.join(' ')}` : '')
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -92,6 +92,7 @@ export const getOpenGraphProps = (contract: Contract) => {
|
|||
creatorAvatarUrl,
|
||||
description,
|
||||
numericValue,
|
||||
resolution,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -103,6 +104,7 @@ export type OgCardProps = {
|
|||
creatorUsername: string
|
||||
creatorAvatarUrl?: string
|
||||
numericValue?: string
|
||||
resolution?: string
|
||||
}
|
||||
|
||||
export function buildCardUrl(props: OgCardProps, challenge?: Challenge) {
|
||||
|
@ -113,22 +115,32 @@ export function buildCardUrl(props: OgCardProps, challenge?: Challenge) {
|
|||
creatorOutcome,
|
||||
acceptorOutcome,
|
||||
} = challenge || {}
|
||||
const {
|
||||
probability,
|
||||
numericValue,
|
||||
resolution,
|
||||
creatorAvatarUrl,
|
||||
question,
|
||||
metadata,
|
||||
creatorUsername,
|
||||
creatorName,
|
||||
} = props
|
||||
const { userName, userAvatarUrl } = acceptances?.[0] ?? {}
|
||||
|
||||
const probabilityParam =
|
||||
props.probability === undefined
|
||||
probability === undefined
|
||||
? ''
|
||||
: `&probability=${encodeURIComponent(props.probability ?? '')}`
|
||||
: `&probability=${encodeURIComponent(probability ?? '')}`
|
||||
|
||||
const numericValueParam =
|
||||
props.numericValue === undefined
|
||||
numericValue === undefined
|
||||
? ''
|
||||
: `&numericValue=${encodeURIComponent(props.numericValue ?? '')}`
|
||||
: `&numericValue=${encodeURIComponent(numericValue ?? '')}`
|
||||
|
||||
const creatorAvatarUrlParam =
|
||||
props.creatorAvatarUrl === undefined
|
||||
creatorAvatarUrl === undefined
|
||||
? ''
|
||||
: `&creatorAvatarUrl=${encodeURIComponent(props.creatorAvatarUrl ?? '')}`
|
||||
: `&creatorAvatarUrl=${encodeURIComponent(creatorAvatarUrl ?? '')}`
|
||||
|
||||
const challengeUrlParams = challenge
|
||||
? `&creatorAmount=${creatorAmount}&creatorOutcome=${creatorOutcome}` +
|
||||
|
@ -136,16 +148,21 @@ export function buildCardUrl(props: OgCardProps, challenge?: Challenge) {
|
|||
`&acceptedName=${userName ?? ''}&acceptedAvatarUrl=${userAvatarUrl ?? ''}`
|
||||
: ''
|
||||
|
||||
const resolutionUrlParam = resolution
|
||||
? `&resolution=${encodeURIComponent(resolution)}`
|
||||
: ''
|
||||
|
||||
// URL encode each of the props, then add them as query params
|
||||
return (
|
||||
`https://manifold-og-image.vercel.app/m.png` +
|
||||
`?question=${encodeURIComponent(props.question)}` +
|
||||
`?question=${encodeURIComponent(question)}` +
|
||||
probabilityParam +
|
||||
numericValueParam +
|
||||
`&metadata=${encodeURIComponent(props.metadata)}` +
|
||||
`&creatorName=${encodeURIComponent(props.creatorName)}` +
|
||||
`&metadata=${encodeURIComponent(metadata)}` +
|
||||
`&creatorName=${encodeURIComponent(creatorName)}` +
|
||||
creatorAvatarUrlParam +
|
||||
`&creatorUsername=${encodeURIComponent(props.creatorUsername)}` +
|
||||
challengeUrlParams
|
||||
`&creatorUsername=${encodeURIComponent(creatorUsername)}` +
|
||||
challengeUrlParams +
|
||||
resolutionUrlParam
|
||||
)
|
||||
}
|
||||
|
|
|
@ -87,6 +87,12 @@ export type CPMM = {
|
|||
pool: { [outcome: string]: number }
|
||||
p: number // probability constant in y^p * n^(1-p) = k
|
||||
totalLiquidity: number // in M$
|
||||
prob: number
|
||||
probChanges: {
|
||||
day: number
|
||||
week: number
|
||||
month: number
|
||||
}
|
||||
}
|
||||
|
||||
export type Binary = {
|
||||
|
|
|
@ -34,6 +34,11 @@ export const FIREBASE_CONFIG = ENV_CONFIG.firebaseConfig
|
|||
export const PROJECT_ID = ENV_CONFIG.firebaseConfig.projectId
|
||||
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
|
||||
export const CORS_ORIGIN_MANIFOLD = new RegExp(
|
||||
'^https?://(?:[a-zA-Z0-9\\-]+\\.)*' + escapeRegExp(ENV_CONFIG.domain) + '$'
|
||||
|
|
|
@ -73,6 +73,7 @@ export const PROD_CONFIG: EnvConfig = {
|
|||
'manticmarkets@gmail.com', // Manifold
|
||||
'iansphilips@gmail.com', // Ian
|
||||
'd4vidchee@gmail.com', // D4vid
|
||||
'federicoruizcassarino@gmail.com', // Fede
|
||||
],
|
||||
visibility: 'PUBLIC',
|
||||
|
||||
|
|
|
@ -6,13 +6,11 @@ export type Group = {
|
|||
creatorId: string // User id
|
||||
createdTime: number
|
||||
mostRecentActivityTime: number
|
||||
memberIds: string[] // User ids
|
||||
anyoneCanJoin: boolean
|
||||
contractIds: string[]
|
||||
|
||||
totalContracts: number
|
||||
totalMembers: number
|
||||
aboutPostId?: string
|
||||
chatDisabled?: boolean
|
||||
mostRecentChatActivityTime?: number
|
||||
mostRecentContractAddedTime?: number
|
||||
}
|
||||
export const MAX_GROUP_NAME_LENGTH = 75
|
||||
|
|
|
@ -118,7 +118,7 @@ const getFreeResponseContractLoanUpdate = (
|
|||
contract: FreeResponseContract | MultipleChoiceContract,
|
||||
bets: Bet[]
|
||||
) => {
|
||||
const openBets = bets.filter((bet) => bet.isSold || bet.sale)
|
||||
const openBets = bets.filter((bet) => !bet.isSold && !bet.sale)
|
||||
|
||||
return openBets.map((bet) => {
|
||||
const loanAmount = bet.loanAmount ?? 0
|
||||
|
|
|
@ -123,6 +123,8 @@ const getBinaryCpmmProps = (initialProb: number, ante: number) => {
|
|||
initialProbability: p,
|
||||
p,
|
||||
pool: pool,
|
||||
prob: initialProb,
|
||||
probChanges: { day: 0, week: 0, month: 0 },
|
||||
}
|
||||
|
||||
return system
|
||||
|
|
|
@ -15,6 +15,7 @@ export type Notification = {
|
|||
sourceUserUsername?: string
|
||||
sourceUserAvatarUrl?: string
|
||||
sourceText?: string
|
||||
data?: string
|
||||
|
||||
sourceContractTitle?: string
|
||||
sourceContractCreatorUsername?: string
|
||||
|
|
|
@ -13,7 +13,10 @@ export const getRedeemableAmount = (bets: RedeemableBet[]) => {
|
|||
const yesShares = sumBy(yesBets, (b) => b.shares)
|
||||
const noShares = sumBy(noBets, (b) => b.shares)
|
||||
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 loanPayment = loanAmount * soldFrac
|
||||
const netAmount = shares - loanPayment
|
||||
|
|
|
@ -35,7 +35,7 @@ export default Node.create<IframeOptions>({
|
|||
HTMLAttributes: {
|
||||
class: 'iframe-wrapper' + ' ' + wrapperClasses,
|
||||
// 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: {
|
||||
default: 0,
|
||||
},
|
||||
height: {
|
||||
default: 0,
|
||||
},
|
||||
allowfullscreen: {
|
||||
default: this.options.allowFullscreen,
|
||||
parseHTML: () => this.options.allowFullscreen,
|
||||
|
@ -60,6 +63,11 @@ export default Node.create<IframeOptions>({
|
|||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
this.options.HTMLAttributes.style =
|
||||
this.options.HTMLAttributes.style +
|
||||
' height: ' +
|
||||
HTMLAttributes.height +
|
||||
';'
|
||||
return [
|
||||
'div',
|
||||
this.options.HTMLAttributes,
|
||||
|
|
|
@ -54,19 +54,33 @@ Returns the authenticated user.
|
|||
|
||||
Gets all groups, in no particular order.
|
||||
|
||||
Parameters:
|
||||
- `availableToUserId`: Optional. if specified, only groups that the user can
|
||||
join and groups they've already joined will be returned.
|
||||
|
||||
Requires no authorization.
|
||||
|
||||
### `GET /v0/groups/[slug]`
|
||||
### `GET /v0/group/[slug]`
|
||||
|
||||
Gets a group by its slug.
|
||||
|
||||
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.
|
||||
|
||||
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`
|
||||
|
||||
|
|
|
@ -12,7 +12,9 @@ service cloud.firestore {
|
|||
'taowell@gmail.com',
|
||||
'abc.sinclair@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']);
|
||||
}
|
||||
|
||||
match /groups/{groupId} {
|
||||
match /{somePath=**}/groupMembers/{memberId} {
|
||||
allow read;
|
||||
}
|
||||
|
||||
match /{somePath=**}/groupContracts/{contractId} {
|
||||
allow read;
|
||||
}
|
||||
|
||||
match /groups/{groupId} {
|
||||
allow read;
|
||||
allow update: if (request.auth.uid == resource.data.creatorId || isAdmin())
|
||||
&& request.resource.data.diff(resource.data)
|
||||
.affectedKeys()
|
||||
.hasOnly(['name', 'about', 'contractIds', 'memberIds', 'anyoneCanJoin', 'aboutPostId' ]);
|
||||
allow update: if (request.auth.uid in resource.data.memberIds || resource.data.anyoneCanJoin)
|
||||
&& request.resource.data.diff(resource.data)
|
||||
.affectedKeys()
|
||||
.hasOnly([ 'contractIds', 'memberIds' ]);
|
||||
.hasOnly(['name', 'about', 'anyoneCanJoin', 'aboutPostId' ]);
|
||||
allow delete: if request.auth.uid == resource.data.creatorId;
|
||||
|
||||
function isMember() {
|
||||
return request.auth.uid in get(/databases/$(database)/documents/groups/$(groupId)).data.memberIds;
|
||||
match /groupContracts/{contractId} {
|
||||
allow write: if isGroupMember() || request.auth.uid == get(/databases/$(database)/documents/groups/$(groupId)).data.creatorId
|
||||
}
|
||||
|
||||
match /groupMembers/{memberId}{
|
||||
allow create: if request.auth.uid == get(/databases/$(database)/documents/groups/$(groupId)).data.creatorId || (request.auth.uid == request.resource.data.userId && get(/databases/$(database)/documents/groups/$(groupId)).data.anyoneCanJoin);
|
||||
allow delete: if request.auth.uid == resource.data.userId;
|
||||
}
|
||||
|
||||
function isGroupMember() {
|
||||
return exists(/databases/$(database)/documents/groups/$(groupId)/groupMembers/$(request.auth.uid));
|
||||
}
|
||||
|
||||
match /comments/{commentId} {
|
||||
allow read;
|
||||
allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) && isMember();
|
||||
allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) && isGroupMember();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
match /posts/{postId} {
|
||||
|
@ -188,6 +205,10 @@ service cloud.firestore {
|
|||
.affectedKeys()
|
||||
.hasOnly(['name', 'content']);
|
||||
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) ;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -37,6 +37,45 @@ export const changeUser = async (
|
|||
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) => {
|
||||
if (update.username) {
|
||||
update.username = cleanUsername(update.username)
|
||||
|
@ -58,42 +97,7 @@ export const changeUser = async (
|
|||
|
||||
const userRef = firestore.collection('users').doc(user.id)
|
||||
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)
|
||||
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))
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -58,13 +58,23 @@ export const creategroup = newEndpoint({}, async (req, auth) => {
|
|||
createdTime: Date.now(),
|
||||
mostRecentActivityTime: Date.now(),
|
||||
// TODO: allow users to add contract ids on group creation
|
||||
contractIds: [],
|
||||
anyoneCanJoin,
|
||||
memberIds,
|
||||
totalContracts: 0,
|
||||
totalMembers: memberIds.length,
|
||||
}
|
||||
|
||||
await groupRef.create(group)
|
||||
|
||||
// create a GroupMemberDoc for each member
|
||||
await Promise.all(
|
||||
memberIds.map((memberId) =>
|
||||
groupRef.collection('groupMembers').doc(memberId).create({
|
||||
userId: memberId,
|
||||
createdTime: Date.now(),
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
return { status: 'success', group: group }
|
||||
})
|
||||
|
||||
|
|
|
@ -155,8 +155,14 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
|
|||
}
|
||||
|
||||
group = groupDoc.data() as Group
|
||||
const groupMembersSnap = await firestore
|
||||
.collection(`groups/${groupId}/groupMembers`)
|
||||
.get()
|
||||
const groupMemberDocs = groupMembersSnap.docs.map(
|
||||
(doc) => doc.data() as { userId: string; createdTime: number }
|
||||
)
|
||||
if (
|
||||
!group.memberIds.includes(user.id) &&
|
||||
!groupMemberDocs.map((m) => m.userId).includes(user.id) &&
|
||||
!group.anyoneCanJoin &&
|
||||
group.creatorId !== user.id
|
||||
) {
|
||||
|
@ -227,11 +233,20 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
|
|||
await contractRef.create(contract)
|
||||
|
||||
if (group != null) {
|
||||
if (!group.contractIds.includes(contractRef.id)) {
|
||||
const groupContractsSnap = await firestore
|
||||
.collection(`groups/${groupId}/groupContracts`)
|
||||
.get()
|
||||
const groupContracts = groupContractsSnap.docs.map(
|
||||
(doc) => doc.data() as { contractId: string; createdTime: number }
|
||||
)
|
||||
if (!groupContracts.map((c) => c.contractId).includes(contractRef.id)) {
|
||||
await createGroupLinks(group, [contractRef.id], auth.uid)
|
||||
const groupDocRef = firestore.collection('groups').doc(group.id)
|
||||
groupDocRef.update({
|
||||
contractIds: uniq([...group.contractIds, contractRef.id]),
|
||||
const groupContractRef = firestore
|
||||
.collection(`groups/${groupId}/groupContracts`)
|
||||
.doc(contract.id)
|
||||
await groupContractRef.set({
|
||||
contractId: contract.id,
|
||||
createdTime: Date.now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 = {}
|
||||
// The following functions modify the userToReasonTexts object in place.
|
||||
|
||||
|
@ -192,16 +183,6 @@ export const createNotification = async (
|
|||
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)
|
||||
|
@ -737,3 +718,38 @@ export async function filterUserIdsForOnlyFollowerIds(
|
|||
)
|
||||
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))
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import * as admin from 'firebase-admin'
|
||||
import { z } from 'zod'
|
||||
import { uniq } from 'lodash'
|
||||
|
||||
import { PrivateUser, User } from '../../common/user'
|
||||
import { getUser, getUserByUsername, getValues } from './utils'
|
||||
|
@ -17,7 +16,7 @@ import {
|
|||
|
||||
import { track } from './analytics'
|
||||
import { APIError, newEndpoint, validate } from './api'
|
||||
import { Group, NEW_USER_GROUP_SLUGS } from '../../common/group'
|
||||
import { Group } from '../../common/group'
|
||||
import { SUS_STARTING_BALANCE, STARTING_BALANCE } from '../../common/economy'
|
||||
|
||||
const bodySchema = z.object({
|
||||
|
@ -117,23 +116,8 @@ const addUserToDefaultGroups = async (user: User) => {
|
|||
firestore.collection('groups').where('slug', '==', slug)
|
||||
)
|
||||
await firestore
|
||||
.collection('groups')
|
||||
.doc(groups[0].id)
|
||||
.update({
|
||||
memberIds: uniq(groups[0].memberIds.concat(user.id)),
|
||||
})
|
||||
}
|
||||
|
||||
for (const slug of NEW_USER_GROUP_SLUGS) {
|
||||
const groups = await getValues<Group>(
|
||||
firestore.collection('groups').where('slug', '==', slug)
|
||||
)
|
||||
const group = groups[0]
|
||||
await firestore
|
||||
.collection('groups')
|
||||
.doc(group.id)
|
||||
.update({
|
||||
memberIds: uniq(group.memberIds.concat(user.id)),
|
||||
})
|
||||
.collection(`groups/${groups[0].id}/groupMembers`)
|
||||
.doc(user.id)
|
||||
.set({ userId: user.id, createdTime: Date.now() })
|
||||
}
|
||||
}
|
||||
|
|
|
@ -351,8 +351,7 @@
|
|||
font-size: 16px;
|
||||
margin: 0;
|
||||
" />
|
||||
Resolve your market to earn {{creatorFee}} as the
|
||||
creator commission.
|
||||
Please resolve your market.
|
||||
<br style="
|
||||
font-family: 'Helvetica Neue', Helvetica,
|
||||
Arial, sans-serif;
|
||||
|
|
|
@ -4,7 +4,6 @@ import { Bet } from '../../common/bet'
|
|||
import { getProbability } from '../../common/calculate'
|
||||
import { Comment } from '../../common/comment'
|
||||
import { Contract } from '../../common/contract'
|
||||
import { DPM_CREATOR_FEE } from '../../common/fees'
|
||||
import { PrivateUser, User } from '../../common/user'
|
||||
import {
|
||||
formatLargeNumber,
|
||||
|
@ -187,7 +186,7 @@ export const sendPersonalFollowupEmail = async (
|
|||
|
||||
const emailBody = `Hi ${firstName},
|
||||
|
||||
Thanks for signing up! I'm one of the cofounders of Manifold Markets, and was wondering how you've found your exprience on the platform so far?
|
||||
Thanks for signing up! I'm one of the cofounders of Manifold Markets, and was wondering how you've found your experience on the platform so far?
|
||||
|
||||
If you haven't already, I encourage you to try creating your own prediction market (https://manifold.markets/create) and joining our Discord chat (https://discord.com/invite/eHQBNBqXuh).
|
||||
|
||||
|
@ -322,7 +321,7 @@ export const sendMarketCloseEmail = async (
|
|||
const { username, name, id: userId } = user
|
||||
const firstName = name.split(' ')[0]
|
||||
|
||||
const { question, slug, volume, mechanism, collectedFees } = contract
|
||||
const { question, slug, volume } = contract
|
||||
|
||||
const url = `https://${DOMAIN}/${username}/${slug}`
|
||||
const emailType = 'market-resolve'
|
||||
|
@ -339,10 +338,6 @@ export const sendMarketCloseEmail = async (
|
|||
userId,
|
||||
name: firstName,
|
||||
volume: formatMoney(volume),
|
||||
creatorFee:
|
||||
mechanism === 'dpm-2'
|
||||
? `${DPM_CREATOR_FEE * 100}% of the profits`
|
||||
: formatMoney(collectedFees.creatorFee),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
},
|
||||
}
|
|
@ -21,9 +21,7 @@ export * from './on-follow-user'
|
|||
export * from './on-unfollow-user'
|
||||
export * from './on-create-liquidity-provision'
|
||||
export * from './on-update-group'
|
||||
export * from './on-create-group'
|
||||
export * from './on-update-user'
|
||||
export * from './on-create-comment-on-group'
|
||||
export * from './on-create-txn'
|
||||
export * from './on-delete-group'
|
||||
export * from './score-contracts'
|
||||
|
@ -72,7 +70,6 @@ import { unsubscribe } from './unsubscribe'
|
|||
import { stripewebhook, createcheckoutsession } from './stripe'
|
||||
import { getcurrentuser } from './get-current-user'
|
||||
import { acceptchallenge } from './accept-challenge'
|
||||
import { getcustomtoken } from './get-custom-token'
|
||||
import { createpost } from './create-post'
|
||||
|
||||
const toCloudFunction = ({ opts, handler }: EndpointDefinition) => {
|
||||
|
@ -98,7 +95,6 @@ const stripeWebhookFunction = toCloudFunction(stripewebhook)
|
|||
const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession)
|
||||
const getCurrentUserFunction = toCloudFunction(getcurrentuser)
|
||||
const acceptChallenge = toCloudFunction(acceptchallenge)
|
||||
const getCustomTokenFunction = toCloudFunction(getcustomtoken)
|
||||
const createPostFunction = toCloudFunction(createpost)
|
||||
|
||||
export {
|
||||
|
@ -122,6 +118,5 @@ export {
|
|||
createCheckoutSessionFunction as createcheckoutsession,
|
||||
getCurrentUserFunction as getcurrentuser,
|
||||
acceptChallenge as acceptchallenge,
|
||||
getCustomTokenFunction as getcustomtoken,
|
||||
createPostFunction as createpost,
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@ import { getUser, getValues, isProd, log } from './utils'
|
|||
import {
|
||||
createBetFillNotification,
|
||||
createBettingStreakBonusNotification,
|
||||
createNotification,
|
||||
createUniqueBettorBonusNotification,
|
||||
} from './create-notification'
|
||||
import { filterDefined } from '../../common/util/array'
|
||||
import { Contract } from '../../common/contract'
|
||||
|
@ -54,11 +54,11 @@ export const onCreateBet = functions.firestore
|
|||
log(`Could not find contract ${contractId}`)
|
||||
return
|
||||
}
|
||||
await updateUniqueBettorsAndGiveCreatorBonus(contract, eventId, bet.userId)
|
||||
|
||||
const bettor = await getUser(bet.userId)
|
||||
if (!bettor) return
|
||||
|
||||
await updateUniqueBettorsAndGiveCreatorBonus(contract, eventId, bettor)
|
||||
await notifyFills(bet, contract, eventId, bettor)
|
||||
await updateBettingStreak(bettor, bet, contract, eventId)
|
||||
|
||||
|
@ -126,7 +126,7 @@ const updateBettingStreak = async (
|
|||
const updateUniqueBettorsAndGiveCreatorBonus = async (
|
||||
contract: Contract,
|
||||
eventId: string,
|
||||
bettorId: string
|
||||
bettor: User
|
||||
) => {
|
||||
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
|
||||
if (!contract.uniqueBettorIds || isNewUniqueBettor) {
|
||||
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({
|
||||
uniqueBettorIds: newUniqueBettorIds,
|
||||
uniqueBettorCount: newUniqueBettorIds.length,
|
||||
|
@ -161,7 +161,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
|
|||
}
|
||||
|
||||
// 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
|
||||
const bonusTxnDetails = {
|
||||
|
@ -192,18 +192,13 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
|
|||
log(`No bonus for user: ${contract.creatorId} - reason:`, result.status)
|
||||
} else {
|
||||
log(`Bonus txn for user: ${contract.creatorId} completed:`, result.txn?.id)
|
||||
await createNotification(
|
||||
await createUniqueBettorBonusNotification(
|
||||
contract.creatorId,
|
||||
bettor,
|
||||
result.txn.id,
|
||||
'bonus',
|
||||
'created',
|
||||
fromUser,
|
||||
eventId + '-bonus',
|
||||
result.txn.amount + '',
|
||||
{
|
||||
contract,
|
||||
slug: contract.slug,
|
||||
title: contract.question,
|
||||
}
|
||||
contract,
|
||||
result.txn.amount,
|
||||
eventId + '-unique-bettor-bonus'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -63,11 +63,15 @@ export const onCreateCommentOnContract = functions
|
|||
.doc(comment.betId)
|
||||
.get()
|
||||
bet = betSnapshot.data() as Bet
|
||||
|
||||
answer =
|
||||
contract.outcomeType === 'FREE_RESPONSE' && contract.answers
|
||||
? contract.answers.find((answer) => answer.id === bet?.outcome)
|
||||
: undefined
|
||||
|
||||
await change.ref.update({
|
||||
betOutcome: bet.outcome,
|
||||
betAmount: bet.amount,
|
||||
})
|
||||
}
|
||||
|
||||
const comments = await getValues<ContractComment>(
|
||||
|
|
|
@ -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
|
||||
)
|
||||
})
|
||||
)
|
||||
})
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
})
|
|
@ -15,21 +15,68 @@ export const onUpdateGroup = functions.firestore
|
|||
if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime)
|
||||
return
|
||||
|
||||
if (prevGroup.contractIds.length < group.contractIds.length) {
|
||||
await firestore
|
||||
.collection('groups')
|
||||
.doc(group.id)
|
||||
.update({ mostRecentContractAddedTime: Date.now() })
|
||||
//TODO: create notification with isSeeOnHref set to the group's /group/slug/questions url
|
||||
// but first, let the new /group/slug/chat notification permeate so that we can differentiate between the two
|
||||
}
|
||||
|
||||
await firestore
|
||||
.collection('groups')
|
||||
.doc(group.id)
|
||||
.update({ mostRecentActivityTime: Date.now() })
|
||||
})
|
||||
|
||||
export const onCreateGroupContract = functions.firestore
|
||||
.document('groups/{groupId}/groupContracts/{contractId}')
|
||||
.onCreate(async (change) => {
|
||||
const groupId = change.ref.parent.parent?.id
|
||||
if (groupId)
|
||||
await firestore
|
||||
.collection('groups')
|
||||
.doc(groupId)
|
||||
.update({
|
||||
mostRecentContractAddedTime: Date.now(),
|
||||
totalContracts: admin.firestore.FieldValue.increment(1),
|
||||
})
|
||||
})
|
||||
|
||||
export const onDeleteGroupContract = functions.firestore
|
||||
.document('groups/{groupId}/groupContracts/{contractId}')
|
||||
.onDelete(async (change) => {
|
||||
const groupId = change.ref.parent.parent?.id
|
||||
if (groupId)
|
||||
await firestore
|
||||
.collection('groups')
|
||||
.doc(groupId)
|
||||
.update({
|
||||
mostRecentContractAddedTime: Date.now(),
|
||||
totalContracts: admin.firestore.FieldValue.increment(-1),
|
||||
})
|
||||
})
|
||||
|
||||
export const onCreateGroupMember = functions.firestore
|
||||
.document('groups/{groupId}/groupMembers/{memberId}')
|
||||
.onCreate(async (change) => {
|
||||
const groupId = change.ref.parent.parent?.id
|
||||
if (groupId)
|
||||
await firestore
|
||||
.collection('groups')
|
||||
.doc(groupId)
|
||||
.update({
|
||||
mostRecentActivityTime: Date.now(),
|
||||
totalMembers: admin.firestore.FieldValue.increment(1),
|
||||
})
|
||||
})
|
||||
|
||||
export const onDeleteGroupMember = functions.firestore
|
||||
.document('groups/{groupId}/groupMembers/{memberId}')
|
||||
.onDelete(async (change) => {
|
||||
const groupId = change.ref.parent.parent?.id
|
||||
if (groupId)
|
||||
await firestore
|
||||
.collection('groups')
|
||||
.doc(groupId)
|
||||
.update({
|
||||
mostRecentActivityTime: Date.now(),
|
||||
totalMembers: admin.firestore.FieldValue.increment(-1),
|
||||
})
|
||||
})
|
||||
|
||||
export async function removeGroupLinks(group: Group, contractIds: string[]) {
|
||||
for (const contractId of contractIds) {
|
||||
const contract = await getContract(contractId)
|
||||
|
|
|
@ -135,7 +135,7 @@ export const placebet = newEndpoint({}, async (req, auth) => {
|
|||
!isFinite(newP) ||
|
||||
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()
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -4,21 +4,23 @@ import * as admin from 'firebase-admin'
|
|||
import { initAdmin } from './script-init'
|
||||
import { isProd, log } from '../utils'
|
||||
import { getSlug } from '../create-group'
|
||||
import { Group } from '../../../common/group'
|
||||
import { Group, GroupLink } from '../../../common/group'
|
||||
import { uniq } from 'lodash'
|
||||
import { Contract } from 'common/contract'
|
||||
|
||||
const getTaggedContractIds = async (tag: string) => {
|
||||
const getTaggedContracts = async (tag: string) => {
|
||||
const firestore = admin.firestore()
|
||||
const results = await firestore
|
||||
.collection('contracts')
|
||||
.where('lowercaseTags', 'array-contains', tag.toLowerCase())
|
||||
.get()
|
||||
return results.docs.map((d) => d.id)
|
||||
return results.docs.map((d) => d.data() as Contract)
|
||||
}
|
||||
|
||||
const createGroup = async (
|
||||
name: string,
|
||||
about: string,
|
||||
contractIds: string[]
|
||||
contracts: Contract[]
|
||||
) => {
|
||||
const firestore = admin.firestore()
|
||||
const creatorId = isProd()
|
||||
|
@ -36,21 +38,60 @@ const createGroup = async (
|
|||
about,
|
||||
createdTime: now,
|
||||
mostRecentActivityTime: now,
|
||||
contractIds: contractIds,
|
||||
anyoneCanJoin: true,
|
||||
memberIds: [],
|
||||
totalContracts: contracts.length,
|
||||
totalMembers: 1,
|
||||
}
|
||||
return await groupRef.create(group)
|
||||
await groupRef.create(group)
|
||||
// create a GroupMemberDoc for the creator
|
||||
const memberDoc = groupRef.collection('groupMembers').doc(creatorId)
|
||||
await memberDoc.create({
|
||||
userId: creatorId,
|
||||
createdTime: now,
|
||||
})
|
||||
|
||||
// create GroupContractDocs for each contractId
|
||||
await Promise.all(
|
||||
contracts
|
||||
.map((c) => c.id)
|
||||
.map((contractId) =>
|
||||
groupRef.collection('groupContracts').doc(contractId).create({
|
||||
contractId,
|
||||
createdTime: now,
|
||||
})
|
||||
)
|
||||
)
|
||||
for (const market of contracts) {
|
||||
if (market.groupLinks?.map((l) => l.groupId).includes(group.id)) continue // already in that group
|
||||
|
||||
const newGroupLinks = [
|
||||
...(market.groupLinks ?? []),
|
||||
{
|
||||
groupId: group.id,
|
||||
createdTime: Date.now(),
|
||||
slug: group.slug,
|
||||
name: group.name,
|
||||
} as GroupLink,
|
||||
]
|
||||
await firestore
|
||||
.collection('contracts')
|
||||
.doc(market.id)
|
||||
.update({
|
||||
groupSlugs: uniq([...(market.groupSlugs ?? []), group.slug]),
|
||||
groupLinks: newGroupLinks,
|
||||
})
|
||||
}
|
||||
return { status: 'success', group: group }
|
||||
}
|
||||
|
||||
const convertTagToGroup = async (tag: string, groupName: string) => {
|
||||
log(`Looking up contract IDs with tag ${tag}...`)
|
||||
const contractIds = await getTaggedContractIds(tag)
|
||||
log(`${contractIds.length} contracts found.`)
|
||||
if (contractIds.length > 0) {
|
||||
const contracts = await getTaggedContracts(tag)
|
||||
log(`${contracts.length} contracts found.`)
|
||||
if (contracts.length > 0) {
|
||||
log(`Creating group ${groupName}...`)
|
||||
const about = `Contracts that used to be tagged ${tag}.`
|
||||
const result = await createGroup(groupName, about, contractIds)
|
||||
const result = await createGroup(groupName, about, contracts)
|
||||
log(`Done. Group: `, result)
|
||||
}
|
||||
}
|
||||
|
|
69
functions/src/scripts/denormalize-comment-bet-data.ts
Normal file
69
functions/src/scripts/denormalize-comment-bet-data.ts
Normal 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))
|
||||
}
|
122
functions/src/scripts/update-groups.ts
Normal file
122
functions/src/scripts/update-groups.ts
Normal 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()
|
||||
}
|
|
@ -26,7 +26,6 @@ import { resolvemarket } from './resolve-market'
|
|||
import { unsubscribe } from './unsubscribe'
|
||||
import { stripewebhook, createcheckoutsession } from './stripe'
|
||||
import { getcurrentuser } from './get-current-user'
|
||||
import { getcustomtoken } from './get-custom-token'
|
||||
import { createpost } from './create-post'
|
||||
|
||||
type Middleware = (req: Request, res: Response, next: NextFunction) => void
|
||||
|
@ -66,7 +65,6 @@ addJsonEndpointRoute('/resolvemarket', resolvemarket)
|
|||
addJsonEndpointRoute('/unsubscribe', unsubscribe)
|
||||
addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession)
|
||||
addJsonEndpointRoute('/getcurrentuser', getcurrentuser)
|
||||
addEndpointRoute('/getcustomtoken', getcustomtoken)
|
||||
addEndpointRoute('/stripewebhook', stripewebhook, express.raw())
|
||||
addEndpointRoute('/createpost', createpost)
|
||||
|
||||
|
|
|
@ -1,43 +1,29 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
import { groupBy, isEmpty, keyBy, sum, sumBy } from 'lodash'
|
||||
import { groupBy, isEmpty, keyBy, last, sortBy } from 'lodash'
|
||||
import { getValues, log, logMemory, writeAsync } from './utils'
|
||||
import { Bet } from '../../common/bet'
|
||||
import { Contract } from '../../common/contract'
|
||||
import { Contract, CPMM } from '../../common/contract'
|
||||
import { PortfolioMetrics, User } from '../../common/user'
|
||||
import { calculatePayout } from '../../common/calculate'
|
||||
import { DAY_MS } from '../../common/util/time'
|
||||
import { last } from 'lodash'
|
||||
import { getLoanUpdates } from '../../common/loans'
|
||||
import {
|
||||
calculateCreatorVolume,
|
||||
calculateNewPortfolioMetrics,
|
||||
calculateNewProfit,
|
||||
calculateProbChanges,
|
||||
computeVolume,
|
||||
} from '../../common/calculate-metrics'
|
||||
import { getProbability } from '../../common/calculate'
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
||||
const computeInvestmentValue = (
|
||||
bets: Bet[],
|
||||
contractsDict: { [k: string]: Contract }
|
||||
) => {
|
||||
return sumBy(bets, (bet) => {
|
||||
const contract = contractsDict[bet.contractId]
|
||||
if (!contract || contract.isResolved) return 0
|
||||
if (bet.sale || bet.isSold) return 0
|
||||
export const updateMetrics = functions
|
||||
.runWith({ memory: '2GB', timeoutSeconds: 540 })
|
||||
.pubsub.schedule('every 15 minutes')
|
||||
.onRun(updateMetricsCore)
|
||||
|
||||
const payout = calculatePayout(contract, bet, 'MKT')
|
||||
const value = payout - (bet.loanAmount ?? 0)
|
||||
if (isNaN(value)) return 0
|
||||
return value
|
||||
})
|
||||
}
|
||||
|
||||
const computeTotalPool = (userContracts: Contract[], startTime = 0) => {
|
||||
const periodFilteredContracts = userContracts.filter(
|
||||
(contract) => contract.createdTime >= startTime
|
||||
)
|
||||
return sum(
|
||||
periodFilteredContracts.map((contract) => sum(Object.values(contract.pool)))
|
||||
)
|
||||
}
|
||||
|
||||
export const updateMetricsCore = async () => {
|
||||
export async function updateMetricsCore() {
|
||||
const [users, contracts, bets, allPortfolioHistories] = await Promise.all([
|
||||
getValues<User>(firestore.collection('users')),
|
||||
getValues<Contract>(firestore.collection('contracts')),
|
||||
|
@ -59,11 +45,29 @@ export const updateMetricsCore = async () => {
|
|||
.filter((contract) => contract.id)
|
||||
.map((contract) => {
|
||||
const contractBets = betsByContract[contract.id] ?? []
|
||||
const descendingBets = sortBy(
|
||||
contractBets,
|
||||
(bet) => bet.createdTime
|
||||
).reverse()
|
||||
|
||||
let cpmmFields: Partial<CPMM> = {}
|
||||
if (contract.mechanism === 'cpmm-1') {
|
||||
const prob = descendingBets[0]
|
||||
? descendingBets[0].probAfter
|
||||
: getProbability(contract)
|
||||
|
||||
cpmmFields = {
|
||||
prob,
|
||||
probChanges: calculateProbChanges(descendingBets),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
doc: firestore.collection('contracts').doc(contract.id),
|
||||
fields: {
|
||||
volume24Hours: computeVolume(contractBets, now - DAY_MS),
|
||||
volume7Days: computeVolume(contractBets, now - DAY_MS * 7),
|
||||
...cpmmFields,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
@ -88,23 +92,20 @@ export const updateMetricsCore = async () => {
|
|||
currentBets
|
||||
)
|
||||
const lastPortfolio = last(portfolioHistory)
|
||||
const didProfitChange =
|
||||
const didPortfolioChange =
|
||||
lastPortfolio === undefined ||
|
||||
lastPortfolio.balance !== newPortfolio.balance ||
|
||||
lastPortfolio.totalDeposits !== newPortfolio.totalDeposits ||
|
||||
lastPortfolio.investmentValue !== newPortfolio.investmentValue
|
||||
|
||||
const newProfit = calculateNewProfit(
|
||||
portfolioHistory,
|
||||
newPortfolio,
|
||||
didProfitChange
|
||||
)
|
||||
const newProfit = calculateNewProfit(portfolioHistory, newPortfolio)
|
||||
|
||||
return {
|
||||
user,
|
||||
newCreatorVolume,
|
||||
newPortfolio,
|
||||
newProfit,
|
||||
didProfitChange,
|
||||
didPortfolioChange,
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -120,16 +121,20 @@ export const updateMetricsCore = async () => {
|
|||
const nextLoanByUser = keyBy(userPayouts, (payout) => payout.user.id)
|
||||
|
||||
const userUpdates = userMetrics.map(
|
||||
({ user, newCreatorVolume, newPortfolio, newProfit, didProfitChange }) => {
|
||||
({
|
||||
user,
|
||||
newCreatorVolume,
|
||||
newPortfolio,
|
||||
newProfit,
|
||||
didPortfolioChange,
|
||||
}) => {
|
||||
const nextLoanCached = nextLoanByUser[user.id]?.payout ?? 0
|
||||
return {
|
||||
fieldUpdates: {
|
||||
doc: firestore.collection('users').doc(user.id),
|
||||
fields: {
|
||||
creatorVolumeCached: newCreatorVolume,
|
||||
...(didProfitChange && {
|
||||
profitCached: newProfit,
|
||||
}),
|
||||
profitCached: newProfit,
|
||||
nextLoanCached,
|
||||
},
|
||||
},
|
||||
|
@ -140,11 +145,7 @@ export const updateMetricsCore = async () => {
|
|||
.doc(user.id)
|
||||
.collection('portfolioHistory')
|
||||
.doc(),
|
||||
fields: {
|
||||
...(didProfitChange && {
|
||||
...newPortfolio,
|
||||
}),
|
||||
},
|
||||
fields: didPortfolioChange ? newPortfolio : {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -162,108 +163,3 @@ export const updateMetricsCore = async () => {
|
|||
)
|
||||
log(`Updated metrics for ${users.length} users.`)
|
||||
}
|
||||
|
||||
const computeVolume = (contractBets: Bet[], since: number) => {
|
||||
return sumBy(contractBets, (b) =>
|
||||
b.createdTime > since && !b.isRedemption ? Math.abs(b.amount) : 0
|
||||
)
|
||||
}
|
||||
|
||||
const calculateProfitForPeriod = (
|
||||
startTime: number,
|
||||
portfolioHistory: PortfolioMetrics[],
|
||||
currentProfit: number
|
||||
) => {
|
||||
const startingPortfolio = [...portfolioHistory]
|
||||
.reverse() // so we search in descending order (most recent first), for efficiency
|
||||
.find((p) => p.timestamp < startTime)
|
||||
|
||||
if (startingPortfolio === undefined) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const startingProfit = calculateTotalProfit(startingPortfolio)
|
||||
|
||||
return currentProfit - startingProfit
|
||||
}
|
||||
|
||||
const calculateTotalProfit = (portfolio: PortfolioMetrics) => {
|
||||
return portfolio.investmentValue + portfolio.balance - portfolio.totalDeposits
|
||||
}
|
||||
|
||||
const calculateCreatorVolume = (userContracts: Contract[]) => {
|
||||
const allTimeCreatorVolume = computeTotalPool(userContracts, 0)
|
||||
const monthlyCreatorVolume = computeTotalPool(
|
||||
userContracts,
|
||||
Date.now() - 30 * DAY_MS
|
||||
)
|
||||
const weeklyCreatorVolume = computeTotalPool(
|
||||
userContracts,
|
||||
Date.now() - 7 * DAY_MS
|
||||
)
|
||||
|
||||
const dailyCreatorVolume = computeTotalPool(
|
||||
userContracts,
|
||||
Date.now() - 1 * DAY_MS
|
||||
)
|
||||
|
||||
return {
|
||||
daily: dailyCreatorVolume,
|
||||
weekly: weeklyCreatorVolume,
|
||||
monthly: monthlyCreatorVolume,
|
||||
allTime: allTimeCreatorVolume,
|
||||
}
|
||||
}
|
||||
|
||||
const calculateNewPortfolioMetrics = (
|
||||
user: User,
|
||||
contractsById: { [k: string]: Contract },
|
||||
currentBets: Bet[]
|
||||
) => {
|
||||
const investmentValue = computeInvestmentValue(currentBets, contractsById)
|
||||
const newPortfolio = {
|
||||
investmentValue: investmentValue,
|
||||
balance: user.balance,
|
||||
totalDeposits: user.totalDeposits,
|
||||
timestamp: Date.now(),
|
||||
userId: user.id,
|
||||
}
|
||||
return newPortfolio
|
||||
}
|
||||
|
||||
const calculateNewProfit = (
|
||||
portfolioHistory: PortfolioMetrics[],
|
||||
newPortfolio: PortfolioMetrics,
|
||||
didProfitChange: boolean
|
||||
) => {
|
||||
if (!didProfitChange) {
|
||||
return {} // early return for performance
|
||||
}
|
||||
|
||||
const allTimeProfit = calculateTotalProfit(newPortfolio)
|
||||
const newProfit = {
|
||||
daily: calculateProfitForPeriod(
|
||||
Date.now() - 1 * DAY_MS,
|
||||
portfolioHistory,
|
||||
allTimeProfit
|
||||
),
|
||||
weekly: calculateProfitForPeriod(
|
||||
Date.now() - 7 * DAY_MS,
|
||||
portfolioHistory,
|
||||
allTimeProfit
|
||||
),
|
||||
monthly: calculateProfitForPeriod(
|
||||
Date.now() - 30 * DAY_MS,
|
||||
portfolioHistory,
|
||||
allTimeProfit
|
||||
),
|
||||
allTime: allTimeProfit,
|
||||
}
|
||||
|
||||
return newProfit
|
||||
}
|
||||
|
||||
export const updateMetrics = functions
|
||||
.runWith({ memory: '2GB', timeoutSeconds: 540 })
|
||||
.pubsub.schedule('every 15 minutes')
|
||||
.onRun(updateMetricsCore)
|
||||
|
|
|
@ -1,85 +1,5 @@
|
|||
import { sanitizeHtml } from './sanitizer'
|
||||
import { ParsedRequest } from './types'
|
||||
|
||||
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;
|
||||
}
|
||||
`
|
||||
}
|
||||
import { getTemplateCss } from './template-css'
|
||||
|
||||
export function getChallengeHtml(parsedReq: ParsedRequest) {
|
||||
const {
|
||||
|
@ -112,7 +32,7 @@ export function getChallengeHtml(parsedReq: ParsedRequest) {
|
|||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<style>
|
||||
${getCss(theme, fontSize)}
|
||||
${getTemplateCss(theme, fontSize)}
|
||||
</style>
|
||||
<body>
|
||||
<div class="px-24">
|
||||
|
|
|
@ -21,6 +21,7 @@ export function parseRequest(req: IncomingMessage) {
|
|||
creatorName,
|
||||
creatorUsername,
|
||||
creatorAvatarUrl,
|
||||
resolution,
|
||||
|
||||
// Challenge attributes:
|
||||
challengerAmount,
|
||||
|
@ -71,6 +72,7 @@ export function parseRequest(req: IncomingMessage) {
|
|||
|
||||
question:
|
||||
getString(question) || 'Will you create a prediction market on Manifold?',
|
||||
resolution: getString(resolution),
|
||||
probability: getString(probability),
|
||||
numericValue: getString(numericValue) || '',
|
||||
metadata: getString(metadata) || 'Jan 1 • M$ 123 pool',
|
||||
|
|
81
og-image/api/_lib/template-css.ts
Normal file
81
og-image/api/_lib/template-css.ts
Normal 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;
|
||||
}
|
||||
`
|
||||
}
|
|
@ -1,85 +1,5 @@
|
|||
import { sanitizeHtml } from './sanitizer'
|
||||
import { ParsedRequest } from './types'
|
||||
|
||||
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;
|
||||
}
|
||||
`
|
||||
}
|
||||
import { getTemplateCss } from './template-css'
|
||||
|
||||
export function getHtml(parsedReq: ParsedRequest) {
|
||||
const {
|
||||
|
@ -92,6 +12,7 @@ export function getHtml(parsedReq: ParsedRequest) {
|
|||
creatorUsername,
|
||||
creatorAvatarUrl,
|
||||
numericValue,
|
||||
resolution,
|
||||
} = parsedReq
|
||||
const MAX_QUESTION_CHARS = 100
|
||||
const truncatedQuestion =
|
||||
|
@ -99,6 +20,49 @@ export function getHtml(parsedReq: ParsedRequest) {
|
|||
? question.slice(0, MAX_QUESTION_CHARS) + '...'
|
||||
: question
|
||||
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>
|
||||
<html>
|
||||
<head>
|
||||
|
@ -108,7 +72,7 @@ export function getHtml(parsedReq: ParsedRequest) {
|
|||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<style>
|
||||
${getCss(theme, fontSize)}
|
||||
${getTemplateCss(theme, fontSize)}
|
||||
</style>
|
||||
<body>
|
||||
<div class="px-24">
|
||||
|
@ -148,21 +112,22 @@ export function getHtml(parsedReq: ParsedRequest) {
|
|||
<div class="text-indigo-700 text-6xl leading-tight">
|
||||
${truncatedQuestion}
|
||||
</div>
|
||||
<div class="flex flex-col text-primary">
|
||||
<div class="text-8xl">${probability}</div>
|
||||
<div class="text-4xl">${probability !== '' ? 'chance' : ''}</div>
|
||||
<span class='text-blue-500 text-center'>
|
||||
<div class="text-8xl ">${
|
||||
numericValue !== '' && probability === '' ? numericValue : ''
|
||||
}</div>
|
||||
<div class="text-4xl">${numericValue !== '' ? 'expected' : ''}</div>
|
||||
</span>
|
||||
<div class="flex flex-col">
|
||||
${
|
||||
resolution
|
||||
? resolutionDiv
|
||||
: numericValue
|
||||
? numericValueDiv
|
||||
: probability
|
||||
? probabilityDiv
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metadata -->
|
||||
<div class="absolute bottom-16">
|
||||
<div class="text-gray-500 text-3xl">
|
||||
<div class="text-gray-500 text-3xl max-w-[80vw]">
|
||||
${metadata}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -19,6 +19,7 @@ export interface ParsedRequest {
|
|||
creatorName: string
|
||||
creatorUsername: string
|
||||
creatorAvatarUrl: string
|
||||
resolution: string
|
||||
// Challenge attributes:
|
||||
challengerAmount: string
|
||||
challengerOutcome: string
|
||||
|
|
|
@ -84,6 +84,7 @@ export function BuyAmountInput(props: {
|
|||
setError: (error: string | undefined) => void
|
||||
minimumAmount?: number
|
||||
disabled?: boolean
|
||||
showSliderOnMobile?: boolean
|
||||
className?: string
|
||||
inputClassName?: string
|
||||
// Needed to focus the amount input
|
||||
|
@ -94,6 +95,7 @@ export function BuyAmountInput(props: {
|
|||
onChange,
|
||||
error,
|
||||
setError,
|
||||
showSliderOnMobile: showSlider,
|
||||
disabled,
|
||||
className,
|
||||
inputClassName,
|
||||
|
@ -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 (
|
||||
<AmountInput
|
||||
amount={amount}
|
||||
onChange={onAmountChange}
|
||||
label={ENV_CONFIG.moneyMoniker}
|
||||
error={error}
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
inputClassName={inputClassName}
|
||||
inputRef={inputRef}
|
||||
/>
|
||||
<>
|
||||
<AmountInput
|
||||
amount={amount}
|
||||
onChange={onAmountChange}
|
||||
label={ENV_CONFIG.moneyMoniker}
|
||||
error={error}
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
inputClassName={inputClassName}
|
||||
inputRef={inputRef}
|
||||
/>
|
||||
{showSlider && (
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="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"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import clsx from 'clsx'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { XIcon } from '@heroicons/react/solid'
|
||||
|
||||
import { Answer } from 'common/answer'
|
||||
|
@ -26,7 +26,7 @@ import { Bet } from 'common/bet'
|
|||
import { track } from 'web/lib/service/analytics'
|
||||
import { BetSignUpPrompt } from '../sign-up-prompt'
|
||||
import { isIOS } from 'web/lib/util/device'
|
||||
import { AlertBox } from '../alert-box'
|
||||
import { WarningConfirmationButton } from '../warning-confirmation-button'
|
||||
|
||||
export function AnswerBetPanel(props: {
|
||||
answer: Answer
|
||||
|
@ -116,11 +116,20 @@ export function AnswerBetPanel(props: {
|
|||
|
||||
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 (
|
||||
<Col className={clsx('px-2 pb-2 pt-4 sm:pt-0', className)}>
|
||||
<Row className="items-center justify-between self-stretch">
|
||||
<div className="text-xl">
|
||||
Bet on {isModal ? `"${answer.text}"` : 'this answer'}
|
||||
Buy answer: {isModal ? `"${answer.text}"` : 'this answer'}
|
||||
</div>
|
||||
|
||||
{!isModal && (
|
||||
|
@ -132,7 +141,11 @@ export function AnswerBetPanel(props: {
|
|||
</button>
|
||||
)}
|
||||
</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
|
||||
inputClassName="w-full max-w-none"
|
||||
amount={betAmount}
|
||||
|
@ -141,23 +154,9 @@ export function AnswerBetPanel(props: {
|
|||
setError={setError}
|
||||
disabled={isSubmitting}
|
||||
inputRef={inputRef}
|
||||
showSliderOnMobile
|
||||
/>
|
||||
|
||||
{(betAmount ?? 0) > 10 &&
|
||||
bankrollFraction >= 0.5 &&
|
||||
bankrollFraction <= 1 ? (
|
||||
<AlertBox
|
||||
title="Whoa, there!"
|
||||
text={`You might not want to spend ${formatPercent(
|
||||
bankrollFraction
|
||||
)} of your balance on a single bet. \n\nCurrent balance: ${formatMoney(
|
||||
user?.balance ?? 0
|
||||
)}`}
|
||||
/>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
|
||||
<Col className="mt-3 w-full gap-3">
|
||||
<Row className="items-center justify-between text-sm">
|
||||
<div className="text-gray-500">Probability</div>
|
||||
|
@ -193,16 +192,17 @@ export function AnswerBetPanel(props: {
|
|||
<Spacer h={6} />
|
||||
|
||||
{user ? (
|
||||
<button
|
||||
className={clsx(
|
||||
<WarningConfirmationButton
|
||||
warning={warning}
|
||||
onSubmit={submitBet}
|
||||
isSubmitting={isSubmitting}
|
||||
disabled={!!betDisabled}
|
||||
openModalButtonClass={clsx(
|
||||
'btn self-stretch',
|
||||
betDisabled ? 'btn-disabled' : 'btn-primary',
|
||||
isSubmitting ? 'loading' : ''
|
||||
)}
|
||||
onClick={betDisabled ? undefined : submitBet}
|
||||
>
|
||||
{isSubmitting ? 'Submitting...' : 'Submit trade'}
|
||||
</button>
|
||||
/>
|
||||
) : (
|
||||
<BetSignUpPrompt />
|
||||
)}
|
||||
|
|
|
@ -18,19 +18,20 @@ export const AnswersGraph = memo(function AnswersGraph(props: {
|
|||
}) {
|
||||
const { contract, bets, height } = props
|
||||
const { createdTime, resolutionTime, closeTime, answers } = contract
|
||||
const now = Date.now()
|
||||
|
||||
const { probsByOutcome, sortedOutcomes } = computeProbsByOutcome(
|
||||
bets,
|
||||
contract
|
||||
)
|
||||
|
||||
const isClosed = !!closeTime && Date.now() > closeTime
|
||||
const isClosed = !!closeTime && now > closeTime
|
||||
const latestTime = dayjs(
|
||||
resolutionTime && isClosed
|
||||
? Math.min(resolutionTime, closeTime)
|
||||
: isClosed
|
||||
? closeTime
|
||||
: resolutionTime ?? Date.now()
|
||||
: resolutionTime ?? now
|
||||
)
|
||||
|
||||
const { width } = useWindowSize()
|
||||
|
@ -71,14 +72,14 @@ export const AnswersGraph = memo(function AnswersGraph(props: {
|
|||
const yTickValues = [0, 25, 50, 75, 100]
|
||||
|
||||
const numXTickValues = isLargeWidth ? 5 : 2
|
||||
const startDate = new Date(contract.createdTime)
|
||||
const endDate = dayjs(startDate).add(1, 'hour').isAfter(latestTime)
|
||||
? latestTime.add(1, 'hours').toDate()
|
||||
: latestTime.toDate()
|
||||
const includeMinute = dayjs(endDate).diff(startDate, 'hours') < 2
|
||||
const startDate = dayjs(contract.createdTime)
|
||||
const endDate = startDate.add(1, 'hour').isAfter(latestTime)
|
||||
? latestTime.add(1, 'hours')
|
||||
: latestTime
|
||||
const includeMinute = endDate.diff(startDate, 'hours') < 2
|
||||
|
||||
const multiYear = !dayjs(startDate).isSame(latestTime, 'year')
|
||||
const lessThanAWeek = dayjs(startDate).add(1, 'week').isAfter(latestTime)
|
||||
const multiYear = !startDate.isSame(latestTime, 'year')
|
||||
const lessThanAWeek = startDate.add(1, 'week').isAfter(latestTime)
|
||||
|
||||
return (
|
||||
<div
|
||||
|
@ -96,16 +97,16 @@ export const AnswersGraph = memo(function AnswersGraph(props: {
|
|||
}}
|
||||
xScale={{
|
||||
type: 'time',
|
||||
min: startDate,
|
||||
max: endDate,
|
||||
min: startDate.toDate(),
|
||||
max: endDate.toDate(),
|
||||
}}
|
||||
xFormat={(d) =>
|
||||
formatTime(+d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek)
|
||||
formatTime(now, +d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek)
|
||||
}
|
||||
axisBottom={{
|
||||
tickValues: numXTickValues,
|
||||
format: (time) =>
|
||||
formatTime(+time, multiYear, lessThanAWeek, includeMinute),
|
||||
formatTime(now, +time, multiYear, lessThanAWeek, includeMinute),
|
||||
}}
|
||||
colors={[
|
||||
'#fca5a5', // red-300
|
||||
|
@ -158,23 +159,20 @@ function formatPercent(y: DatumValue) {
|
|||
}
|
||||
|
||||
function formatTime(
|
||||
now: number,
|
||||
time: number,
|
||||
includeYear: boolean,
|
||||
includeHour: boolean,
|
||||
includeMinute: boolean
|
||||
) {
|
||||
const d = dayjs(time)
|
||||
|
||||
if (
|
||||
d.add(1, 'minute').isAfter(Date.now()) &&
|
||||
d.subtract(1, 'minute').isBefore(Date.now())
|
||||
)
|
||||
if (d.add(1, 'minute').isAfter(now) && d.subtract(1, 'minute').isBefore(now))
|
||||
return 'Now'
|
||||
|
||||
let format: string
|
||||
if (d.isSame(Date.now(), 'day')) {
|
||||
if (d.isSame(now, 'day')) {
|
||||
format = '[Today]'
|
||||
} else if (d.add(1, 'day').isSame(Date.now(), 'day')) {
|
||||
} else if (d.add(1, 'day').isSame(now, 'day')) {
|
||||
format = '[Yesterday]'
|
||||
} else {
|
||||
format = 'MMM D'
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import clsx from 'clsx'
|
||||
import { useState } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import Textarea from 'react-expanding-textarea'
|
||||
import { findBestMatch } from 'string-similarity'
|
||||
|
||||
|
@ -120,7 +120,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
|
|||
|
||||
return (
|
||||
<Col className="gap-4 rounded">
|
||||
<Col className="flex-1 gap-2">
|
||||
<Col className="flex-1 gap-2 px-4 xl:px-0">
|
||||
<div className="mb-1">Add your answer</div>
|
||||
<Textarea
|
||||
value={text}
|
||||
|
@ -149,7 +149,12 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
|
|||
{text && (
|
||||
<>
|
||||
<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
|
||||
amount={betAmount}
|
||||
onChange={setBetAmount}
|
||||
|
@ -157,6 +162,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
|
|||
setError={setAmountError}
|
||||
minimumAmount={1}
|
||||
disabled={isSubmitting}
|
||||
showSliderOnMobile
|
||||
/>
|
||||
</Col>
|
||||
<Col className="gap-3">
|
||||
|
@ -200,7 +206,7 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
|
|||
disabled={!canSubmit}
|
||||
onClick={withTracking(submitAnswer, 'submit answer')}
|
||||
>
|
||||
Submit answer & buy
|
||||
Submit
|
||||
</button>
|
||||
) : (
|
||||
text && (
|
||||
|
|
|
@ -12,7 +12,7 @@ import { User } from 'common/user'
|
|||
import { Group } from 'common/group'
|
||||
|
||||
export function ArrangeHome(props: {
|
||||
user: User | null
|
||||
user: User | null | undefined
|
||||
homeSections: { visible: string[]; hidden: string[] }
|
||||
setHomeSections: (homeSections: {
|
||||
visible: string[]
|
||||
|
@ -30,7 +30,6 @@ export function ArrangeHome(props: {
|
|||
return (
|
||||
<DragDropContext
|
||||
onDragEnd={(e) => {
|
||||
console.log('drag end', e)
|
||||
const { destination, source, draggableId } = e
|
||||
if (!destination) return
|
||||
|
||||
|
@ -112,7 +111,7 @@ export const getHomeItems = (
|
|||
{ label: 'Trending', id: 'score' },
|
||||
{ label: 'Newest', id: 'newest' },
|
||||
{ label: 'Close date', id: 'close-date' },
|
||||
{ label: 'Your bets', id: 'your-bets' },
|
||||
{ label: 'Your trades', id: 'your-bets' },
|
||||
...groups.map((g) => ({
|
||||
label: g.name,
|
||||
id: g.id,
|
||||
|
|
|
@ -8,17 +8,20 @@ import {
|
|||
getUserAndPrivateUser,
|
||||
setCachedReferralInfoForUser,
|
||||
} from 'web/lib/firebase/users'
|
||||
import { deleteTokenCookies, setTokenCookies } from 'web/lib/firebase/auth'
|
||||
import { createUser } from 'web/lib/firebase/api'
|
||||
import { randomString } from 'common/util/random'
|
||||
import { identifyUser, setUserProperty } from 'web/lib/service/analytics'
|
||||
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
|
||||
// the user is not logged in (null), or we know the user is logged in.
|
||||
type AuthUser = undefined | null | UserAndPrivateUser
|
||||
|
||||
const TEN_YEARS_SECS = 60 * 60 * 24 * 365 * 10
|
||||
const CACHED_USER_KEY = 'CACHED_USER_KEY_V2'
|
||||
|
||||
// Proxy localStorage in case it's not available (eg in incognito iframe)
|
||||
const localStorage =
|
||||
typeof window !== 'undefined'
|
||||
|
@ -38,6 +41,16 @@ const ensureDeviceToken = () => {
|
|||
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 function AuthProvider(props: {
|
||||
|
@ -54,30 +67,33 @@ export function AuthProvider(props: {
|
|||
}
|
||||
}, [setAuthUser, serverUser])
|
||||
|
||||
useEffect(() => {
|
||||
if (authUser != null) {
|
||||
// Persist to local storage, to reduce login blink next time.
|
||||
// Note: Cap on localStorage size is ~5mb
|
||||
localStorage.setItem(CACHED_USER_KEY, JSON.stringify(authUser))
|
||||
} else {
|
||||
localStorage.removeItem(CACHED_USER_KEY)
|
||||
}
|
||||
}, [authUser])
|
||||
|
||||
useEffect(() => {
|
||||
return onIdTokenChanged(
|
||||
auth,
|
||||
async (fbUser) => {
|
||||
if (fbUser) {
|
||||
setTokenCookies({
|
||||
id: await fbUser.getIdToken(),
|
||||
refresh: fbUser.refreshToken,
|
||||
})
|
||||
setUserCookie(JSON.stringify(fbUser.toJSON()))
|
||||
let current = await getUserAndPrivateUser(fbUser.uid)
|
||||
if (!current.user || !current.privateUser) {
|
||||
const deviceToken = ensureDeviceToken()
|
||||
current = (await createUser({ deviceToken })) as UserAndPrivateUser
|
||||
setCachedReferralInfoForUser(current.user)
|
||||
}
|
||||
setAuthUser(current)
|
||||
// Persist to local storage, to reduce login blink next time.
|
||||
// Note: Cap on localStorage size is ~5mb
|
||||
localStorage.setItem(CACHED_USER_KEY, JSON.stringify(current))
|
||||
setCachedReferralInfoForUser(current.user)
|
||||
} else {
|
||||
// User logged out; reset to null
|
||||
deleteTokenCookies()
|
||||
setUserCookie(undefined)
|
||||
setAuthUser(null)
|
||||
localStorage.removeItem(CACHED_USER_KEY)
|
||||
}
|
||||
},
|
||||
(e) => {
|
||||
|
@ -87,29 +103,32 @@ export function AuthProvider(props: {
|
|||
}, [setAuthUser])
|
||||
|
||||
const uid = authUser?.user.id
|
||||
const username = authUser?.user.username
|
||||
useEffect(() => {
|
||||
if (uid && username) {
|
||||
if (uid) {
|
||||
identifyUser(uid)
|
||||
setUserProperty('username', username)
|
||||
const userListener = listenForUser(uid, (user) =>
|
||||
setAuthUser((authUser) => {
|
||||
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
|
||||
return { ...authUser!, user: user! }
|
||||
})
|
||||
)
|
||||
const userListener = listenForUser(uid, (user) => {
|
||||
setAuthUser((currAuthUser) =>
|
||||
currAuthUser && user ? { ...currAuthUser, user } : null
|
||||
)
|
||||
})
|
||||
const privateUserListener = listenForPrivateUser(uid, (privateUser) => {
|
||||
setAuthUser((authUser) => {
|
||||
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
|
||||
return { ...authUser!, privateUser: privateUser! }
|
||||
})
|
||||
setAuthUser((currAuthUser) =>
|
||||
currAuthUser && privateUser ? { ...currAuthUser, privateUser } : null
|
||||
)
|
||||
})
|
||||
return () => {
|
||||
userListener()
|
||||
privateUserListener()
|
||||
}
|
||||
}
|
||||
}, [uid, username, setAuthUser])
|
||||
}, [uid, setAuthUser])
|
||||
|
||||
const username = authUser?.user.username
|
||||
useEffect(() => {
|
||||
if (username != null) {
|
||||
setUserProperty('username', username)
|
||||
}
|
||||
}, [username])
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={authUser}>{children}</AuthContext.Provider>
|
||||
|
|
|
@ -2,6 +2,7 @@ import Router from 'next/router'
|
|||
import clsx from 'clsx'
|
||||
import { MouseEvent, useEffect, useState } from 'react'
|
||||
import { UserCircleIcon, UserIcon, UsersIcon } from '@heroicons/react/solid'
|
||||
import Image from 'next/future/image'
|
||||
|
||||
export function Avatar(props: {
|
||||
username?: string
|
||||
|
@ -14,6 +15,7 @@ export function Avatar(props: {
|
|||
const [avatarUrl, setAvatarUrl] = useState(props.avatarUrl)
|
||||
useEffect(() => setAvatarUrl(props.avatarUrl), [props.avatarUrl])
|
||||
const s = size == 'xs' ? 6 : size === 'sm' ? 8 : size || 10
|
||||
const sizeInPx = s * 4
|
||||
|
||||
const onClick =
|
||||
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"
|
||||
// item with a fake grey user circle guy even if you aren't signed in
|
||||
return avatarUrl ? (
|
||||
<img
|
||||
<Image
|
||||
width={sizeInPx}
|
||||
height={sizeInPx}
|
||||
className={clsx(
|
||||
'flex-shrink-0 rounded-full bg-white object-cover',
|
||||
`w-${s} h-${s}`,
|
||||
|
|
|
@ -35,10 +35,13 @@ export default function BetButton(props: {
|
|||
{user ? (
|
||||
<Button
|
||||
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)}
|
||||
>
|
||||
Bet
|
||||
Predict
|
||||
</Button>
|
||||
) : (
|
||||
<BetSignUpPrompt />
|
||||
|
|
|
@ -8,6 +8,7 @@ import { Col } from './layout/col'
|
|||
import { Row } from './layout/row'
|
||||
import { Spacer } from './layout/spacer'
|
||||
import {
|
||||
formatLargeNumber,
|
||||
formatMoney,
|
||||
formatPercent,
|
||||
formatWithCommas,
|
||||
|
@ -28,7 +29,7 @@ import { getProbability } from 'common/calculate'
|
|||
import { useFocus } from 'web/hooks/use-focus'
|
||||
import { useUserContractBets } from 'web/hooks/use-user-bets'
|
||||
import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm'
|
||||
import { getFormattedMappedValue } from 'common/pseudo-numeric'
|
||||
import { getFormattedMappedValue, getMappedValue } from 'common/pseudo-numeric'
|
||||
import { SellRow } from './sell-row'
|
||||
import { useSaveBinaryShares } from './use-save-binary-shares'
|
||||
import { BetSignUpPrompt } from './sign-up-prompt'
|
||||
|
@ -39,7 +40,8 @@ import { LimitBets } from './limit-bets'
|
|||
import { PillButton } from './buttons/pill-button'
|
||||
import { YesNoSelector } from './yes-no-selector'
|
||||
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: {
|
||||
contract: CPMMBinaryContract | PseudoNumericContract
|
||||
|
@ -67,27 +69,32 @@ export function BetPanel(props: {
|
|||
className
|
||||
)}
|
||||
>
|
||||
<QuickOrLimitBet
|
||||
isLimitOrder={isLimitOrder}
|
||||
setIsLimitOrder={setIsLimitOrder}
|
||||
hideToggle={!user}
|
||||
/>
|
||||
<BuyPanel
|
||||
hidden={isLimitOrder}
|
||||
contract={contract}
|
||||
user={user}
|
||||
unfilledBets={unfilledBets}
|
||||
/>
|
||||
<LimitOrderPanel
|
||||
hidden={!isLimitOrder}
|
||||
contract={contract}
|
||||
user={user}
|
||||
unfilledBets={unfilledBets}
|
||||
/>
|
||||
|
||||
<BetSignUpPrompt />
|
||||
|
||||
{!user && <PlayMoneyDisclaimer />}
|
||||
{user ? (
|
||||
<>
|
||||
<QuickOrLimitBet
|
||||
isLimitOrder={isLimitOrder}
|
||||
setIsLimitOrder={setIsLimitOrder}
|
||||
hideToggle={!user}
|
||||
/>
|
||||
<BuyPanel
|
||||
hidden={isLimitOrder}
|
||||
contract={contract}
|
||||
user={user}
|
||||
unfilledBets={unfilledBets}
|
||||
/>
|
||||
<LimitOrderPanel
|
||||
hidden={!isLimitOrder}
|
||||
contract={contract}
|
||||
user={user}
|
||||
unfilledBets={unfilledBets}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<BetSignUpPrompt />
|
||||
<PlayMoneyDisclaimer />
|
||||
</>
|
||||
)}
|
||||
</Col>
|
||||
|
||||
{user && unfilledBets.length > 0 && (
|
||||
|
@ -178,17 +185,13 @@ function BuyPanel(props: {
|
|||
|
||||
const [inputRef, focusAmountInput] = useFocus()
|
||||
|
||||
// useEffect(() => {
|
||||
// if (selected) {
|
||||
// if (isIOS()) window.scrollTo(0, window.scrollY + 200)
|
||||
// focusAmountInput()
|
||||
// }
|
||||
// }, [selected, focusAmountInput])
|
||||
|
||||
function onBetChoice(choice: 'YES' | 'NO') {
|
||||
setOutcome(choice)
|
||||
setWasSubmitted(false)
|
||||
focusAmountInput()
|
||||
|
||||
if (!isIOS() && !isAndroid()) {
|
||||
focusAmountInput()
|
||||
}
|
||||
}
|
||||
|
||||
function onBetChange(newAmount: number | undefined) {
|
||||
|
@ -251,17 +254,33 @@ function BuyPanel(props: {
|
|||
const resultProb = getCpmmProbability(newPool, newP)
|
||||
const probStayedSame =
|
||||
formatPercent(resultProb) === formatPercent(initialProb)
|
||||
|
||||
const probChange = Math.abs(resultProb - initialProb)
|
||||
|
||||
const currentPayout = newBet.shares
|
||||
|
||||
const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0
|
||||
const currentReturnPercent = formatPercent(currentReturn)
|
||||
|
||||
const format = getFormattedMappedValue(contract)
|
||||
|
||||
const getValue = getMappedValue(contract)
|
||||
const rawDifference = Math.abs(getValue(resultProb) - getValue(initialProb))
|
||||
const displayedDifference = isPseudoNumeric
|
||||
? formatLargeNumber(rawDifference)
|
||||
: formatPercent(rawDifference)
|
||||
|
||||
const bankrollFraction = (betAmount ?? 0) / (user?.balance ?? 1e9)
|
||||
|
||||
const warning =
|
||||
(betAmount ?? 0) > 10 && bankrollFraction >= 0.5 && bankrollFraction <= 1
|
||||
? `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 (
|
||||
<Col className={hidden ? 'hidden' : ''}>
|
||||
<div className="my-3 text-left text-sm text-gray-500">
|
||||
|
@ -275,7 +294,13 @@ function BuyPanel(props: {
|
|||
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
|
||||
inputClassName="w-full max-w-none"
|
||||
amount={betAmount}
|
||||
|
@ -284,36 +309,9 @@ function BuyPanel(props: {
|
|||
setError={setError}
|
||||
disabled={isSubmitting}
|
||||
inputRef={inputRef}
|
||||
showSliderOnMobile
|
||||
/>
|
||||
|
||||
{(betAmount ?? 0) > 10 &&
|
||||
bankrollFraction >= 0.5 &&
|
||||
bankrollFraction <= 1 ? (
|
||||
<AlertBox
|
||||
title="Whoa, there!"
|
||||
text={`You might not want to spend ${formatPercent(
|
||||
bankrollFraction
|
||||
)} of your balance on a single bet. \n\nCurrent balance: ${formatMoney(
|
||||
user?.balance ?? 0
|
||||
)}`}
|
||||
/>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
|
||||
{(betAmount ?? 0) > 10 && probChange >= 0.3 ? (
|
||||
<AlertBox
|
||||
title="Whoa, there!"
|
||||
text={`Are you sure you want to move the market ${
|
||||
isPseudoNumeric && contract.isLogScale
|
||||
? 'this much'
|
||||
: format(probChange)
|
||||
}?`}
|
||||
/>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
|
||||
<Col className="mt-3 w-full gap-3">
|
||||
<Row className="items-center justify-between text-sm">
|
||||
<div className="text-gray-500">
|
||||
|
@ -341,9 +339,6 @@ function BuyPanel(props: {
|
|||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* <InfoTooltip
|
||||
text={`Includes ${formatMoneyWithDecimals(totalFees)} in fees`}
|
||||
/> */}
|
||||
</Row>
|
||||
<div>
|
||||
<span className="mr-2 whitespace-nowrap">
|
||||
|
@ -357,23 +352,23 @@ function BuyPanel(props: {
|
|||
<Spacer h={8} />
|
||||
|
||||
{user && (
|
||||
<button
|
||||
className={clsx(
|
||||
<WarningConfirmationButton
|
||||
warning={warning}
|
||||
onSubmit={submitBet}
|
||||
isSubmitting={isSubmitting}
|
||||
disabled={!!betDisabled}
|
||||
openModalButtonClass={clsx(
|
||||
'btn mb-2 flex-1',
|
||||
betDisabled
|
||||
? 'btn-disabled'
|
||||
: outcome === 'YES'
|
||||
? 'btn-primary'
|
||||
: 'border-none bg-red-400 hover:bg-red-500',
|
||||
isSubmitting ? 'loading' : ''
|
||||
: 'border-none bg-red-400 hover:bg-red-500'
|
||||
)}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
@ -559,7 +554,7 @@ function LimitOrderPanel(props: {
|
|||
<Row className="mt-1 items-center gap-4">
|
||||
<Col className="gap-2">
|
||||
<div className="relative ml-1 text-sm text-gray-500">
|
||||
Bet {isPseudoNumeric ? <HigherLabel /> : <YesLabel />} up to
|
||||
Buy {isPseudoNumeric ? <HigherLabel /> : <YesLabel />} up to
|
||||
</div>
|
||||
<ProbabilityOrNumericInput
|
||||
contract={contract}
|
||||
|
@ -570,7 +565,7 @@ function LimitOrderPanel(props: {
|
|||
</Col>
|
||||
<Col className="gap-2">
|
||||
<div className="ml-1 text-sm text-gray-500">
|
||||
Bet {isPseudoNumeric ? <LowerLabel /> : <NoLabel />} down to
|
||||
Buy {isPseudoNumeric ? <LowerLabel /> : <NoLabel />} down to
|
||||
</div>
|
||||
<ProbabilityOrNumericInput
|
||||
contract={contract}
|
||||
|
@ -593,9 +588,15 @@ function LimitOrderPanel(props: {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-1 mb-3 text-left text-sm text-gray-500">
|
||||
Max amount<span className="ml-1 text-red-500">*</span>
|
||||
</div>
|
||||
<Row className="mt-1 mb-3 justify-between text-left text-sm text-gray-500">
|
||||
<span>
|
||||
Max amount<span className="ml-1 text-red-500">*</span>
|
||||
</span>
|
||||
<span className={'xl:hidden'}>
|
||||
Balance: {formatMoney(user?.balance ?? 0)}
|
||||
</span>
|
||||
</Row>
|
||||
|
||||
<BuyAmountInput
|
||||
inputClassName="w-full max-w-none"
|
||||
amount={betAmount}
|
||||
|
@ -603,6 +604,7 @@ function LimitOrderPanel(props: {
|
|||
error={error}
|
||||
setError={setError}
|
||||
disabled={isSubmitting}
|
||||
showSliderOnMobile
|
||||
/>
|
||||
|
||||
<Col className="mt-3 w-full gap-3">
|
||||
|
@ -733,15 +735,16 @@ function QuickOrLimitBet(props: {
|
|||
|
||||
return (
|
||||
<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 && (
|
||||
<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
|
||||
selected={!isLimitOrder}
|
||||
onSelect={() => {
|
||||
setIsLimitOrder(false)
|
||||
track('select quick order')
|
||||
}}
|
||||
xs={true}
|
||||
>
|
||||
Quick
|
||||
</PillButton>
|
||||
|
@ -751,6 +754,7 @@ function QuickOrLimitBet(props: {
|
|||
setIsLimitOrder(true)
|
||||
track('select limit order')
|
||||
}}
|
||||
xs={true}
|
||||
>
|
||||
Limit
|
||||
</PillButton>
|
||||
|
|
|
@ -1,14 +1,13 @@
|
|||
import Link from 'next/link'
|
||||
import { keyBy, groupBy, mapValues, sortBy, partition, sumBy } from 'lodash'
|
||||
import dayjs from 'dayjs'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid'
|
||||
|
||||
import { Bet } from 'web/lib/firebase/bets'
|
||||
import { User } from 'web/lib/firebase/users'
|
||||
import {
|
||||
formatLargeNumber,
|
||||
formatMoney,
|
||||
formatPercent,
|
||||
formatWithCommas,
|
||||
|
@ -35,8 +34,6 @@ import {
|
|||
resolvedPayout,
|
||||
getContractBetNullMetrics,
|
||||
} from 'common/calculate'
|
||||
import { useTimeSinceFirstRender } from 'web/hooks/use-time-since-first-render'
|
||||
import { trackLatency } from 'web/lib/firebase/tracking'
|
||||
import { NumericContract } from 'common/contract'
|
||||
import { formatNumericProbability } from 'common/pseudo-numeric'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
|
@ -85,13 +82,6 @@ export function BetsList(props: { user: User }) {
|
|||
const start = page * CONTRACTS_PER_PAGE
|
||||
const end = start + CONTRACTS_PER_PAGE
|
||||
|
||||
const getTime = useTimeSinceFirstRender()
|
||||
useEffect(() => {
|
||||
if (bets && contractsById && signedInUser) {
|
||||
trackLatency(signedInUser.id, 'portfolio', getTime())
|
||||
}
|
||||
}, [signedInUser, bets, contractsById, getTime])
|
||||
|
||||
if (!bets || !contractsById) {
|
||||
return <LoadingIndicator />
|
||||
}
|
||||
|
@ -171,7 +161,7 @@ export function BetsList(props: { user: User }) {
|
|||
((currentBetsValue - currentInvested) / (currentInvested + 0.1)) * 100
|
||||
|
||||
return (
|
||||
<Col className="mt-6">
|
||||
<Col>
|
||||
<Col className="mx-4 gap-4 sm:flex-row sm:justify-between md:mx-0">
|
||||
<Row className="gap-8">
|
||||
<Col>
|
||||
|
@ -219,26 +209,27 @@ export function BetsList(props: { user: User }) {
|
|||
|
||||
<Col className="mt-6 divide-y">
|
||||
{displayedContracts.length === 0 ? (
|
||||
<NoBets user={user} />
|
||||
<NoMatchingBets />
|
||||
) : (
|
||||
displayedContracts.map((contract) => (
|
||||
<ContractBets
|
||||
key={contract.id}
|
||||
contract={contract}
|
||||
bets={contractBets[contract.id] ?? []}
|
||||
metric={sort === 'profit' ? 'profit' : 'value'}
|
||||
isYourBets={isYourBets}
|
||||
<>
|
||||
{displayedContracts.map((contract) => (
|
||||
<ContractBets
|
||||
key={contract.id}
|
||||
contract={contract}
|
||||
bets={contractBets[contract.id] ?? []}
|
||||
metric={sort === 'profit' ? 'profit' : 'value'}
|
||||
isYourBets={isYourBets}
|
||||
/>
|
||||
))}
|
||||
<Pagination
|
||||
page={page}
|
||||
itemsPerPage={CONTRACTS_PER_PAGE}
|
||||
totalItems={filteredContracts.length}
|
||||
setPage={setPage}
|
||||
/>
|
||||
))
|
||||
</>
|
||||
)}
|
||||
</Col>
|
||||
|
||||
<Pagination
|
||||
page={page}
|
||||
itemsPerPage={CONTRACTS_PER_PAGE}
|
||||
totalItems={filteredContracts.length}
|
||||
setPage={setPage}
|
||||
/>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
@ -246,7 +237,7 @@ export function BetsList(props: { user: User }) {
|
|||
const NoBets = ({ user }: { user: User }) => {
|
||||
const me = useUser()
|
||||
return (
|
||||
<div className="mx-4 text-gray-500">
|
||||
<div className="mx-4 py-4 text-gray-500">
|
||||
{user.id === me?.id ? (
|
||||
<>
|
||||
You have not made any bets yet.{' '}
|
||||
|
@ -260,6 +251,11 @@ const NoBets = ({ user }: { user: User }) => {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
const NoMatchingBets = () => (
|
||||
<div className="mx-4 py-4 text-gray-500">
|
||||
No bets matching the current filter.
|
||||
</div>
|
||||
)
|
||||
|
||||
function ContractBets(props: {
|
||||
contract: Contract
|
||||
|
@ -483,23 +479,6 @@ export function BetsSummary(props: {
|
|||
<div className="whitespace-nowrap">{formatMoney(noWinnings)}</div>
|
||||
</Col>
|
||||
</>
|
||||
) : isPseudoNumeric ? (
|
||||
<>
|
||||
<Col>
|
||||
<div className="whitespace-nowrap text-sm text-gray-500">
|
||||
Payout if {'>='} {formatLargeNumber(contract.max)}
|
||||
</div>
|
||||
<div className="whitespace-nowrap">
|
||||
{formatMoney(yesWinnings)}
|
||||
</div>
|
||||
</Col>
|
||||
<Col>
|
||||
<div className="whitespace-nowrap text-sm text-gray-500">
|
||||
Payout if {'<='} {formatLargeNumber(contract.min)}
|
||||
</div>
|
||||
<div className="whitespace-nowrap">{formatMoney(noWinnings)}</div>
|
||||
</Col>
|
||||
</>
|
||||
) : (
|
||||
<Col>
|
||||
<div className="whitespace-nowrap text-sm text-gray-500">
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { ReactNode } from 'react'
|
||||
import { MouseEventHandler, ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export type SizeType = '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
|
||||
|
@ -14,7 +14,7 @@ export type ColorType =
|
|||
|
||||
export function Button(props: {
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
onClick?: MouseEventHandler<any> | undefined
|
||||
children?: ReactNode
|
||||
size?: SizeType
|
||||
color?: ColorType
|
||||
|
|
|
@ -5,19 +5,19 @@ export function PillButton(props: {
|
|||
selected: boolean
|
||||
onSelect: () => void
|
||||
color?: string
|
||||
big?: boolean
|
||||
xs?: boolean
|
||||
children: ReactNode
|
||||
}) {
|
||||
const { children, selected, onSelect, color, big } = props
|
||||
const { children, selected, onSelect, color, xs } = props
|
||||
|
||||
return (
|
||||
<button
|
||||
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
|
||||
? ['text-white', color ?? 'bg-greyscale-6']
|
||||
: 'bg-greyscale-2 hover:bg-greyscale-3',
|
||||
big ? 'px-8 py-2' : 'px-3 py-1.5 text-sm'
|
||||
: 'bg-greyscale-2 hover:bg-greyscale-3'
|
||||
)}
|
||||
onClick={onSelect}
|
||||
>
|
||||
|
|
|
@ -33,12 +33,12 @@ export function Carousel(props: {
|
|||
}, 500)
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(onScroll, [])
|
||||
useEffect(onScroll, [children])
|
||||
|
||||
return (
|
||||
<div className={clsx('relative', className)}>
|
||||
<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}
|
||||
onScroll={onScroll}
|
||||
>
|
||||
|
|
|
@ -27,7 +27,8 @@ export function AcceptChallengeButton(props: {
|
|||
setErrorText('')
|
||||
}, [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 = () => {
|
||||
setLoading(true)
|
||||
|
|
|
@ -147,7 +147,7 @@ function CreateChallengeForm(props: {
|
|||
setFinishedCreating(true)
|
||||
}}
|
||||
>
|
||||
<Title className="!mt-2" text="Challenge bet " />
|
||||
<Title className="!mt-2 hidden sm:block" text="Challenge bet " />
|
||||
|
||||
<div className="mb-8">
|
||||
Challenge a friend to bet on{' '}
|
||||
|
@ -170,72 +170,76 @@ function CreateChallengeForm(props: {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-col flex-wrap justify-center gap-x-5 gap-y-2">
|
||||
<div>You'll bet:</div>
|
||||
<Row
|
||||
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',
|
||||
}
|
||||
})
|
||||
<Col className="mt-2 flex-wrap justify-center gap-x-5 sm:gap-y-2">
|
||||
<Col>
|
||||
<div>You'll bet:</div>
|
||||
<Row
|
||||
className={
|
||||
'form-control w-full max-w-xs items-center justify-between gap-4 pr-3'
|
||||
}
|
||||
>
|
||||
<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)
|
||||
|
||||
amount={challengeInfo.amount || undefined}
|
||||
onChange={(newAmount) =>
|
||||
setChallengeInfo((m: challengeInfo) => {
|
||||
return {
|
||||
...m,
|
||||
acceptorAmount: newAmount ?? 0,
|
||||
amount: newAmount ?? 0,
|
||||
acceptorAmount: editingAcceptorAmount
|
||||
? m.acceptorAmount
|
||||
: newAmount ?? 0,
|
||||
}
|
||||
})
|
||||
}}
|
||||
}
|
||||
error={undefined}
|
||||
label={'M$'}
|
||||
inputClassName="w-24"
|
||||
/>
|
||||
</div>
|
||||
<span>on</span>
|
||||
{challengeInfo.outcome === 'YES' ? <NoLabel /> : <YesLabel />}
|
||||
</Row>
|
||||
</div>
|
||||
<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
|
||||
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 && (
|
||||
<Button
|
||||
size="2xs"
|
||||
|
|
175
web/components/comment-input.tsx
Normal file
175
web/components/comment-input.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -47,13 +47,13 @@ export function ConfirmationButton(props: {
|
|||
{children}
|
||||
<Row className="gap-4">
|
||||
<div
|
||||
className={clsx('btn normal-case', cancelBtn?.className)}
|
||||
className={clsx('btn', cancelBtn?.className)}
|
||||
onClick={() => updateOpen(false)}
|
||||
>
|
||||
{cancelBtn?.label ?? 'Cancel'}
|
||||
</div>
|
||||
<div
|
||||
className={clsx('btn normal-case', submitBtn?.className)}
|
||||
className={clsx('btn', submitBtn?.className)}
|
||||
onClick={
|
||||
onSubmitWithSuccess
|
||||
? () =>
|
||||
|
@ -69,7 +69,7 @@ export function ConfirmationButton(props: {
|
|||
</Col>
|
||||
</Modal>
|
||||
<div
|
||||
className={clsx('btn normal-case', openModalBtn.className)}
|
||||
className={clsx('btn', openModalBtn.className)}
|
||||
onClick={() => updateOpen(true)}
|
||||
>
|
||||
{openModalBtn.icon}
|
||||
|
|
|
@ -43,10 +43,13 @@ export const SORTS = [
|
|||
{ label: 'Trending', value: 'score' },
|
||||
{ label: 'Most traded', value: 'most-traded' },
|
||||
{ label: '24h volume', value: '24-hour-vol' },
|
||||
{ label: '24h change', value: 'prob-change-day' },
|
||||
{ label: 'Last updated', value: 'last-updated' },
|
||||
{ label: 'Subsidy', value: 'liquidity' },
|
||||
{ label: 'Close date', value: 'close-date' },
|
||||
{ label: 'Resolve date', value: 'resolve-date' },
|
||||
{ label: 'Highest %', value: 'prob-descending' },
|
||||
{ label: 'Lowest %', value: 'prob-ascending' },
|
||||
] as const
|
||||
|
||||
export type Sort = typeof SORTS[number]['value']
|
||||
|
@ -66,6 +69,7 @@ type AdditionalFilter = {
|
|||
excludeContractIds?: string[]
|
||||
groupSlug?: string
|
||||
yourBets?: boolean
|
||||
followed?: boolean
|
||||
}
|
||||
|
||||
export function ContractSearch(props: {
|
||||
|
@ -85,6 +89,7 @@ export function ContractSearch(props: {
|
|||
useQueryUrlParam?: boolean
|
||||
isWholePage?: boolean
|
||||
noControls?: boolean
|
||||
maxResults?: number
|
||||
renderContracts?: (
|
||||
contracts: Contract[] | undefined,
|
||||
loadMore: () => void
|
||||
|
@ -104,6 +109,7 @@ export function ContractSearch(props: {
|
|||
useQueryUrlParam,
|
||||
isWholePage,
|
||||
noControls,
|
||||
maxResults,
|
||||
renderContracts,
|
||||
} = props
|
||||
|
||||
|
@ -186,7 +192,8 @@ export function ContractSearch(props: {
|
|||
const contracts = state.pages
|
||||
.flat()
|
||||
.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) {
|
||||
return <ContractSearchFirestore additionalFilter={additionalFilter} />
|
||||
|
@ -282,13 +289,26 @@ function ContractSearchControls(props: {
|
|||
: DEFAULT_CATEGORY_GROUPS.map((g) => g.slug)
|
||||
|
||||
const memberPillGroups = sortBy(
|
||||
memberGroups.filter((group) => group.contractIds.length > 0),
|
||||
(group) => group.contractIds.length
|
||||
memberGroups.filter((group) => group.totalContracts > 0),
|
||||
(group) => group.totalContracts
|
||||
).reverse()
|
||||
|
||||
const pillGroups: { name: string; slug: string }[] =
|
||||
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 = [
|
||||
additionalFilter?.creatorId
|
||||
? `creatorId:${additionalFilter.creatorId}`
|
||||
|
@ -301,6 +321,7 @@ function ContractSearchControls(props: {
|
|||
? // Show contracts bet on by the user
|
||||
`uniqueBettorIds:${user.id}`
|
||||
: '',
|
||||
...(additionalFilter?.followed ? personalFilters : []),
|
||||
]
|
||||
const facetFilters = query
|
||||
? additionalFilters
|
||||
|
@ -317,21 +338,7 @@ function ContractSearchControls(props: {
|
|||
state.pillFilter !== 'your-bets'
|
||||
? `groupLinks.slug:${state.pillFilter}`
|
||||
: '',
|
||||
state.pillFilter === 'personal'
|
||||
? // 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 === 'personal' ? personalFilters : []),
|
||||
state.pillFilter === 'your-bets' && user
|
||||
? // Show contracts bet on by the user
|
||||
`uniqueBettorIds:${user.id}`
|
||||
|
@ -442,7 +449,7 @@ function ContractSearchControls(props: {
|
|||
selected={state.pillFilter === 'your-bets'}
|
||||
onSelect={selectPill('your-bets')}
|
||||
>
|
||||
Your bets
|
||||
Your trades
|
||||
</PillButton>
|
||||
)}
|
||||
|
||||
|
|
|
@ -35,7 +35,6 @@ import { Tooltip } from '../tooltip'
|
|||
|
||||
export function ContractCard(props: {
|
||||
contract: Contract
|
||||
showHotVolume?: boolean
|
||||
showTime?: ShowTime
|
||||
className?: string
|
||||
questionClass?: string
|
||||
|
@ -45,7 +44,6 @@ export function ContractCard(props: {
|
|||
trackingPostfix?: string
|
||||
}) {
|
||||
const {
|
||||
showHotVolume,
|
||||
showTime,
|
||||
className,
|
||||
questionClass,
|
||||
|
@ -147,7 +145,6 @@ export function ContractCard(props: {
|
|||
<AvatarDetails contract={contract} short={true} className="md:hidden" />
|
||||
<MiscDetails
|
||||
contract={contract}
|
||||
showHotVolume={showHotVolume}
|
||||
showTime={showTime}
|
||||
hideGroupLink={hideGroupLink}
|
||||
/>
|
||||
|
|
|
@ -6,6 +6,7 @@ import Textarea from 'react-expanding-textarea'
|
|||
import { Contract, MAX_DESCRIPTION_LENGTH } from 'common/contract'
|
||||
import { exhibitExts, parseTags } from 'common/util/parse'
|
||||
import { useAdmin } from 'web/hooks/use-admin'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { updateContract } from 'web/lib/firebase/contracts'
|
||||
import { Row } from '../layout/row'
|
||||
import { Content } from '../editor'
|
||||
|
@ -17,11 +18,12 @@ import { insertContent } from '../editor/utils'
|
|||
|
||||
export function ContractDescription(props: {
|
||||
contract: Contract
|
||||
isCreator: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const { contract, isCreator, className } = props
|
||||
const { contract, className } = props
|
||||
const isAdmin = useAdmin()
|
||||
const user = useUser()
|
||||
const isCreator = user?.id === contract.creatorId
|
||||
return (
|
||||
<div className={clsx('mt-2 text-gray-700', className)}>
|
||||
{isCreator || isAdmin ? (
|
||||
|
|
|
@ -2,12 +2,12 @@ import {
|
|||
ClockIcon,
|
||||
DatabaseIcon,
|
||||
PencilIcon,
|
||||
TrendingUpIcon,
|
||||
UserGroupIcon,
|
||||
} from '@heroicons/react/outline'
|
||||
import clsx from 'clsx'
|
||||
import { Editor } from '@tiptap/react'
|
||||
import dayjs from 'dayjs'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { Row } from '../layout/row'
|
||||
import { formatMoney } from 'common/util/format'
|
||||
|
@ -26,11 +26,10 @@ import { Button } from 'web/components/button'
|
|||
import { Modal } from 'web/components/layout/modal'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { ContractGroupsList } from 'web/components/groups/contract-groups-list'
|
||||
import { SiteLink } from 'web/components/site-link'
|
||||
import { groupPath } from 'web/lib/firebase/groups'
|
||||
import { linkClass } from 'web/components/site-link'
|
||||
import { getGroupLinkToDisplay, groupPath } from 'web/lib/firebase/groups'
|
||||
import { insertContent } from '../editor/utils'
|
||||
import { contractMetrics } from 'common/contract-details'
|
||||
import { User } from 'common/user'
|
||||
import { UserLink } from 'web/components/user-link'
|
||||
import { FeaturedContractBadge } from 'web/components/contract/featured-contract-badge'
|
||||
import { Tooltip } from 'web/components/tooltip'
|
||||
|
@ -40,30 +39,19 @@ export type ShowTime = 'resolve-date' | 'close-date'
|
|||
|
||||
export function MiscDetails(props: {
|
||||
contract: Contract
|
||||
showHotVolume?: boolean
|
||||
showTime?: ShowTime
|
||||
hideGroupLink?: boolean
|
||||
}) {
|
||||
const { contract, showHotVolume, showTime, hideGroupLink } = props
|
||||
const {
|
||||
volume,
|
||||
volume24Hours,
|
||||
closeTime,
|
||||
isResolved,
|
||||
createdTime,
|
||||
resolutionTime,
|
||||
groupLinks,
|
||||
} = contract
|
||||
const { contract, showTime, hideGroupLink } = props
|
||||
const { volume, closeTime, isResolved, createdTime, resolutionTime } =
|
||||
contract
|
||||
|
||||
const isNew = createdTime > Date.now() - DAY_MS && !isResolved
|
||||
const groupToDisplay = getGroupLinkToDisplay(contract)
|
||||
|
||||
return (
|
||||
<Row className="items-center gap-3 truncate text-sm text-gray-400">
|
||||
{showHotVolume ? (
|
||||
<Row className="gap-0.5">
|
||||
<TrendingUpIcon className="h-5 w-5" /> {formatMoney(volume24Hours)}
|
||||
</Row>
|
||||
) : showTime === 'close-date' ? (
|
||||
{showTime === 'close-date' ? (
|
||||
<Row className="gap-0.5 whitespace-nowrap">
|
||||
<ClockIcon className="h-5 w-5" />
|
||||
{(closeTime || 0) < Date.now() ? 'Closed' : 'Closes'}{' '}
|
||||
|
@ -83,13 +71,12 @@ export function MiscDetails(props: {
|
|||
<NewContractBadge />
|
||||
)}
|
||||
|
||||
{!hideGroupLink && groupLinks && groupLinks.length > 0 && (
|
||||
<SiteLink
|
||||
href={groupPath(groupLinks[0].slug)}
|
||||
className="truncate text-sm text-gray-400"
|
||||
>
|
||||
{groupLinks[0].name}
|
||||
</SiteLink>
|
||||
{!hideGroupLink && groupToDisplay && (
|
||||
<Link prefetch={false} href={groupPath(groupToDisplay.slug)}>
|
||||
<a className={clsx(linkClass, 'truncate text-sm text-gray-400')}>
|
||||
{groupToDisplay.name}
|
||||
</a>
|
||||
</Link>
|
||||
)}
|
||||
</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: {
|
||||
contract: Contract
|
||||
user: User | null | undefined
|
||||
isCreator?: boolean
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const { contract, isCreator, disabled } = props
|
||||
const { contract, disabled } = props
|
||||
const {
|
||||
closeTime,
|
||||
creatorName,
|
||||
creatorUsername,
|
||||
creatorId,
|
||||
groupLinks,
|
||||
creatorAvatarUrl,
|
||||
resolutionTime,
|
||||
} = contract
|
||||
const { volumeLabel, resolvedDate } = contractMetrics(contract)
|
||||
|
||||
const groupToDisplay =
|
||||
groupLinks?.sort((a, b) => a.createdTime - b.createdTime)[0] ?? null
|
||||
const user = useUser()
|
||||
const isCreator = user?.id === creatorId
|
||||
const [open, setOpen] = useState(false)
|
||||
const { width } = useWindowSize()
|
||||
const isMobile = (width ?? 0) < 600
|
||||
|
||||
const groupToDisplay = getGroupLinkToDisplay(contract)
|
||||
const groupInfo = groupToDisplay ? (
|
||||
<Row
|
||||
className={clsx(
|
||||
'items-center 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" />
|
||||
<span className="items-center truncate">{groupToDisplay.name}</span>
|
||||
</Row>
|
||||
</SiteLink>
|
||||
</Row>
|
||||
<Link prefetch={false} href={groupPath(groupToDisplay.slug)}>
|
||||
<a
|
||||
className={clsx(
|
||||
linkClass,
|
||||
'flex flex-row items-center truncate pr-0 sm:pr-2',
|
||||
isMobile ? 'max-w-[140px]' : 'max-w-[250px]'
|
||||
)}
|
||||
>
|
||||
<UserGroupIcon className="mx-1 inline h-5 w-5 shrink-0" />
|
||||
<span className="items-center truncate">{groupToDisplay.name}</span>
|
||||
</a>
|
||||
</Link>
|
||||
) : (
|
||||
<Button
|
||||
size={'xs'}
|
||||
|
@ -236,11 +198,7 @@ export function ContractDetails(props: {
|
|||
'max-h-[70vh] min-h-[20rem] overflow-auto rounded bg-white p-6'
|
||||
}
|
||||
>
|
||||
<ContractGroupsList
|
||||
groupLinks={groupLinks ?? []}
|
||||
contract={contract}
|
||||
user={user}
|
||||
/>
|
||||
<ContractGroupsList contract={contract} user={user} />
|
||||
</Col>
|
||||
</Modal>
|
||||
|
||||
|
@ -287,18 +245,18 @@ export function ContractDetails(props: {
|
|||
|
||||
export function ExtraMobileContractDetails(props: {
|
||||
contract: Contract
|
||||
user: User | null | undefined
|
||||
forceShowVolume?: boolean
|
||||
}) {
|
||||
const { contract, user, forceShowVolume } = props
|
||||
const { contract, forceShowVolume } = props
|
||||
const { volume, resolutionTime, closeTime, creatorId, uniqueBettorCount } =
|
||||
contract
|
||||
const user = useUser()
|
||||
const uniqueBettors = uniqueBettorCount ?? 0
|
||||
const { resolvedDate } = contractMetrics(contract)
|
||||
const volumeTranslation =
|
||||
volume > 800 || uniqueBettors > 20
|
||||
volume > 800 || uniqueBettors >= 20
|
||||
? 'High'
|
||||
: volume > 300 || uniqueBettors > 10
|
||||
: volume > 300 || uniqueBettors >= 10
|
||||
? 'Medium'
|
||||
: 'Low'
|
||||
|
||||
|
@ -336,7 +294,7 @@ export function ExtraMobileContractDetails(props: {
|
|||
<Tooltip
|
||||
text={`${formatMoney(
|
||||
volume
|
||||
)} bet - ${uniqueBettors} unique bettors`}
|
||||
)} bet - ${uniqueBettors} unique traders`}
|
||||
>
|
||||
{volumeTranslation}
|
||||
</Tooltip>
|
||||
|
@ -399,7 +357,7 @@ function EditableCloseDate(props: {
|
|||
return (
|
||||
<>
|
||||
{isEditingCloseTime ? (
|
||||
<Row className="z-10 mr-2 w-full shrink-0 items-start items-center gap-1">
|
||||
<Row className="z-10 mr-2 w-full shrink-0 items-center gap-1">
|
||||
<input
|
||||
type="date"
|
||||
className="input input-bordered shrink-0"
|
||||
|
|
|
@ -135,7 +135,7 @@ export function ContractInfoDialog(props: {
|
|||
</tr> */}
|
||||
|
||||
<tr>
|
||||
<td>Bettors</td>
|
||||
<td>Traders</td>
|
||||
<td>{bettorsCount}</td>
|
||||
</tr>
|
||||
|
||||
|
|
|
@ -49,7 +49,7 @@ export function ContractLeaderboard(props: {
|
|||
|
||||
return users && users.length > 0 ? (
|
||||
<Leaderboard
|
||||
title="🏅 Top bettors"
|
||||
title="🏅 Top traders"
|
||||
users={users || []}
|
||||
columns={[
|
||||
{
|
||||
|
@ -109,10 +109,6 @@ export function ContractTopTrades(props: {
|
|||
betsBySameUser={[betsById[topCommentId]]}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-500">
|
||||
{commentsById[topCommentId].userName} made{' '}
|
||||
{formatMoney(profitById[topCommentId] || 0)}!
|
||||
</div>
|
||||
<Spacer h={16} />
|
||||
</>
|
||||
)}
|
||||
|
@ -120,11 +116,11 @@ export function ContractTopTrades(props: {
|
|||
{/* If they're the same, only show the comment; otherwise show both */}
|
||||
{topBettor && topBetId !== topCommentId && profitById[topBetId] > 0 && (
|
||||
<>
|
||||
<Title text="💸 Smartest money" className="!mt-0" />
|
||||
<Title text="💸 Best bet" className="!mt-0" />
|
||||
<div className="relative flex items-start space-x-3 rounded-md bg-gray-50 px-2 py-4">
|
||||
<FeedBet contract={contract} bet={betsById[topBetId]} />
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-500">
|
||||
<div className="mt-2 ml-2 text-sm text-gray-500">
|
||||
{topBettor?.name} made {formatMoney(profitById[topBetId] || 0)}!
|
||||
</div>
|
||||
</>
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import React from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import { tradingAllowed } from 'web/lib/firebase/contracts'
|
||||
import { Col } from '../layout/col'
|
||||
|
@ -16,136 +15,154 @@ import {
|
|||
import { Bet } from 'common/bet'
|
||||
import BetButton from '../bet-button'
|
||||
import { AnswersGraph } from '../answers/answers-graph'
|
||||
import { Contract, CPMMBinaryContract } from 'common/contract'
|
||||
import { ContractDescription } from './contract-description'
|
||||
import {
|
||||
Contract,
|
||||
BinaryContract,
|
||||
CPMMContract,
|
||||
CPMMBinaryContract,
|
||||
FreeResponseContract,
|
||||
MultipleChoiceContract,
|
||||
NumericContract,
|
||||
PseudoNumericContract,
|
||||
} from 'common/contract'
|
||||
import { ContractDetails, ExtraMobileContractDetails } from './contract-details'
|
||||
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: {
|
||||
contract: Contract
|
||||
bets: Bet[]
|
||||
className?: string
|
||||
}) => {
|
||||
const { contract, bets, className } = props
|
||||
const { question, creatorId, outcomeType, resolution } = contract
|
||||
|
||||
const user = useUser()
|
||||
const isCreator = user?.id === creatorId
|
||||
|
||||
const isBinary = outcomeType === 'BINARY'
|
||||
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
|
||||
|
||||
return (
|
||||
<Col className={clsx('mb-6', className)}>
|
||||
<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>
|
||||
)
|
||||
const { contract, bets } = props
|
||||
switch (contract.outcomeType) {
|
||||
case 'BINARY':
|
||||
return <BinaryOverview contract={contract} bets={bets} />
|
||||
case 'NUMERIC':
|
||||
return <NumericOverview contract={contract} />
|
||||
case 'PSEUDO_NUMERIC':
|
||||
return <PseudoNumericOverview contract={contract} bets={bets} />
|
||||
case 'FREE_RESPONSE':
|
||||
case 'MULTIPLE_CHOICE':
|
||||
return <ChoiceOverview contract={contract} bets={bets} />
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,7 +13,6 @@ import { Tabs } from '../layout/tabs'
|
|||
import { Col } from '../layout/col'
|
||||
import { tradingAllowed } from 'web/lib/firebase/contracts'
|
||||
import { CommentTipMap } from 'web/hooks/use-tip-txns'
|
||||
import { useBets } from 'web/hooks/use-bets'
|
||||
import { useComments } from 'web/hooks/use-comments'
|
||||
import { useLiquidity } from 'web/hooks/use-liquidity'
|
||||
import { BetSignUpPrompt } from '../sign-up-prompt'
|
||||
|
@ -27,24 +26,23 @@ export function ContractTabs(props: {
|
|||
comments: ContractComment[]
|
||||
tips: CommentTipMap
|
||||
}) {
|
||||
const { contract, user, tips } = props
|
||||
const { contract, user, bets, tips } = props
|
||||
const { outcomeType } = contract
|
||||
|
||||
const bets = useBets(contract.id) ?? props.bets
|
||||
const lps = useLiquidity(contract.id) ?? []
|
||||
const lps = useLiquidity(contract.id)
|
||||
|
||||
const userBets =
|
||||
user && bets.filter((bet) => !bet.isAnte && bet.userId === user.id)
|
||||
const visibleBets = bets.filter(
|
||||
(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
|
||||
const updatedComments = useComments(contract.id)
|
||||
const comments = updatedComments ?? props.comments
|
||||
|
||||
const betActivity = (
|
||||
const betActivity = visibleLps && (
|
||||
<ContractBetsActivity
|
||||
contract={contract}
|
||||
bets={visibleBets}
|
||||
|
@ -116,13 +114,13 @@ export function ContractTabs(props: {
|
|||
badge: `${comments.length}`,
|
||||
},
|
||||
{
|
||||
title: 'Bets',
|
||||
title: 'Trades',
|
||||
content: betActivity,
|
||||
badge: `${visibleBets.length}`,
|
||||
},
|
||||
...(!user || !userBets?.length
|
||||
? []
|
||||
: [{ title: 'Your bets', content: yourTrades }]),
|
||||
: [{ title: 'Your trades', content: yourTrades }]),
|
||||
]}
|
||||
/>
|
||||
{!user ? (
|
||||
|
|
|
@ -27,6 +27,7 @@ export function ContractsGrid(props: {
|
|||
}
|
||||
highlightOptions?: ContractHighlightOptions
|
||||
trackingPostfix?: string
|
||||
breakpointColumns?: { [key: string]: number }
|
||||
}) {
|
||||
const {
|
||||
contracts,
|
||||
|
@ -67,7 +68,7 @@ export function ContractsGrid(props: {
|
|||
<Col className="gap-8">
|
||||
<Masonry
|
||||
// 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"
|
||||
columnClassName="pl-4 bg-clip-padding"
|
||||
>
|
||||
|
@ -113,6 +114,7 @@ export function CreatorContractsList(props: {
|
|||
additionalFilter={{
|
||||
creatorId: creator.id,
|
||||
}}
|
||||
persistPrefix={`user-${creator.id}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -5,20 +5,26 @@ import { Row } from '../layout/row'
|
|||
import { Contract } from 'web/lib/firebase/contracts'
|
||||
import React, { useState } from 'react'
|
||||
import { Button } from 'web/components/button'
|
||||
import { User } from 'common/user'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { ShareModal } from './share-modal'
|
||||
import { FollowMarketButton } from 'web/components/follow-market-button'
|
||||
import { LikeMarketButton } from 'web/components/contract/like-market-button'
|
||||
import { ContractInfoDialog } from 'web/components/contract/contract-info-dialog'
|
||||
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: {
|
||||
contract: Contract
|
||||
user: User | undefined | null
|
||||
}) {
|
||||
const { user, contract } = props
|
||||
|
||||
export function ExtraContractActionsRow(props: { contract: Contract }) {
|
||||
const { contract } = props
|
||||
const { outcomeType, resolution } = contract
|
||||
const user = useUser()
|
||||
const [isShareOpen, setShareOpen] = useState(false)
|
||||
const [openCreateChallengeModal, setOpenCreateChallengeModal] =
|
||||
useState(false)
|
||||
const showChallenge =
|
||||
user && outcomeType === 'BINARY' && !resolution && CHALLENGES_ENABLED
|
||||
|
||||
return (
|
||||
<Row className={'mt-0.5 justify-around sm:mt-2 lg:justify-start'}>
|
||||
|
@ -37,7 +43,6 @@ export function ExtraContractActionsRow(props: {
|
|||
/>
|
||||
<span>Share</span>
|
||||
</Col>
|
||||
|
||||
<ShareModal
|
||||
isOpen={isShareOpen}
|
||||
setOpen={setShareOpen}
|
||||
|
@ -46,6 +51,29 @@ export function ExtraContractActionsRow(props: {
|
|||
/>
|
||||
</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} />
|
||||
{user?.id !== contract.creatorId && (
|
||||
<LikeMarketButton contract={contract} user={user} />
|
||||
|
|
|
@ -39,14 +39,14 @@ export function LikeMarketButton(props: {
|
|||
return (
|
||||
<Button
|
||||
size={'lg'}
|
||||
className={'mb-1'}
|
||||
className={'max-w-xs self-center'}
|
||||
color={'gray-white'}
|
||||
onClick={onLike}
|
||||
>
|
||||
<Col className={'items-center sm:flex-row sm:gap-x-2'}>
|
||||
<Col className={'items-center sm:flex-row'}>
|
||||
<HeartIcon
|
||||
className={clsx(
|
||||
'h-6 w-6',
|
||||
'h-[24px] w-5 sm:mr-2',
|
||||
user &&
|
||||
(userLikedContractIds?.includes(contract.id) ||
|
||||
(!likes && contract.likedByUserIds?.includes(user.id)))
|
||||
|
|
98
web/components/contract/prob-change-table.tsx
Normal file
98
web/components/contract/prob-change-table.tsx
Normal 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>
|
||||
}
|
|
@ -45,7 +45,7 @@ export function ShareModal(props: {
|
|||
|
||||
return (
|
||||
<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" />
|
||||
<p>
|
||||
Earn{' '}
|
||||
|
@ -57,7 +57,7 @@ export function ShareModal(props: {
|
|||
<Button
|
||||
size="2xl"
|
||||
color="gradient"
|
||||
className={'mb-2 flex max-w-xs self-center'}
|
||||
className={'flex max-w-xs self-center'}
|
||||
onClick={() => {
|
||||
copyToClipboard(shareUrl)
|
||||
toast.success('Link copied!', {
|
||||
|
@ -68,17 +68,18 @@ export function ShareModal(props: {
|
|||
>
|
||||
{linkIcon} Copy link
|
||||
</Button>
|
||||
<Row className={'justify-center'}>or</Row>
|
||||
{showChallenge && (
|
||||
<Button
|
||||
size="lg"
|
||||
color="gray-white"
|
||||
size="2xl"
|
||||
color="gradient"
|
||||
className={'mb-2 flex max-w-xs self-center'}
|
||||
onClick={withTracking(
|
||||
() => setOpenCreateChallengeModal(true),
|
||||
'click challenge button'
|
||||
)}
|
||||
>
|
||||
<span>⚔️ Challenge a friend</span>
|
||||
<span>⚔️ Challenge</span>
|
||||
<CreateChallengeModal
|
||||
isOpen={openCreateChallengeModal}
|
||||
setOpen={(open) => {
|
||||
|
|
|
@ -1,27 +1,13 @@
|
|||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import { User } from 'web/lib/firebase/users'
|
||||
import { Button } from './button'
|
||||
import { SiteLink } from 'web/components/site-link'
|
||||
|
||||
export const CreateQuestionButton = (props: {
|
||||
user: User | null | undefined
|
||||
overrideText?: string
|
||||
className?: string
|
||||
query?: string
|
||||
}) => {
|
||||
const { user, overrideText, className, query } = props
|
||||
|
||||
if (!user || user?.isBannedFromPosting) return <></>
|
||||
|
||||
export const CreateQuestionButton = () => {
|
||||
return (
|
||||
<div className={clsx('flex justify-center', className)}>
|
||||
<Link href={`/create${query ? query : ''}`} passHref>
|
||||
<Button color="gradient" size="xl" className="mt-4">
|
||||
{overrideText ?? 'Create a market'}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<SiteLink href="/create">
|
||||
<Button color="gradient" size="xl" className="mt-4 w-full">
|
||||
Create a market
|
||||
</Button>
|
||||
</SiteLink>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -7,7 +7,6 @@ import { Col } from 'web/components/layout/col'
|
|||
|
||||
export function DoubleCarousel(props: {
|
||||
contracts: Contract[]
|
||||
seeMoreUrl?: string
|
||||
showTime?: ShowTime
|
||||
loadMore?: () => void
|
||||
}) {
|
||||
|
@ -19,7 +18,7 @@ export function DoubleCarousel(props: {
|
|||
? range(0, Math.floor(contracts.length / 2)).map((col) => {
|
||||
const i = col * 2
|
||||
return (
|
||||
<Col key={contracts[i].id}>
|
||||
<Col className="snap-start scroll-m-4" key={contracts[i].id}>
|
||||
<ContractCard
|
||||
contract={contracts[i]}
|
||||
className="mb-2 w-96 shrink-0"
|
||||
|
|
|
@ -18,7 +18,6 @@ import { uploadImage } from 'web/lib/firebase/storage'
|
|||
import { useMutation } from 'react-query'
|
||||
import { FileUploadButton } from './file-upload-button'
|
||||
import { linkClass } from './site-link'
|
||||
import { useUsers } from 'web/hooks/use-users'
|
||||
import { mentionSuggestion } from './editor/mention-suggestion'
|
||||
import { DisplayMention } from './editor/mention'
|
||||
import Iframe from 'common/util/tiptap-iframe'
|
||||
|
@ -68,50 +67,42 @@ export function useTextEditor(props: {
|
|||
}) {
|
||||
const { placeholder, max, defaultValue = '', disabled, simple } = props
|
||||
|
||||
const users = useUsers()
|
||||
|
||||
const editorClass = clsx(
|
||||
proseClass,
|
||||
!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(
|
||||
{
|
||||
editorProps: { attributes: { class: editorClass } },
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: simple ? false : { levels: [1, 2, 3] },
|
||||
horizontalRule: simple ? false : {},
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder,
|
||||
emptyEditorClass:
|
||||
'before:content-[attr(data-placeholder)] before:text-slate-500 before:float-left before:h-0 cursor-text',
|
||||
}),
|
||||
CharacterCount.configure({ limit: max }),
|
||||
simple ? DisplayImage : Image,
|
||||
DisplayLink,
|
||||
DisplayMention.configure({
|
||||
suggestion: mentionSuggestion(users),
|
||||
}),
|
||||
Iframe,
|
||||
TiptapTweet,
|
||||
],
|
||||
content: defaultValue,
|
||||
},
|
||||
[!users.length] // passed as useEffect dependency. (re-render editor when users load, to update mention menu)
|
||||
)
|
||||
const editor = useEditor({
|
||||
editorProps: { attributes: { class: editorClass } },
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: simple ? false : { levels: [1, 2, 3] },
|
||||
horizontalRule: simple ? false : {},
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder,
|
||||
emptyEditorClass:
|
||||
'before:content-[attr(data-placeholder)] before:text-slate-500 before:float-left before:h-0 cursor-text',
|
||||
}),
|
||||
CharacterCount.configure({ limit: max }),
|
||||
simple ? DisplayImage : Image,
|
||||
DisplayLink,
|
||||
DisplayMention.configure({ suggestion: mentionSuggestion }),
|
||||
Iframe,
|
||||
TiptapTweet,
|
||||
],
|
||||
content: defaultValue,
|
||||
})
|
||||
|
||||
const upload = useUploadMutation(editor)
|
||||
|
||||
editor?.setOptions({
|
||||
editorProps: {
|
||||
handlePaste(view, event) {
|
||||
const imageFiles = Array.from(event.clipboardData?.files ?? []).filter(
|
||||
(file) => file.type.startsWith('image')
|
||||
)
|
||||
|
||||
const imageFiles = getImages(event.clipboardData)
|
||||
if (imageFiles.length) {
|
||||
event.preventDefault()
|
||||
upload.mutate(imageFiles)
|
||||
|
@ -126,6 +117,13 @@ export function useTextEditor(props: {
|
|||
|
||||
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 }
|
||||
}
|
||||
|
||||
const getImages = (data: DataTransfer | null) =>
|
||||
Array.from(data?.files ?? []).filter((file) => file.type.startsWith('image'))
|
||||
|
||||
function isValidIframe(text: string) {
|
||||
return /^<iframe.*<\/iframe>$/.test(text)
|
||||
}
|
||||
|
@ -157,7 +158,7 @@ export function TextEditor(props: {
|
|||
<EditorContent editor={editor} />
|
||||
{/* Toolbar, with buttons for images and embeds */}
|
||||
<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
|
||||
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"
|
||||
|
@ -165,7 +166,7 @@ export function TextEditor(props: {
|
|||
<PhotographIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</FileUploadButton>
|
||||
</Tooltip>
|
||||
<Tooltip className="flex items-center" text="Add embed" noTap>
|
||||
<Tooltip text="Add embed" noTap noFade>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIframeOpen(true)}
|
||||
|
@ -179,7 +180,7 @@ export function TextEditor(props: {
|
|||
<CodeIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip className="flex items-center" text="Add market" noTap>
|
||||
<Tooltip text="Add market" noTap noFade>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMarketOpen(true)}
|
||||
|
@ -245,7 +246,7 @@ export function RichContent(props: {
|
|||
extensions: [
|
||||
StarterKit,
|
||||
smallImage ? DisplayImage : Image,
|
||||
DisplayLink,
|
||||
DisplayLink.configure({ openOnClick: false }), // stop link opening twice (browser still opens)
|
||||
DisplayMention,
|
||||
Iframe,
|
||||
TiptapTweet,
|
||||
|
|
|
@ -7,7 +7,7 @@ import { Col } from '../layout/col'
|
|||
import { Modal } from '../layout/modal'
|
||||
import { Row } from '../layout/row'
|
||||
import { LoadingIndicator } from '../loading-indicator'
|
||||
import { embedCode } from '../share-embed-button'
|
||||
import { embedContractCode, embedContractGridCode } from '../share-embed-button'
|
||||
import { insertContent } from './utils'
|
||||
|
||||
export function MarketModal(props: {
|
||||
|
@ -28,7 +28,11 @@ export function MarketModal(props: {
|
|||
|
||||
async function doneAddingContracts() {
|
||||
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)
|
||||
setOpen(false)
|
||||
setContracts([])
|
||||
|
@ -42,9 +46,14 @@ export function MarketModal(props: {
|
|||
|
||||
{!loading && (
|
||||
<Row className="grow justify-end gap-4">
|
||||
{contracts.length > 0 && (
|
||||
{contracts.length == 1 && (
|
||||
<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'}
|
||||
</Button>
|
||||
)}
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import type { MentionOptions } from '@tiptap/extension-mention'
|
||||
import { ReactRenderer } from '@tiptap/react'
|
||||
import { User } from 'common/user'
|
||||
import { searchInAny } from 'common/util/parse'
|
||||
import { orderBy } from 'lodash'
|
||||
import tippy from 'tippy.js'
|
||||
import { getCachedUsers } from 'web/hooks/use-users'
|
||||
import { MentionList } from './mention-list'
|
||||
|
||||
type Suggestion = MentionOptions['suggestion']
|
||||
|
@ -12,10 +12,12 @@ const beginsWith = (text: string, query: string) =>
|
|||
text.toLocaleLowerCase().startsWith(query.toLocaleLowerCase())
|
||||
|
||||
// copied from https://tiptap.dev/api/nodes/mention#usage
|
||||
export const mentionSuggestion = (users: User[]): Suggestion => ({
|
||||
items: ({ query }) =>
|
||||
export const mentionSuggestion: Suggestion = {
|
||||
items: async ({ query }) =>
|
||||
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)),
|
||||
'followerCountCached',
|
||||
|
@ -38,7 +40,7 @@ export const mentionSuggestion = (users: User[]): Suggestion => ({
|
|||
popup = tippy('body', {
|
||||
getReferenceClientRect: props.clientRect as any,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
content: component?.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: 'manual',
|
||||
|
@ -46,27 +48,27 @@ export const mentionSuggestion = (users: User[]): Suggestion => ({
|
|||
})
|
||||
},
|
||||
onUpdate(props) {
|
||||
component.updateProps(props)
|
||||
component?.updateProps(props)
|
||||
|
||||
if (!props.clientRect) {
|
||||
return
|
||||
}
|
||||
|
||||
popup[0].setProps({
|
||||
popup?.[0].setProps({
|
||||
getReferenceClientRect: props.clientRect as any,
|
||||
})
|
||||
},
|
||||
onKeyDown(props) {
|
||||
if (props.event.key === 'Escape') {
|
||||
popup[0].hide()
|
||||
popup?.[0].hide()
|
||||
return true
|
||||
}
|
||||
return (component.ref as any)?.onKeyDown(props)
|
||||
return (component?.ref as any)?.onKeyDown(props)
|
||||
},
|
||||
onExit() {
|
||||
popup[0].destroy()
|
||||
component.destroy()
|
||||
popup?.[0].destroy()
|
||||
component?.destroy()
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@ import { getOutcomeProbability } from 'common/calculate'
|
|||
import { FeedBet } from './feed-bets'
|
||||
import { FeedLiquidity } from './feed-liquidity'
|
||||
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 { CommentTipMap } from 'web/hooks/use-tip-txns'
|
||||
import { LiquidityProvision } from 'common/liquidity-provision'
|
||||
|
@ -72,7 +72,7 @@ export function ContractCommentsActivity(props: {
|
|||
|
||||
return (
|
||||
<>
|
||||
<CommentInput
|
||||
<ContractCommentInput
|
||||
className="mb-5"
|
||||
contract={contract}
|
||||
betsByCurrentUser={(user && betsByUserId[user.id]) ?? []}
|
||||
|
|
|
@ -9,7 +9,7 @@ import { Avatar } from 'web/components/avatar'
|
|||
import { Linkify } from 'web/components/linkify'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
CommentInput,
|
||||
ContractCommentInput,
|
||||
FeedComment,
|
||||
getMostRecentCommentableBet,
|
||||
} 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"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<CommentInput
|
||||
<ContractCommentInput
|
||||
contract={contract}
|
||||
betsByCurrentUser={betsByCurrentUser}
|
||||
commentsByCurrentUser={commentsByCurrentUser}
|
||||
|
|
|
@ -13,21 +13,17 @@ import { Avatar } from 'web/components/avatar'
|
|||
import { OutcomeLabel } from 'web/components/outcome-label'
|
||||
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
|
||||
import { firebaseLogin } from 'web/lib/firebase/users'
|
||||
import {
|
||||
createCommentOnContract,
|
||||
MAX_COMMENT_LENGTH,
|
||||
} from 'web/lib/firebase/comments'
|
||||
import { createCommentOnContract } from 'web/lib/firebase/comments'
|
||||
import { BetStatusText } from 'web/components/feed/feed-bets'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
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 { Tipper } from '../tipper'
|
||||
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 { UserLink } from 'web/components/user-link'
|
||||
import { CommentInput } from '../comment-input'
|
||||
|
||||
export function FeedCommentThread(props: {
|
||||
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"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<CommentInput
|
||||
<ContractCommentInput
|
||||
contract={contract}
|
||||
betsByCurrentUser={(user && betsByUserId[user.id]) ?? []}
|
||||
commentsByCurrentUser={(user && commentsByUserId[user.id]) ?? []}
|
||||
parentCommentId={parentComment.id}
|
||||
replyToUser={replyTo}
|
||||
parentAnswerOutcome={parentComment.answerOutcome}
|
||||
onSubmitComment={() => setShowReply(false)}
|
||||
onSubmitComment={() => {
|
||||
setShowReply(false)
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
)}
|
||||
|
@ -124,15 +122,12 @@ export function FeedComment(props: {
|
|||
} = props
|
||||
const { text, content, userUsername, userName, userAvatarUrl, createdTime } =
|
||||
comment
|
||||
let betOutcome: string | undefined,
|
||||
bought: string | undefined,
|
||||
money: string | undefined
|
||||
|
||||
const matchedBet = betsBySameUser.find((bet) => bet.id === comment.betId)
|
||||
if (matchedBet) {
|
||||
betOutcome = matchedBet.outcome
|
||||
bought = matchedBet.amount >= 0 ? 'bought' : 'sold'
|
||||
money = formatMoney(Math.abs(matchedBet.amount))
|
||||
const betOutcome = comment.betOutcome
|
||||
let bought: string | undefined
|
||||
let money: string | undefined
|
||||
if (comment.betAmount != null) {
|
||||
bought = comment.betAmount >= 0 ? 'bought' : 'sold'
|
||||
money = formatMoney(Math.abs(comment.betAmount))
|
||||
}
|
||||
|
||||
const [highlighted, setHighlighted] = useState(false)
|
||||
|
@ -147,7 +142,7 @@ export function FeedComment(props: {
|
|||
const { userPosition, outcome } = getBettorsLargestPositionBeforeTime(
|
||||
contract,
|
||||
comment.createdTime,
|
||||
matchedBet ? [] : betsBySameUser
|
||||
comment.betId ? [] : betsBySameUser
|
||||
)
|
||||
|
||||
return (
|
||||
|
@ -174,7 +169,7 @@ export function FeedComment(props: {
|
|||
username={userUsername}
|
||||
name={userName}
|
||||
/>{' '}
|
||||
{!matchedBet &&
|
||||
{!comment.betId != null &&
|
||||
userPosition > 0 &&
|
||||
contract.outcomeType !== 'NUMERIC' && (
|
||||
<>
|
||||
|
@ -193,7 +188,6 @@ export function FeedComment(props: {
|
|||
of{' '}
|
||||
<OutcomeLabel
|
||||
outcome={betOutcome ? betOutcome : ''}
|
||||
value={(matchedBet as any).value}
|
||||
contract={contract}
|
||||
truncate="short"
|
||||
/>
|
||||
|
@ -270,67 +264,76 @@ function CommentStatus(props: {
|
|||
)
|
||||
}
|
||||
|
||||
//TODO: move commentinput and comment input text area into their own files
|
||||
export function CommentInput(props: {
|
||||
export function ContractCommentInput(props: {
|
||||
contract: Contract
|
||||
betsByCurrentUser: Bet[]
|
||||
commentsByCurrentUser: ContractComment[]
|
||||
className?: string
|
||||
parentAnswerOutcome?: string | undefined
|
||||
replyToUser?: { id: string; username: string }
|
||||
// Reply to a free response answer
|
||||
parentAnswerOutcome?: string
|
||||
// Reply to another comment
|
||||
parentCommentId?: string
|
||||
onSubmitComment?: () => void
|
||||
}) {
|
||||
const {
|
||||
contract,
|
||||
betsByCurrentUser,
|
||||
commentsByCurrentUser,
|
||||
className,
|
||||
parentAnswerOutcome,
|
||||
parentCommentId,
|
||||
replyToUser,
|
||||
onSubmitComment,
|
||||
} = 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)
|
||||
|
||||
const mostRecentCommentableBet = getMostRecentCommentableBet(
|
||||
betsByCurrentUser,
|
||||
commentsByCurrentUser,
|
||||
user,
|
||||
parentAnswerOutcome
|
||||
)
|
||||
const { id } = mostRecentCommentableBet || { id: undefined }
|
||||
|
||||
async function submitComment(betId: string | undefined) {
|
||||
async function onSubmitComment(editor: Editor, betId: string | undefined) {
|
||||
if (!user) {
|
||||
track('sign in to comment')
|
||||
return await firebaseLogin()
|
||||
}
|
||||
if (!editor || editor.isEmpty || isSubmitting) return
|
||||
setIsSubmitting(true)
|
||||
await createCommentOnContract(
|
||||
contract.id,
|
||||
props.contract.id,
|
||||
editor.getJSON(),
|
||||
user,
|
||||
betId,
|
||||
parentAnswerOutcome,
|
||||
parentCommentId
|
||||
props.parentAnswerOutcome,
|
||||
props.parentCommentId
|
||||
)
|
||||
onSubmitComment?.()
|
||||
setIsSubmitting(false)
|
||||
props.onSubmitComment?.()
|
||||
}
|
||||
|
||||
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(
|
||||
contract,
|
||||
Date.now(),
|
||||
|
@ -339,158 +342,36 @@ export function CommentInput(props: {
|
|||
|
||||
const isNumeric = contract.outcomeType === 'NUMERIC'
|
||||
|
||||
if (user?.isBannedFromPosting) return <></>
|
||||
|
||||
return (
|
||||
<Row className={clsx(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">
|
||||
<div className="mb-1 text-gray-500">
|
||||
{mostRecentCommentableBet && (
|
||||
<BetStatusText
|
||||
<Row className={clsx(props.className, 'mb-2 gap-1 sm:gap-2')}>
|
||||
<div className="mb-1 text-gray-500">
|
||||
{mostRecentCommentableBet && (
|
||||
<BetStatusText
|
||||
contract={contract}
|
||||
bet={mostRecentCommentableBet}
|
||||
isSelf={true}
|
||||
hideOutcome={isNumeric || contract.outcomeType === 'FREE_RESPONSE'}
|
||||
/>
|
||||
)}
|
||||
{!mostRecentCommentableBet && user && userPosition > 0 && !isNumeric && (
|
||||
<>
|
||||
{"You're"}
|
||||
<CommentStatus
|
||||
outcome={outcome}
|
||||
contract={contract}
|
||||
bet={mostRecentCommentableBet}
|
||||
isSelf={true}
|
||||
hideOutcome={
|
||||
isNumeric || contract.outcomeType === 'FREE_RESPONSE'
|
||||
prob={
|
||||
contract.outcomeType === 'BINARY'
|
||||
? getProbability(contract)
|
||||
: 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>
|
||||
</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(
|
||||
contract: Contract,
|
||||
createdTime: number,
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -2,9 +2,9 @@ import clsx from 'clsx'
|
|||
import { PencilIcon } from '@heroicons/react/outline'
|
||||
|
||||
import { User } from 'common/user'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
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 { Col } from './layout/col'
|
||||
import { Modal } from './layout/modal'
|
||||
|
@ -105,16 +105,9 @@ function FollowsDialog(props: {
|
|||
const { user, followingIds, followerIds, defaultTab, isOpen, setIsOpen } =
|
||||
props
|
||||
|
||||
useEffect(() => {
|
||||
prefetchUsers([...followingIds, ...followerIds])
|
||||
}, [followingIds, followerIds])
|
||||
|
||||
const currentUser = useUser()
|
||||
|
||||
const discoverUserIds = useDiscoverUsers(user?.id)
|
||||
useEffect(() => {
|
||||
prefetchUsers(discoverUserIds)
|
||||
}, [discoverUserIds])
|
||||
usePrefetchUsers([...followingIds, ...followerIds, ...discoverUserIds])
|
||||
|
||||
return (
|
||||
<Modal open={isOpen} setOpen={setIsOpen}>
|
||||
|
|
|
@ -7,22 +7,30 @@ import { Button } from 'web/components/button'
|
|||
import { GroupSelector } from 'web/components/groups/group-selector'
|
||||
import {
|
||||
addContractToGroup,
|
||||
canModifyGroupContracts,
|
||||
removeContractFromGroup,
|
||||
} from 'web/lib/firebase/groups'
|
||||
import { User } from 'common/user'
|
||||
import { Contract } from 'common/contract'
|
||||
import { SiteLink } from 'web/components/site-link'
|
||||
import { GroupLink } from 'common/group'
|
||||
import { useGroupsWithContract } from 'web/hooks/use-group'
|
||||
import { useGroupsWithContract, useMemberGroupIds } from 'web/hooks/use-group'
|
||||
import { Group } from 'common/group'
|
||||
|
||||
export function ContractGroupsList(props: {
|
||||
groupLinks: GroupLink[]
|
||||
contract: Contract
|
||||
user: User | null | undefined
|
||||
}) {
|
||||
const { groupLinks, user, contract } = props
|
||||
const { user, contract } = props
|
||||
const { groupLinks } = contract
|
||||
const groups = useGroupsWithContract(contract)
|
||||
const memberGroupIds = useMemberGroupIds(user)
|
||||
|
||||
const canModifyGroupContracts = (group: Group, userId: string) => {
|
||||
return (
|
||||
group.creatorId === userId ||
|
||||
group.anyoneCanJoin ||
|
||||
memberGroupIds?.includes(group.id)
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Col className={'gap-2'}>
|
||||
<span className={'text-xl text-indigo-700'}>
|
||||
|
@ -35,7 +43,7 @@ export function ContractGroupsList(props: {
|
|||
options={{
|
||||
showSelector: true,
|
||||
showLabel: false,
|
||||
ignoreGroupIds: groupLinks.map((g) => g.groupId),
|
||||
ignoreGroupIds: groupLinks?.map((g) => g.groupId),
|
||||
}}
|
||||
setSelectedGroup={(group) =>
|
||||
group && addContractToGroup(group, contract, user.id)
|
||||
|
@ -62,7 +70,7 @@ export function ContractGroupsList(props: {
|
|||
<Button
|
||||
color={'gray-white'}
|
||||
size={'xs'}
|
||||
onClick={() => removeContractFromGroup(group, contract, user.id)}
|
||||
onClick={() => removeContractFromGroup(group, contract)}
|
||||
>
|
||||
<XIcon className="h-4 w-4 text-gray-500" />
|
||||
</Button>
|
||||
|
|
|
@ -3,17 +3,16 @@ import clsx from 'clsx'
|
|||
import { PencilIcon } from '@heroicons/react/outline'
|
||||
|
||||
import { Group } from 'common/group'
|
||||
import { deleteGroup, updateGroup } from 'web/lib/firebase/groups'
|
||||
import { deleteGroup, joinGroup } from 'web/lib/firebase/groups'
|
||||
import { Spacer } from '../layout/spacer'
|
||||
import { useRouter } from 'next/router'
|
||||
import { Modal } from 'web/components/layout/modal'
|
||||
import { FilterSelectUsers } from 'web/components/filter-select-users'
|
||||
import { User } from 'common/user'
|
||||
import { uniq } from 'lodash'
|
||||
import { useMemberIds } from 'web/hooks/use-group'
|
||||
|
||||
export function EditGroupButton(props: { group: Group; className?: string }) {
|
||||
const { group, className } = props
|
||||
const { memberIds } = group
|
||||
const router = useRouter()
|
||||
|
||||
const [name, setName] = useState(group.name)
|
||||
|
@ -21,7 +20,7 @@ export function EditGroupButton(props: { group: Group; className?: string }) {
|
|||
const [open, setOpen] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [addMemberUsers, setAddMemberUsers] = useState<User[]>([])
|
||||
|
||||
const memberIds = useMemberIds(group.id)
|
||||
function updateOpen(newOpen: boolean) {
|
||||
setAddMemberUsers([])
|
||||
setOpen(newOpen)
|
||||
|
@ -33,11 +32,7 @@ export function EditGroupButton(props: { group: Group; className?: string }) {
|
|||
const onSubmit = async () => {
|
||||
setIsSubmitting(true)
|
||||
|
||||
await updateGroup(group, {
|
||||
name,
|
||||
about,
|
||||
memberIds: uniq([...memberIds, ...addMemberUsers.map((user) => user.id)]),
|
||||
})
|
||||
await Promise.all(addMemberUsers.map((user) => joinGroup(group, user.id)))
|
||||
|
||||
setIsSubmitting(false)
|
||||
updateOpen(false)
|
||||
|
|
|
@ -22,7 +22,7 @@ export function GroupAboutPost(props: {
|
|||
const post = usePost(group.aboutPostId) ?? props.post
|
||||
|
||||
return (
|
||||
<div className="rounded-md bg-white p-4">
|
||||
<div className="rounded-md bg-white p-4 ">
|
||||
{isEditable ? (
|
||||
<RichEditGroupAboutPost group={group} post={post} />
|
||||
) : (
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
})
|
|
@ -5,6 +5,7 @@ import {
|
|||
CheckIcon,
|
||||
PlusCircleIcon,
|
||||
SelectorIcon,
|
||||
UserIcon,
|
||||
} from '@heroicons/react/outline'
|
||||
import clsx from 'clsx'
|
||||
import { CreateGroupButton } from 'web/components/groups/create-group-button'
|
||||
|
@ -12,6 +13,7 @@ import { useState } from 'react'
|
|||
import { useMemberGroups, useOpenGroups } from 'web/hooks/use-group'
|
||||
import { User } from 'common/user'
|
||||
import { searchInAny } from 'common/util/parse'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
|
||||
export function GroupSelector(props: {
|
||||
selectedGroup: Group | undefined
|
||||
|
@ -28,13 +30,27 @@ export function GroupSelector(props: {
|
|||
const { showSelector, showLabel, ignoreGroupIds } = options
|
||||
const [query, setQuery] = useState('')
|
||||
const openGroups = useOpenGroups()
|
||||
const memberGroups = useMemberGroups(creator?.id)
|
||||
const memberGroupIds = memberGroups?.map((g) => g.id) ?? []
|
||||
const availableGroups = openGroups
|
||||
.concat(
|
||||
(useMemberGroups(creator?.id) ?? []).filter(
|
||||
(memberGroups ?? []).filter(
|
||||
(g) => !openGroups.map((og) => og.id).includes(g.id)
|
||||
)
|
||||
)
|
||||
.filter((group) => !ignoreGroupIds?.includes(group.id))
|
||||
.sort((a, b) => b.totalContracts - a.totalContracts)
|
||||
// put the groups the user is a member of first
|
||||
.sort((a, b) => {
|
||||
if (memberGroupIds.includes(a.id)) {
|
||||
return -1
|
||||
}
|
||||
if (memberGroupIds.includes(b.id)) {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
const filteredGroups = availableGroups.filter((group) =>
|
||||
searchInAny(query, group.name)
|
||||
)
|
||||
|
@ -96,7 +112,7 @@ export function GroupSelector(props: {
|
|||
value={group}
|
||||
className={({ active }) =>
|
||||
clsx(
|
||||
'relative h-12 cursor-pointer select-none py-2 pl-4 pr-9',
|
||||
'relative h-12 cursor-pointer select-none py-2 pr-6',
|
||||
active ? 'bg-indigo-500 text-white' : 'text-gray-900'
|
||||
)
|
||||
}
|
||||
|
@ -115,11 +131,28 @@ export function GroupSelector(props: {
|
|||
)}
|
||||
<span
|
||||
className={clsx(
|
||||
'ml-5 mt-1 block truncate',
|
||||
'ml-3 mt-1 block flex flex-row justify-between',
|
||||
selected && 'font-semibold'
|
||||
)}
|
||||
>
|
||||
{group.name}
|
||||
<Row className={'items-center gap-1 truncate pl-5'}>
|
||||
{memberGroupIds.includes(group.id) && (
|
||||
<UserIcon
|
||||
className={'text-primary h-4 w-4 shrink-0'}
|
||||
/>
|
||||
)}
|
||||
{group.name}
|
||||
</Row>
|
||||
<span
|
||||
className={clsx(
|
||||
'ml-1 w-[1.4rem] shrink-0 rounded-full bg-indigo-500 text-center text-white',
|
||||
group.totalContracts > 99 ? 'w-[2.1rem]' : ''
|
||||
)}
|
||||
>
|
||||
{group.totalContracts > 99
|
||||
? '99+'
|
||||
: group.totalContracts}
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import clsx from 'clsx'
|
||||
import { User } from 'common/user'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { withTracking } from 'web/lib/service/analytics'
|
||||
import { Row } from 'web/components/layout/row'
|
||||
import { useMemberGroups } from 'web/hooks/use-group'
|
||||
import { useMemberGroups, useMemberIds } from 'web/hooks/use-group'
|
||||
import { TextButton } from 'web/components/text-button'
|
||||
import { Group } from 'common/group'
|
||||
import { Modal } from 'web/components/layout/modal'
|
||||
|
@ -17,9 +17,7 @@ import toast from 'react-hot-toast'
|
|||
export function GroupsButton(props: { user: User }) {
|
||||
const { user } = props
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const groups = useMemberGroups(user.id, undefined, {
|
||||
by: 'mostRecentChatActivityTime',
|
||||
})
|
||||
const groups = useMemberGroups(user.id)
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -74,51 +72,34 @@ function GroupsList(props: { groups: Group[] }) {
|
|||
|
||||
function GroupItem(props: { group: Group; className?: string }) {
|
||||
const { group, className } = props
|
||||
const user = useUser()
|
||||
const memberIds = useMemberIds(group.id)
|
||||
return (
|
||||
<Row className={clsx('items-center justify-between gap-2 p-2', className)}>
|
||||
<Row className="line-clamp-1 items-center gap-2">
|
||||
<GroupLinkItem group={group} />
|
||||
</Row>
|
||||
<JoinOrLeaveGroupButton group={group} />
|
||||
<JoinOrLeaveGroupButton
|
||||
group={group}
|
||||
user={user}
|
||||
isMember={user ? memberIds?.includes(user.id) : false}
|
||||
/>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
export function JoinOrLeaveGroupButton(props: {
|
||||
group: Group
|
||||
isMember: boolean
|
||||
user: User | undefined | null
|
||||
small?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const { group, small, className } = props
|
||||
const currentUser = useUser()
|
||||
const [isMember, setIsMember] = useState<boolean>(false)
|
||||
useEffect(() => {
|
||||
if (currentUser && group.memberIds.includes(currentUser.id)) {
|
||||
setIsMember(group.memberIds.includes(currentUser.id))
|
||||
}
|
||||
}, [currentUser, group])
|
||||
|
||||
const onJoinGroup = () => {
|
||||
if (!currentUser) return
|
||||
setIsMember(true)
|
||||
joinGroup(group, currentUser.id).catch(() => {
|
||||
setIsMember(false)
|
||||
toast.error('Failed to join group')
|
||||
})
|
||||
}
|
||||
const onLeaveGroup = () => {
|
||||
if (!currentUser) return
|
||||
setIsMember(false)
|
||||
leaveGroup(group, currentUser.id).catch(() => {
|
||||
setIsMember(true)
|
||||
toast.error('Failed to leave group')
|
||||
})
|
||||
}
|
||||
|
||||
const { group, small, className, isMember, user } = props
|
||||
const smallStyle =
|
||||
'btn !btn-xs border-2 border-gray-500 bg-white normal-case text-gray-500 hover:border-gray-500 hover:bg-white hover:text-gray-500'
|
||||
|
||||
if (!currentUser || isMember === undefined) {
|
||||
if (!user) {
|
||||
if (!group.anyoneCanJoin)
|
||||
return <div className={clsx(className, 'text-gray-500')}>Closed</div>
|
||||
return (
|
||||
|
@ -126,10 +107,20 @@ export function JoinOrLeaveGroupButton(props: {
|
|||
onClick={firebaseLogin}
|
||||
className={clsx('btn btn-sm', small && smallStyle, className)}
|
||||
>
|
||||
Login to Join
|
||||
Login to follow
|
||||
</button>
|
||||
)
|
||||
}
|
||||
const onJoinGroup = () => {
|
||||
joinGroup(group, user.id).catch(() => {
|
||||
toast.error('Failed to join group')
|
||||
})
|
||||
}
|
||||
const onLeaveGroup = () => {
|
||||
leaveGroup(group, user.id).catch(() => {
|
||||
toast.error('Failed to leave group')
|
||||
})
|
||||
}
|
||||
|
||||
if (isMember) {
|
||||
return (
|
||||
|
@ -141,7 +132,7 @@ export function JoinOrLeaveGroupButton(props: {
|
|||
)}
|
||||
onClick={withTracking(onLeaveGroup, 'leave group')}
|
||||
>
|
||||
Leave
|
||||
Unfollow
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
@ -153,7 +144,7 @@ export function JoinOrLeaveGroupButton(props: {
|
|||
className={clsx('btn btn-sm', small && smallStyle, className)}
|
||||
onClick={withTracking(onJoinGroup, 'join group')}
|
||||
>
|
||||
Join
|
||||
Follow
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -27,23 +27,18 @@ export function LandingPagePanel(props: { hotContracts: Contract[] }) {
|
|||
<div className="m-4 max-w-[550px] self-center">
|
||||
<h1 className="text-3xl sm:text-6xl xl:text-6xl">
|
||||
<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">
|
||||
anything!
|
||||
</span>
|
||||
market
|
||||
</span>{' '}
|
||||
for every question
|
||||
</div>
|
||||
</h1>
|
||||
<Spacer h={6} />
|
||||
<div className="mb-4 px-2 ">
|
||||
Create a play-money prediction market on any topic you care about
|
||||
and bet with your friends on what will happen!
|
||||
Create a play-money prediction market on any topic you care about.
|
||||
Trade with your friends to forecast the future.
|
||||
<br />
|
||||
{/* <br />
|
||||
Sign up and get {formatMoney(1000)} - worth $10 to your{' '}
|
||||
<SiteLink className="font-semibold" href="/charity">
|
||||
favorite charity.
|
||||
</SiteLink>
|
||||
<br /> */}
|
||||
</div>
|
||||
</div>
|
||||
<Spacer h={6} />
|
||||
|
|
75
web/components/multi-user-transaction-link.tsx
Normal file
75
web/components/multi-user-transaction-link.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -19,12 +19,10 @@ export function MenuButton(props: {
|
|||
as="div"
|
||||
className={clsx(className ? className : 'relative z-40 flex-shrink-0')}
|
||||
>
|
||||
<div>
|
||||
<Menu.Button className="w-full rounded-full">
|
||||
<span className="sr-only">Open user menu</span>
|
||||
{buttonContent}
|
||||
</Menu.Button>
|
||||
</div>
|
||||
<Menu.Button className="w-full rounded-full">
|
||||
<span className="sr-only">Open user menu</span>
|
||||
{buttonContent}
|
||||
</Menu.Button>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
|
|
|
@ -64,7 +64,7 @@ export function BottomNavBar() {
|
|||
item={{
|
||||
name: formatMoney(user.balance),
|
||||
trackingEventName: 'profile',
|
||||
href: `/${user.username}?tab=bets`,
|
||||
href: `/${user.username}?tab=trades`,
|
||||
icon: () => (
|
||||
<Avatar
|
||||
className="mx-auto my-1"
|
||||
|
|
|
@ -8,10 +8,10 @@ import { trackCallback } from 'web/lib/service/analytics'
|
|||
export function ProfileSummary(props: { user: User }) {
|
||||
const { user } = props
|
||||
return (
|
||||
<Link href={`/${user.username}?tab=bets`}>
|
||||
<Link href={`/${user.username}?tab=trades`}>
|
||||
<a
|
||||
onClick={trackCallback('sidebar: profile')}
|
||||
className="group flex flex-row items-center gap-4 rounded-md py-3 text-gray-500 hover:bg-gray-100 hover:text-gray-700"
|
||||
className="group mb-3 flex flex-row items-center gap-4 truncate rounded-md py-3 text-gray-500 hover:bg-gray-100 hover:text-gray-700"
|
||||
>
|
||||
<Avatar avatarUrl={user.avatarUrl} username={user.username} noLink />
|
||||
|
||||
|
|
|
@ -234,11 +234,7 @@ export default function Sidebar(props: { className?: string }) {
|
|||
|
||||
{!user && <SignInButton className="mb-4" />}
|
||||
|
||||
{user && (
|
||||
<div className="min-h-[80px] w-full">
|
||||
<ProfileSummary user={user} />
|
||||
</div>
|
||||
)}
|
||||
{user && <ProfileSummary user={user} />}
|
||||
|
||||
{/* Mobile navigation */}
|
||||
<div className="flex min-h-0 shrink flex-col gap-1 lg:hidden">
|
||||
|
@ -255,7 +251,7 @@ export default function Sidebar(props: { className?: string }) {
|
|||
</div>
|
||||
|
||||
{/* Desktop navigation */}
|
||||
<div className="hidden min-h-0 shrink flex-col gap-1 lg:flex">
|
||||
<div className="hidden min-h-0 shrink flex-col items-stretch gap-1 lg:flex ">
|
||||
{navigationOptions.map((item) => (
|
||||
<SidebarItem key={item.href} item={item} currentPage={currentPage} />
|
||||
))}
|
||||
|
@ -264,7 +260,7 @@ export default function Sidebar(props: { className?: string }) {
|
|||
buttonContent={<MoreButton />}
|
||||
/>
|
||||
|
||||
{user && <CreateQuestionButton user={user} />}
|
||||
{user && !user.isBannedFromPosting && <CreateQuestionButton />}
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
|
|
|
@ -12,11 +12,9 @@ export default function NotificationsIcon(props: { className?: string }) {
|
|||
const privateUser = usePrivateUser()
|
||||
|
||||
return (
|
||||
<Row className={clsx('justify-center')}>
|
||||
<div className={'relative'}>
|
||||
{privateUser && <UnseenNotificationsBubble privateUser={privateUser} />}
|
||||
<BellIcon className={clsx(props.className)} />
|
||||
</div>
|
||||
<Row className="relative justify-center">
|
||||
{privateUser && <UnseenNotificationsBubble privateUser={privateUser} />}
|
||||
<BellIcon className={clsx(props.className)} />
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
@ -32,11 +30,11 @@ function UnseenNotificationsBubble(props: { privateUser: PrivateUser }) {
|
|||
|
||||
const notifications = useUnseenGroupedNotification(privateUser)
|
||||
if (!notifications || notifications.length === 0 || seen) {
|
||||
return <div />
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="-mt-0.75 absolute ml-3.5 min-w-[15px] rounded-full bg-indigo-500 p-[2px] text-center text-[10px] leading-3 text-white lg:-mt-1 lg:ml-2">
|
||||
<div className="-mt-0.75 absolute ml-3.5 min-w-[15px] rounded-full bg-indigo-500 p-[2px] text-center text-[10px] leading-3 text-white lg:left-0 lg:-mt-1 lg:ml-2">
|
||||
{notifications.length > NOTIFICATIONS_PER_PAGE
|
||||
? `${NOTIFICATIONS_PER_PAGE}+`
|
||||
: notifications.length}
|
||||
|
|
|
@ -203,7 +203,7 @@ function NumericBuyPanel(props: {
|
|||
)}
|
||||
onClick={betDisabled ? undefined : submitBet}
|
||||
>
|
||||
{isSubmitting ? 'Submitting...' : 'Submit bet'}
|
||||
{isSubmitting ? 'Submitting...' : 'Submit'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
|
|
@ -58,7 +58,7 @@ export function Pagination(props: {
|
|||
|
||||
const maxPage = Math.ceil(totalItems / itemsPerPage) - 1
|
||||
|
||||
if (maxPage === 0) return <Spacer h={4} />
|
||||
if (maxPage <= 0) return <Spacer h={4} />
|
||||
|
||||
return (
|
||||
<nav
|
||||
|
|
|
@ -2,8 +2,8 @@ import { InfoBox } from './info-box'
|
|||
|
||||
export const PlayMoneyDisclaimer = () => (
|
||||
<InfoBox
|
||||
title="Play-money betting"
|
||||
title="Play-money trading"
|
||||
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!"
|
||||
/>
|
||||
)
|
||||
|
|
|
@ -8,16 +8,21 @@ import { formatTime } from 'web/lib/util/time'
|
|||
|
||||
export const PortfolioValueGraph = memo(function PortfolioValueGraph(props: {
|
||||
portfolioHistory: PortfolioMetrics[]
|
||||
mode: 'value' | 'profit'
|
||||
height?: number
|
||||
includeTime?: boolean
|
||||
}) {
|
||||
const { portfolioHistory, height, includeTime } = props
|
||||
const { portfolioHistory, height, includeTime, mode } = props
|
||||
const { width } = useWindowSize()
|
||||
|
||||
const points = portfolioHistory.map((p) => {
|
||||
const { timestamp, balance, investmentValue, totalDeposits } = p
|
||||
const value = balance + investmentValue
|
||||
const profit = value - totalDeposits
|
||||
|
||||
return {
|
||||
x: new Date(p.timestamp),
|
||||
y: p.balance + p.investmentValue,
|
||||
x: new Date(timestamp),
|
||||
y: mode === 'value' ? value : profit,
|
||||
}
|
||||
})
|
||||
const data = [{ id: 'Value', data: points, color: '#11b981' }]
|
||||
|
|
|
@ -5,6 +5,7 @@ import { usePortfolioHistory } from 'web/hooks/use-portfolio-history'
|
|||
import { Period } from 'web/lib/firebase/users'
|
||||
import { Col } from '../layout/col'
|
||||
import { Row } from '../layout/row'
|
||||
import { Spacer } from '../layout/spacer'
|
||||
import { PortfolioValueGraph } from './portfolio-value-graph'
|
||||
|
||||
export const PortfolioValueSection = memo(
|
||||
|
@ -24,15 +25,16 @@ export const PortfolioValueSection = memo(
|
|||
return <></>
|
||||
}
|
||||
|
||||
const { balance, investmentValue } = lastPortfolioMetrics
|
||||
const { balance, investmentValue, totalDeposits } = lastPortfolioMetrics
|
||||
const totalValue = balance + investmentValue
|
||||
const totalProfit = totalValue - totalDeposits
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row className="gap-8">
|
||||
<Col className="flex-1 justify-center">
|
||||
<div className="text-sm text-gray-500">Portfolio value</div>
|
||||
<div className="text-lg">{formatMoney(totalValue)}</div>
|
||||
<div className="text-sm text-gray-500">Profit</div>
|
||||
<div className="text-lg">{formatMoney(totalProfit)}</div>
|
||||
</Col>
|
||||
<select
|
||||
className="select select-bordered self-start"
|
||||
|
@ -42,6 +44,7 @@ export const PortfolioValueSection = memo(
|
|||
}}
|
||||
>
|
||||
<option value="allTime">All time</option>
|
||||
<option value="monthly">Last Month</option>
|
||||
<option value="weekly">Last 7d</option>
|
||||
<option value="daily">Last 24h</option>
|
||||
</select>
|
||||
|
@ -49,6 +52,17 @@ export const PortfolioValueSection = memo(
|
|||
<PortfolioValueGraph
|
||||
portfolioHistory={currPortfolioHistory}
|
||||
includeTime={portfolioPeriod == 'daily'}
|
||||
mode="profit"
|
||||
/>
|
||||
<Spacer h={8} />
|
||||
<Col className="flex-1 justify-center">
|
||||
<div className="text-sm text-gray-500">Portfolio value</div>
|
||||
<div className="text-lg">{formatMoney(totalValue)}</div>
|
||||
</Col>
|
||||
<PortfolioValueGraph
|
||||
portfolioHistory={currPortfolioHistory}
|
||||
includeTime={portfolioPeriod == 'daily'}
|
||||
mode="value"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
|
|
@ -11,7 +11,7 @@ export function LoansModal(props: {
|
|||
<Modal open={isOpen} setOpen={setOpen}>
|
||||
<Col className="items-center gap-4 rounded-md bg-white px-8 py-6">
|
||||
<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'}>
|
||||
<span className={'text-indigo-700'}>• What are daily loans?</span>
|
||||
<span className={'ml-2'}>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import clsx from 'clsx'
|
||||
import { User } from 'common/user'
|
||||
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 { Modal } from './layout/modal'
|
||||
import { Tabs } from './layout/tabs'
|
||||
|
@ -56,9 +56,7 @@ function ReferralsDialog(props: {
|
|||
}
|
||||
}, [isOpen, referredByUser, user.referredByUserId])
|
||||
|
||||
useEffect(() => {
|
||||
prefetchUsers(referralIds)
|
||||
}, [referralIds])
|
||||
usePrefetchUsers(referralIds)
|
||||
|
||||
return (
|
||||
<Modal open={isOpen} setOpen={setIsOpen}>
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user