Merge branch 'main' into twitch-linking
This commit is contained in:
commit
89032431fb
131
common/calculate-metrics.ts
Normal file
131
common/calculate-metrics.ts
Normal file
|
@ -0,0 +1,131 @@
|
||||||
|
import { 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const calculateCreatorVolume = (userContracts: Contract[]) => {
|
||||||
|
const allTimeCreatorVolume = computeTotalPool(userContracts, 0)
|
||||||
|
const monthlyCreatorVolume = computeTotalPool(
|
||||||
|
userContracts,
|
||||||
|
Date.now() - 30 * DAY_MS
|
||||||
|
)
|
||||||
|
const weeklyCreatorVolume = computeTotalPool(
|
||||||
|
userContracts,
|
||||||
|
Date.now() - 7 * DAY_MS
|
||||||
|
)
|
||||||
|
|
||||||
|
const dailyCreatorVolume = computeTotalPool(
|
||||||
|
userContracts,
|
||||||
|
Date.now() - 1 * DAY_MS
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
daily: dailyCreatorVolume,
|
||||||
|
weekly: weeklyCreatorVolume,
|
||||||
|
monthly: monthlyCreatorVolume,
|
||||||
|
allTime: allTimeCreatorVolume,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const calculateNewPortfolioMetrics = (
|
||||||
|
user: User,
|
||||||
|
contractsById: { [k: string]: Contract },
|
||||||
|
currentBets: Bet[]
|
||||||
|
) => {
|
||||||
|
const investmentValue = computeInvestmentValue(currentBets, contractsById)
|
||||||
|
const newPortfolio = {
|
||||||
|
investmentValue: investmentValue,
|
||||||
|
balance: user.balance,
|
||||||
|
totalDeposits: user.totalDeposits,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
userId: user.id,
|
||||||
|
}
|
||||||
|
return newPortfolio
|
||||||
|
}
|
||||||
|
|
||||||
|
const calculateProfitForPeriod = (
|
||||||
|
startTime: number,
|
||||||
|
descendingPortfolio: PortfolioMetrics[],
|
||||||
|
currentProfit: number
|
||||||
|
) => {
|
||||||
|
const startingPortfolio = descendingPortfolio.find(
|
||||||
|
(p) => p.timestamp < startTime
|
||||||
|
)
|
||||||
|
|
||||||
|
if (startingPortfolio === undefined) {
|
||||||
|
return currentProfit
|
||||||
|
}
|
||||||
|
|
||||||
|
const startingProfit = calculateTotalProfit(startingPortfolio)
|
||||||
|
|
||||||
|
return currentProfit - startingProfit
|
||||||
|
}
|
||||||
|
|
||||||
|
const calculateTotalProfit = (portfolio: PortfolioMetrics) => {
|
||||||
|
return portfolio.investmentValue + portfolio.balance - portfolio.totalDeposits
|
||||||
|
}
|
||||||
|
|
||||||
|
export const calculateNewProfit = (
|
||||||
|
portfolioHistory: PortfolioMetrics[],
|
||||||
|
newPortfolio: PortfolioMetrics
|
||||||
|
) => {
|
||||||
|
const allTimeProfit = calculateTotalProfit(newPortfolio)
|
||||||
|
const descendingPortfolio = sortBy(
|
||||||
|
portfolioHistory,
|
||||||
|
(p) => p.timestamp
|
||||||
|
).reverse()
|
||||||
|
|
||||||
|
const newProfit = {
|
||||||
|
daily: calculateProfitForPeriod(
|
||||||
|
Date.now() - 1 * DAY_MS,
|
||||||
|
descendingPortfolio,
|
||||||
|
allTimeProfit
|
||||||
|
),
|
||||||
|
weekly: calculateProfitForPeriod(
|
||||||
|
Date.now() - 7 * DAY_MS,
|
||||||
|
descendingPortfolio,
|
||||||
|
allTimeProfit
|
||||||
|
),
|
||||||
|
monthly: calculateProfitForPeriod(
|
||||||
|
Date.now() - 30 * DAY_MS,
|
||||||
|
descendingPortfolio,
|
||||||
|
allTimeProfit
|
||||||
|
),
|
||||||
|
allTime: allTimeProfit,
|
||||||
|
}
|
||||||
|
|
||||||
|
return newProfit
|
||||||
|
}
|
|
@ -27,10 +27,10 @@ export function contractMetrics(contract: Contract) {
|
||||||
export function contractTextDetails(contract: Contract) {
|
export function contractTextDetails(contract: Contract) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
const dayjs = require('dayjs')
|
const dayjs = require('dayjs')
|
||||||
const { closeTime, tags } = contract
|
const { closeTime, groupLinks } = contract
|
||||||
const { createdDate, resolvedDate, volumeLabel } = contractMetrics(contract)
|
const { createdDate, resolvedDate, volumeLabel } = contractMetrics(contract)
|
||||||
|
|
||||||
const hashtags = tags.map((tag) => `#${tag}`)
|
const groupHashtags = groupLinks?.slice(0, 5).map((g) => `#${g.name}`)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
`${resolvedDate ? `${createdDate} - ${resolvedDate}` : createdDate}` +
|
`${resolvedDate ? `${createdDate} - ${resolvedDate}` : createdDate}` +
|
||||||
|
@ -40,7 +40,7 @@ export function contractTextDetails(contract: Contract) {
|
||||||
).format('MMM D, h:mma')}`
|
).format('MMM D, h:mma')}`
|
||||||
: '') +
|
: '') +
|
||||||
` • ${volumeLabel}` +
|
` • ${volumeLabel}` +
|
||||||
(hashtags.length > 0 ? ` • ${hashtags.join(' ')}` : '')
|
(groupHashtags ? ` • ${groupHashtags.join(' ')}` : '')
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -92,6 +92,7 @@ export const getOpenGraphProps = (contract: Contract) => {
|
||||||
creatorAvatarUrl,
|
creatorAvatarUrl,
|
||||||
description,
|
description,
|
||||||
numericValue,
|
numericValue,
|
||||||
|
resolution,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -103,6 +104,7 @@ export type OgCardProps = {
|
||||||
creatorUsername: string
|
creatorUsername: string
|
||||||
creatorAvatarUrl?: string
|
creatorAvatarUrl?: string
|
||||||
numericValue?: string
|
numericValue?: string
|
||||||
|
resolution?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildCardUrl(props: OgCardProps, challenge?: Challenge) {
|
export function buildCardUrl(props: OgCardProps, challenge?: Challenge) {
|
||||||
|
@ -113,22 +115,32 @@ export function buildCardUrl(props: OgCardProps, challenge?: Challenge) {
|
||||||
creatorOutcome,
|
creatorOutcome,
|
||||||
acceptorOutcome,
|
acceptorOutcome,
|
||||||
} = challenge || {}
|
} = challenge || {}
|
||||||
|
const {
|
||||||
|
probability,
|
||||||
|
numericValue,
|
||||||
|
resolution,
|
||||||
|
creatorAvatarUrl,
|
||||||
|
question,
|
||||||
|
metadata,
|
||||||
|
creatorUsername,
|
||||||
|
creatorName,
|
||||||
|
} = props
|
||||||
const { userName, userAvatarUrl } = acceptances?.[0] ?? {}
|
const { userName, userAvatarUrl } = acceptances?.[0] ?? {}
|
||||||
|
|
||||||
const probabilityParam =
|
const probabilityParam =
|
||||||
props.probability === undefined
|
probability === undefined
|
||||||
? ''
|
? ''
|
||||||
: `&probability=${encodeURIComponent(props.probability ?? '')}`
|
: `&probability=${encodeURIComponent(probability ?? '')}`
|
||||||
|
|
||||||
const numericValueParam =
|
const numericValueParam =
|
||||||
props.numericValue === undefined
|
numericValue === undefined
|
||||||
? ''
|
? ''
|
||||||
: `&numericValue=${encodeURIComponent(props.numericValue ?? '')}`
|
: `&numericValue=${encodeURIComponent(numericValue ?? '')}`
|
||||||
|
|
||||||
const creatorAvatarUrlParam =
|
const creatorAvatarUrlParam =
|
||||||
props.creatorAvatarUrl === undefined
|
creatorAvatarUrl === undefined
|
||||||
? ''
|
? ''
|
||||||
: `&creatorAvatarUrl=${encodeURIComponent(props.creatorAvatarUrl ?? '')}`
|
: `&creatorAvatarUrl=${encodeURIComponent(creatorAvatarUrl ?? '')}`
|
||||||
|
|
||||||
const challengeUrlParams = challenge
|
const challengeUrlParams = challenge
|
||||||
? `&creatorAmount=${creatorAmount}&creatorOutcome=${creatorOutcome}` +
|
? `&creatorAmount=${creatorAmount}&creatorOutcome=${creatorOutcome}` +
|
||||||
|
@ -136,16 +148,21 @@ export function buildCardUrl(props: OgCardProps, challenge?: Challenge) {
|
||||||
`&acceptedName=${userName ?? ''}&acceptedAvatarUrl=${userAvatarUrl ?? ''}`
|
`&acceptedName=${userName ?? ''}&acceptedAvatarUrl=${userAvatarUrl ?? ''}`
|
||||||
: ''
|
: ''
|
||||||
|
|
||||||
|
const resolutionUrlParam = resolution
|
||||||
|
? `&resolution=${encodeURIComponent(resolution)}`
|
||||||
|
: ''
|
||||||
|
|
||||||
// URL encode each of the props, then add them as query params
|
// URL encode each of the props, then add them as query params
|
||||||
return (
|
return (
|
||||||
`https://manifold-og-image.vercel.app/m.png` +
|
`https://manifold-og-image.vercel.app/m.png` +
|
||||||
`?question=${encodeURIComponent(props.question)}` +
|
`?question=${encodeURIComponent(question)}` +
|
||||||
probabilityParam +
|
probabilityParam +
|
||||||
numericValueParam +
|
numericValueParam +
|
||||||
`&metadata=${encodeURIComponent(props.metadata)}` +
|
`&metadata=${encodeURIComponent(metadata)}` +
|
||||||
`&creatorName=${encodeURIComponent(props.creatorName)}` +
|
`&creatorName=${encodeURIComponent(creatorName)}` +
|
||||||
creatorAvatarUrlParam +
|
creatorAvatarUrlParam +
|
||||||
`&creatorUsername=${encodeURIComponent(props.creatorUsername)}` +
|
`&creatorUsername=${encodeURIComponent(creatorUsername)}` +
|
||||||
challengeUrlParams
|
challengeUrlParams +
|
||||||
|
resolutionUrlParam
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,6 +34,11 @@ export const FIREBASE_CONFIG = ENV_CONFIG.firebaseConfig
|
||||||
export const PROJECT_ID = ENV_CONFIG.firebaseConfig.projectId
|
export const PROJECT_ID = ENV_CONFIG.firebaseConfig.projectId
|
||||||
export const IS_PRIVATE_MANIFOLD = ENV_CONFIG.visibility === 'PRIVATE'
|
export const IS_PRIVATE_MANIFOLD = ENV_CONFIG.visibility === 'PRIVATE'
|
||||||
|
|
||||||
|
export const AUTH_COOKIE_NAME = `FBUSER_${PROJECT_ID.toUpperCase().replace(
|
||||||
|
/-/g,
|
||||||
|
'_'
|
||||||
|
)}`
|
||||||
|
|
||||||
// Manifold's domain or any subdomains thereof
|
// Manifold's domain or any subdomains thereof
|
||||||
export const CORS_ORIGIN_MANIFOLD = new RegExp(
|
export const CORS_ORIGIN_MANIFOLD = new RegExp(
|
||||||
'^https?://(?:[a-zA-Z0-9\\-]+\\.)*' + escapeRegExp(ENV_CONFIG.domain) + '$'
|
'^https?://(?:[a-zA-Z0-9\\-]+\\.)*' + escapeRegExp(ENV_CONFIG.domain) + '$'
|
||||||
|
|
|
@ -6,14 +6,16 @@ export type Group = {
|
||||||
creatorId: string // User id
|
creatorId: string // User id
|
||||||
createdTime: number
|
createdTime: number
|
||||||
mostRecentActivityTime: number
|
mostRecentActivityTime: number
|
||||||
memberIds: string[] // User ids
|
|
||||||
anyoneCanJoin: boolean
|
anyoneCanJoin: boolean
|
||||||
contractIds: string[]
|
totalContracts: number
|
||||||
|
totalMembers: number
|
||||||
aboutPostId?: string
|
aboutPostId?: string
|
||||||
chatDisabled?: boolean
|
chatDisabled?: boolean
|
||||||
mostRecentChatActivityTime?: number
|
|
||||||
mostRecentContractAddedTime?: number
|
mostRecentContractAddedTime?: number
|
||||||
|
/** @deprecated - members and contracts now stored as subcollections*/
|
||||||
|
memberIds?: string[] // Deprecated
|
||||||
|
/** @deprecated - members and contracts now stored as subcollections*/
|
||||||
|
contractIds?: string[] // Deprecated
|
||||||
}
|
}
|
||||||
export const MAX_GROUP_NAME_LENGTH = 75
|
export const MAX_GROUP_NAME_LENGTH = 75
|
||||||
export const MAX_ABOUT_LENGTH = 140
|
export const MAX_ABOUT_LENGTH = 140
|
||||||
|
|
|
@ -3,6 +3,6 @@ export type Like = {
|
||||||
userId: string
|
userId: string
|
||||||
type: 'contract'
|
type: 'contract'
|
||||||
createdTime: number
|
createdTime: number
|
||||||
tipTxnId?: string
|
tipTxnId?: string // only holds most recent tip txn id
|
||||||
}
|
}
|
||||||
export const LIKE_TIP_AMOUNT = 5
|
export const LIKE_TIP_AMOUNT = 5
|
||||||
|
|
|
@ -118,7 +118,7 @@ const getFreeResponseContractLoanUpdate = (
|
||||||
contract: FreeResponseContract | MultipleChoiceContract,
|
contract: FreeResponseContract | MultipleChoiceContract,
|
||||||
bets: Bet[]
|
bets: Bet[]
|
||||||
) => {
|
) => {
|
||||||
const openBets = bets.filter((bet) => bet.isSold || bet.sale)
|
const openBets = bets.filter((bet) => !bet.isSold && !bet.sale)
|
||||||
|
|
||||||
return openBets.map((bet) => {
|
return openBets.map((bet) => {
|
||||||
const loanAmount = bet.loanAmount ?? 0
|
const loanAmount = bet.loanAmount ?? 0
|
||||||
|
|
|
@ -15,6 +15,7 @@ export type Notification = {
|
||||||
sourceUserUsername?: string
|
sourceUserUsername?: string
|
||||||
sourceUserAvatarUrl?: string
|
sourceUserAvatarUrl?: string
|
||||||
sourceText?: string
|
sourceText?: string
|
||||||
|
data?: string
|
||||||
|
|
||||||
sourceContractTitle?: string
|
sourceContractTitle?: string
|
||||||
sourceContractCreatorUsername?: string
|
sourceContractCreatorUsername?: string
|
||||||
|
|
|
@ -34,6 +34,7 @@ export type User = {
|
||||||
followerCountCached: number
|
followerCountCached: number
|
||||||
|
|
||||||
followedCategories?: string[]
|
followedCategories?: string[]
|
||||||
|
homeSections?: { visible: string[]; hidden: string[] }
|
||||||
|
|
||||||
referredByUserId?: string
|
referredByUserId?: string
|
||||||
referredByContractId?: string
|
referredByContractId?: string
|
||||||
|
|
|
@ -35,7 +35,7 @@ export default Node.create<IframeOptions>({
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
class: 'iframe-wrapper' + ' ' + wrapperClasses,
|
class: 'iframe-wrapper' + ' ' + wrapperClasses,
|
||||||
// Tailwind JIT doesn't seem to pick up `pb-[20rem]`, so we hack this in:
|
// Tailwind JIT doesn't seem to pick up `pb-[20rem]`, so we hack this in:
|
||||||
style: 'padding-bottom: 20rem;',
|
style: 'padding-bottom: 20rem; ',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -48,6 +48,9 @@ export default Node.create<IframeOptions>({
|
||||||
frameborder: {
|
frameborder: {
|
||||||
default: 0,
|
default: 0,
|
||||||
},
|
},
|
||||||
|
height: {
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
allowfullscreen: {
|
allowfullscreen: {
|
||||||
default: this.options.allowFullscreen,
|
default: this.options.allowFullscreen,
|
||||||
parseHTML: () => this.options.allowFullscreen,
|
parseHTML: () => this.options.allowFullscreen,
|
||||||
|
@ -60,6 +63,11 @@ export default Node.create<IframeOptions>({
|
||||||
},
|
},
|
||||||
|
|
||||||
renderHTML({ HTMLAttributes }) {
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
this.options.HTMLAttributes.style =
|
||||||
|
this.options.HTMLAttributes.style +
|
||||||
|
' height: ' +
|
||||||
|
HTMLAttributes.height +
|
||||||
|
';'
|
||||||
return [
|
return [
|
||||||
'div',
|
'div',
|
||||||
this.options.HTMLAttributes,
|
this.options.HTMLAttributes,
|
||||||
|
|
|
@ -15,6 +15,7 @@ A list of community-created projects built on, or related to, Manifold Markets.
|
||||||
## API / Dev
|
## API / Dev
|
||||||
|
|
||||||
- [PyManifold](https://github.com/bcongdon/PyManifold) - Python client for the Manifold API
|
- [PyManifold](https://github.com/bcongdon/PyManifold) - Python client for the Manifold API
|
||||||
|
- [PyManifold fork](https://github.com/gappleto97/PyManifold/) - Fork maintained by [@LivInTheLookingGlass](https://manifold.markets/LivInTheLookingGlass)
|
||||||
- [manifold-markets-python](https://github.com/vluzko/manifold-markets-python) - Python tools for working with Manifold Markets (including various accuracy metrics)
|
- [manifold-markets-python](https://github.com/vluzko/manifold-markets-python) - Python tools for working with Manifold Markets (including various accuracy metrics)
|
||||||
- [ManifoldMarketManager](https://github.com/gappleto97/ManifoldMarketManager) - Python script and library to automatically manage markets
|
- [ManifoldMarketManager](https://github.com/gappleto97/ManifoldMarketManager) - Python script and library to automatically manage markets
|
||||||
- [manifeed](https://github.com/joy-void-joy/manifeed) - Tool that creates an RSS feed for new Manifold markets
|
- [manifeed](https://github.com/joy-void-joy/manifeed) - Tool that creates an RSS feed for new Manifold markets
|
||||||
|
@ -24,3 +25,16 @@ A list of community-created projects built on, or related to, Manifold Markets.
|
||||||
|
|
||||||
- [@manifold@botsin.space](https://botsin.space/@manifold) - Posts new Manifold markets to Mastodon
|
- [@manifold@botsin.space](https://botsin.space/@manifold) - Posts new Manifold markets to Mastodon
|
||||||
- [James' Bot](https://github.com/manifoldmarkets/market-maker) — Simple trading bot that makes markets
|
- [James' Bot](https://github.com/manifoldmarkets/market-maker) — Simple trading bot that makes markets
|
||||||
|
- [mana](https://github.com/AnnikaCodes/mana) - A Discord bot for Manifold by [@arae](https://manifold.markets/arae)
|
||||||
|
|
||||||
|
## Writeups
|
||||||
|
- [Information Markets, Decision Markets, Attention Markets, Action Markets](https://astralcodexten.substack.com/p/information-markets-decision-markets) by Scott Alexander
|
||||||
|
- [Mismatched Monetary Motivation in Manifold Markets](https://kevin.zielnicki.com/2022/02/17/manifold/) by Kevin Zielnicki
|
||||||
|
- [Introducing the Salem/CSPI Forecasting Tournament](https://www.cspicenter.com/p/introducing-the-salemcspi-forecasting) by Richard Hanania
|
||||||
|
- [What I learned about running a betting market game night contest](https://shakeddown.wordpress.com/2022/08/04/what-i-learned-about-running-a-betting-market-game-night-contest/) by shakeddown
|
||||||
|
- [Free-riding on prediction markets](https://pedunculate.substack.com/p/free-riding-on-prediction-markets) by John Roxton
|
||||||
|
|
||||||
|
## Art
|
||||||
|
|
||||||
|
- Folded origami and doodles by [@hamnox](https://manifold.markets/hamnox) 
|
||||||
|
- Laser-cut Foldy by [@wasabipesto](https://manifold.markets/wasabipesto) 
|
||||||
|
|
|
@ -26,9 +26,55 @@
|
||||||
"collectionGroup": "bets",
|
"collectionGroup": "bets",
|
||||||
"queryScope": "COLLECTION_GROUP",
|
"queryScope": "COLLECTION_GROUP",
|
||||||
"fields": [
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "isFilled",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"fieldPath": "userId",
|
"fieldPath": "userId",
|
||||||
"order": "ASCENDING"
|
"order": "ASCENDING"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collectionGroup": "bets",
|
||||||
|
"queryScope": "COLLECTION_GROUP",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "userId",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdTime",
|
||||||
|
"order": "DESCENDING"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collectionGroup": "bets",
|
||||||
|
"queryScope": "COLLECTION",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "isCancelled",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isFilled",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdTime",
|
||||||
|
"order": "DESCENDING"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collectionGroup": "challenges",
|
||||||
|
"queryScope": "COLLECTION_GROUP",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "creatorId",
|
||||||
|
"order": "ASCENDING"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldPath": "createdTime",
|
"fieldPath": "createdTime",
|
||||||
|
@ -54,6 +100,34 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"collectionGroup": "comments",
|
||||||
|
"queryScope": "COLLECTION_GROUP",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "userId",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdTime",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collectionGroup": "comments",
|
||||||
|
"queryScope": "COLLECTION_GROUP",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "userId",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdTime",
|
||||||
|
"order": "DESCENDING"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"collectionGroup": "contracts",
|
"collectionGroup": "contracts",
|
||||||
"queryScope": "COLLECTION",
|
"queryScope": "COLLECTION",
|
||||||
|
@ -82,6 +156,42 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"collectionGroup": "contracts",
|
||||||
|
"queryScope": "COLLECTION",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "creatorId",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isResolved",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "popularityScore",
|
||||||
|
"order": "DESCENDING"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collectionGroup": "contracts",
|
||||||
|
"queryScope": "COLLECTION",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "groupSlugs",
|
||||||
|
"arrayConfig": "CONTAINS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isResolved",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "popularityScore",
|
||||||
|
"order": "DESCENDING"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"collectionGroup": "contracts",
|
"collectionGroup": "contracts",
|
||||||
"queryScope": "COLLECTION",
|
"queryScope": "COLLECTION",
|
||||||
|
@ -128,6 +238,46 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"collectionGroup": "contracts",
|
||||||
|
"queryScope": "COLLECTION",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "isResolved",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "visibility",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "closeTime",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "popularityScore",
|
||||||
|
"order": "DESCENDING"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collectionGroup": "contracts",
|
||||||
|
"queryScope": "COLLECTION",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "isResolved",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "visibility",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "popularityScore",
|
||||||
|
"order": "DESCENDING"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"collectionGroup": "contracts",
|
"collectionGroup": "contracts",
|
||||||
"queryScope": "COLLECTION",
|
"queryScope": "COLLECTION",
|
||||||
|
|
|
@ -160,34 +160,49 @@ service cloud.firestore {
|
||||||
.hasOnly(['isSeen', 'viewTime']);
|
.hasOnly(['isSeen', 'viewTime']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
match /{somePath=**}/groupMembers/{memberId} {
|
||||||
|
allow read;
|
||||||
|
}
|
||||||
|
|
||||||
|
match /{somePath=**}/groupContracts/{contractId} {
|
||||||
|
allow read;
|
||||||
|
}
|
||||||
|
|
||||||
match /groups/{groupId} {
|
match /groups/{groupId} {
|
||||||
allow read;
|
allow read;
|
||||||
allow update: if request.auth.uid == resource.data.creatorId
|
allow update: if (request.auth.uid == resource.data.creatorId || isAdmin())
|
||||||
&& request.resource.data.diff(resource.data)
|
&& request.resource.data.diff(resource.data)
|
||||||
.affectedKeys()
|
.affectedKeys()
|
||||||
.hasOnly(['name', 'about', 'contractIds', 'memberIds', 'anyoneCanJoin', 'aboutPostId' ]);
|
.hasOnly(['name', 'about', 'anyoneCanJoin', 'aboutPostId' ]);
|
||||||
allow update: if (request.auth.uid in resource.data.memberIds || resource.data.anyoneCanJoin)
|
|
||||||
&& request.resource.data.diff(resource.data)
|
|
||||||
.affectedKeys()
|
|
||||||
.hasOnly([ 'contractIds', 'memberIds' ]);
|
|
||||||
allow delete: if request.auth.uid == resource.data.creatorId;
|
allow delete: if request.auth.uid == resource.data.creatorId;
|
||||||
|
|
||||||
function isMember() {
|
match /groupContracts/{contractId} {
|
||||||
return request.auth.uid in get(/databases/$(database)/documents/groups/$(groupId)).data.memberIds;
|
allow write: if isGroupMember() || request.auth.uid == get(/databases/$(database)/documents/groups/$(groupId)).data.creatorId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
match /groupMembers/{memberId}{
|
||||||
|
allow create: if request.auth.uid == get(/databases/$(database)/documents/groups/$(groupId)).data.creatorId || (request.auth.uid == request.resource.data.userId && get(/databases/$(database)/documents/groups/$(groupId)).data.anyoneCanJoin);
|
||||||
|
allow delete: if request.auth.uid == resource.data.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGroupMember() {
|
||||||
|
return exists(/databases/$(database)/documents/groups/$(groupId)/groupMembers/$(request.auth.uid));
|
||||||
|
}
|
||||||
|
|
||||||
match /comments/{commentId} {
|
match /comments/{commentId} {
|
||||||
allow read;
|
allow read;
|
||||||
allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) && isMember();
|
allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) && isGroupMember();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
match /posts/{postId} {
|
match /posts/{postId} {
|
||||||
allow read;
|
allow read;
|
||||||
allow update: if request.auth.uid == resource.data.creatorId
|
allow update: if isAdmin() || request.auth.uid == resource.data.creatorId
|
||||||
&& request.resource.data.diff(resource.data)
|
&& request.resource.data.diff(resource.data)
|
||||||
.affectedKeys()
|
.affectedKeys()
|
||||||
.hasOnly(['name', 'content']);
|
.hasOnly(['name', 'content']);
|
||||||
allow delete: if request.auth.uid == resource.data.creatorId;
|
allow delete: if isAdmin() || request.auth.uid == resource.data.creatorId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -58,13 +58,23 @@ export const creategroup = newEndpoint({}, async (req, auth) => {
|
||||||
createdTime: Date.now(),
|
createdTime: Date.now(),
|
||||||
mostRecentActivityTime: Date.now(),
|
mostRecentActivityTime: Date.now(),
|
||||||
// TODO: allow users to add contract ids on group creation
|
// TODO: allow users to add contract ids on group creation
|
||||||
contractIds: [],
|
|
||||||
anyoneCanJoin,
|
anyoneCanJoin,
|
||||||
memberIds,
|
totalContracts: 0,
|
||||||
|
totalMembers: memberIds.length,
|
||||||
}
|
}
|
||||||
|
|
||||||
await groupRef.create(group)
|
await groupRef.create(group)
|
||||||
|
|
||||||
|
// create a GroupMemberDoc for each member
|
||||||
|
await Promise.all(
|
||||||
|
memberIds.map((memberId) =>
|
||||||
|
groupRef.collection('groupMembers').doc(memberId).create({
|
||||||
|
userId: memberId,
|
||||||
|
createdTime: Date.now(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return { status: 'success', group: group }
|
return { status: 'success', group: group }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -155,8 +155,14 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
group = groupDoc.data() as Group
|
group = groupDoc.data() as Group
|
||||||
|
const groupMembersSnap = await firestore
|
||||||
|
.collection(`groups/${groupId}/groupMembers`)
|
||||||
|
.get()
|
||||||
|
const groupMemberDocs = groupMembersSnap.docs.map(
|
||||||
|
(doc) => doc.data() as { userId: string; createdTime: number }
|
||||||
|
)
|
||||||
if (
|
if (
|
||||||
!group.memberIds.includes(user.id) &&
|
!groupMemberDocs.map((m) => m.userId).includes(user.id) &&
|
||||||
!group.anyoneCanJoin &&
|
!group.anyoneCanJoin &&
|
||||||
group.creatorId !== user.id
|
group.creatorId !== user.id
|
||||||
) {
|
) {
|
||||||
|
@ -227,11 +233,20 @@ export const createmarket = newEndpoint({}, async (req, auth) => {
|
||||||
await contractRef.create(contract)
|
await contractRef.create(contract)
|
||||||
|
|
||||||
if (group != null) {
|
if (group != null) {
|
||||||
if (!group.contractIds.includes(contractRef.id)) {
|
const groupContractsSnap = await firestore
|
||||||
|
.collection(`groups/${groupId}/groupContracts`)
|
||||||
|
.get()
|
||||||
|
const groupContracts = groupContractsSnap.docs.map(
|
||||||
|
(doc) => doc.data() as { contractId: string; createdTime: number }
|
||||||
|
)
|
||||||
|
if (!groupContracts.map((c) => c.contractId).includes(contractRef.id)) {
|
||||||
await createGroupLinks(group, [contractRef.id], auth.uid)
|
await createGroupLinks(group, [contractRef.id], auth.uid)
|
||||||
const groupDocRef = firestore.collection('groups').doc(group.id)
|
const groupContractRef = firestore
|
||||||
groupDocRef.update({
|
.collection(`groups/${groupId}/groupContracts`)
|
||||||
contractIds: uniq([...group.contractIds, contractRef.id]),
|
.doc(contract.id)
|
||||||
|
await groupContractRef.set({
|
||||||
|
contractId: contract.id,
|
||||||
|
createdTime: Date.now(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -151,15 +151,6 @@ export const createNotification = async (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const notifyContractCreatorOfUniqueBettorsBonus = async (
|
|
||||||
userToReasonTexts: user_to_reason_texts,
|
|
||||||
userId: string
|
|
||||||
) => {
|
|
||||||
userToReasonTexts[userId] = {
|
|
||||||
reason: 'unique_bettors_on_your_contract',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const userToReasonTexts: user_to_reason_texts = {}
|
const userToReasonTexts: user_to_reason_texts = {}
|
||||||
// The following functions modify the userToReasonTexts object in place.
|
// The following functions modify the userToReasonTexts object in place.
|
||||||
|
|
||||||
|
@ -192,16 +183,6 @@ export const createNotification = async (
|
||||||
sourceContract
|
sourceContract
|
||||||
) {
|
) {
|
||||||
await notifyContractCreator(userToReasonTexts, sourceContract)
|
await notifyContractCreator(userToReasonTexts, sourceContract)
|
||||||
} else if (
|
|
||||||
sourceType === 'bonus' &&
|
|
||||||
sourceUpdateType === 'created' &&
|
|
||||||
sourceContract
|
|
||||||
) {
|
|
||||||
// Note: the daily bonus won't have a contract attached to it
|
|
||||||
await notifyContractCreatorOfUniqueBettorsBonus(
|
|
||||||
userToReasonTexts,
|
|
||||||
sourceContract.creatorId
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await createUsersNotifications(userToReasonTexts)
|
await createUsersNotifications(userToReasonTexts)
|
||||||
|
@ -723,3 +704,52 @@ export const createLikeNotification = async (
|
||||||
}
|
}
|
||||||
return await notificationRef.set(removeUndefinedProps(notification))
|
return await notificationRef.set(removeUndefinedProps(notification))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function filterUserIdsForOnlyFollowerIds(
|
||||||
|
userIds: string[],
|
||||||
|
contractId: string
|
||||||
|
) {
|
||||||
|
// get contract follower documents and check here if they're a follower
|
||||||
|
const contractFollowersSnap = await firestore
|
||||||
|
.collection(`contracts/${contractId}/follows`)
|
||||||
|
.get()
|
||||||
|
const contractFollowersIds = contractFollowersSnap.docs.map(
|
||||||
|
(doc) => doc.data().id
|
||||||
|
)
|
||||||
|
return userIds.filter((id) => contractFollowersIds.includes(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createUniqueBettorBonusNotification = async (
|
||||||
|
contractCreatorId: string,
|
||||||
|
bettor: User,
|
||||||
|
txnId: string,
|
||||||
|
contract: Contract,
|
||||||
|
amount: number,
|
||||||
|
idempotencyKey: string
|
||||||
|
) => {
|
||||||
|
const notificationRef = firestore
|
||||||
|
.collection(`/users/${contractCreatorId}/notifications`)
|
||||||
|
.doc(idempotencyKey)
|
||||||
|
const notification: Notification = {
|
||||||
|
id: idempotencyKey,
|
||||||
|
userId: contractCreatorId,
|
||||||
|
reason: 'unique_bettors_on_your_contract',
|
||||||
|
createdTime: Date.now(),
|
||||||
|
isSeen: false,
|
||||||
|
sourceId: txnId,
|
||||||
|
sourceType: 'bonus',
|
||||||
|
sourceUpdateType: 'created',
|
||||||
|
sourceUserName: bettor.name,
|
||||||
|
sourceUserUsername: bettor.username,
|
||||||
|
sourceUserAvatarUrl: bettor.avatarUrl,
|
||||||
|
sourceText: amount.toString(),
|
||||||
|
sourceSlug: contract.slug,
|
||||||
|
sourceTitle: contract.question,
|
||||||
|
// Perhaps not necessary, but just in case
|
||||||
|
sourceContractSlug: contract.slug,
|
||||||
|
sourceContractId: contract.id,
|
||||||
|
sourceContractTitle: contract.question,
|
||||||
|
sourceContractCreatorUsername: contract.creatorUsername,
|
||||||
|
}
|
||||||
|
return await notificationRef.set(removeUndefinedProps(notification))
|
||||||
|
}
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { uniq } from 'lodash'
|
|
||||||
|
|
||||||
import { PrivateUser, User } from '../../common/user'
|
import { PrivateUser, User } from '../../common/user'
|
||||||
import { getUser, getUserByUsername, getValues } from './utils'
|
import { getUser, getUserByUsername, getValues } from './utils'
|
||||||
|
@ -17,7 +16,7 @@ import {
|
||||||
|
|
||||||
import { track } from './analytics'
|
import { track } from './analytics'
|
||||||
import { APIError, newEndpoint, validate } from './api'
|
import { APIError, newEndpoint, validate } from './api'
|
||||||
import { Group, NEW_USER_GROUP_SLUGS } from '../../common/group'
|
import { Group } from '../../common/group'
|
||||||
import { SUS_STARTING_BALANCE, STARTING_BALANCE } from '../../common/economy'
|
import { SUS_STARTING_BALANCE, STARTING_BALANCE } from '../../common/economy'
|
||||||
|
|
||||||
const bodySchema = z.object({
|
const bodySchema = z.object({
|
||||||
|
@ -117,23 +116,8 @@ const addUserToDefaultGroups = async (user: User) => {
|
||||||
firestore.collection('groups').where('slug', '==', slug)
|
firestore.collection('groups').where('slug', '==', slug)
|
||||||
)
|
)
|
||||||
await firestore
|
await firestore
|
||||||
.collection('groups')
|
.collection(`groups/${groups[0].id}/groupMembers`)
|
||||||
.doc(groups[0].id)
|
.doc(user.id)
|
||||||
.update({
|
.set({ userId: user.id, createdTime: Date.now() })
|
||||||
memberIds: uniq(groups[0].memberIds.concat(user.id)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const slug of NEW_USER_GROUP_SLUGS) {
|
|
||||||
const groups = await getValues<Group>(
|
|
||||||
firestore.collection('groups').where('slug', '==', slug)
|
|
||||||
)
|
|
||||||
const group = groups[0]
|
|
||||||
await firestore
|
|
||||||
.collection('groups')
|
|
||||||
.doc(group.id)
|
|
||||||
.update({
|
|
||||||
memberIds: uniq(group.memberIds.concat(user.id)),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -351,8 +351,7 @@
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
" />
|
" />
|
||||||
Resolve your market to earn {{creatorFee}} as the
|
Please resolve your market.
|
||||||
creator commission.
|
|
||||||
<br style="
|
<br style="
|
||||||
font-family: 'Helvetica Neue', Helvetica,
|
font-family: 'Helvetica Neue', Helvetica,
|
||||||
Arial, sans-serif;
|
Arial, sans-serif;
|
||||||
|
|
|
@ -4,7 +4,6 @@ import { Bet } from '../../common/bet'
|
||||||
import { getProbability } from '../../common/calculate'
|
import { getProbability } from '../../common/calculate'
|
||||||
import { Comment } from '../../common/comment'
|
import { Comment } from '../../common/comment'
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
import { DPM_CREATOR_FEE } from '../../common/fees'
|
|
||||||
import { PrivateUser, User } from '../../common/user'
|
import { PrivateUser, User } from '../../common/user'
|
||||||
import {
|
import {
|
||||||
formatLargeNumber,
|
formatLargeNumber,
|
||||||
|
@ -187,7 +186,7 @@ export const sendPersonalFollowupEmail = async (
|
||||||
|
|
||||||
const emailBody = `Hi ${firstName},
|
const emailBody = `Hi ${firstName},
|
||||||
|
|
||||||
Thanks for signing up! I'm one of the cofounders of Manifold Markets, and was wondering how you've found your exprience on the platform so far?
|
Thanks for signing up! I'm one of the cofounders of Manifold Markets, and was wondering how you've found your experience on the platform so far?
|
||||||
|
|
||||||
If you haven't already, I encourage you to try creating your own prediction market (https://manifold.markets/create) and joining our Discord chat (https://discord.com/invite/eHQBNBqXuh).
|
If you haven't already, I encourage you to try creating your own prediction market (https://manifold.markets/create) and joining our Discord chat (https://discord.com/invite/eHQBNBqXuh).
|
||||||
|
|
||||||
|
@ -322,7 +321,7 @@ export const sendMarketCloseEmail = async (
|
||||||
const { username, name, id: userId } = user
|
const { username, name, id: userId } = user
|
||||||
const firstName = name.split(' ')[0]
|
const firstName = name.split(' ')[0]
|
||||||
|
|
||||||
const { question, slug, volume, mechanism, collectedFees } = contract
|
const { question, slug, volume } = contract
|
||||||
|
|
||||||
const url = `https://${DOMAIN}/${username}/${slug}`
|
const url = `https://${DOMAIN}/${username}/${slug}`
|
||||||
const emailType = 'market-resolve'
|
const emailType = 'market-resolve'
|
||||||
|
@ -339,10 +338,6 @@ export const sendMarketCloseEmail = async (
|
||||||
userId,
|
userId,
|
||||||
name: firstName,
|
name: firstName,
|
||||||
volume: formatMoney(volume),
|
volume: formatMoney(volume),
|
||||||
creatorFee:
|
|
||||||
mechanism === 'dpm-2'
|
|
||||||
? `${DPM_CREATOR_FEE * 100}% of the profits`
|
|
||||||
: formatMoney(collectedFees.creatorFee),
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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-unfollow-user'
|
||||||
export * from './on-create-liquidity-provision'
|
export * from './on-create-liquidity-provision'
|
||||||
export * from './on-update-group'
|
export * from './on-update-group'
|
||||||
export * from './on-create-group'
|
|
||||||
export * from './on-update-user'
|
export * from './on-update-user'
|
||||||
export * from './on-create-comment-on-group'
|
|
||||||
export * from './on-create-txn'
|
export * from './on-create-txn'
|
||||||
export * from './on-delete-group'
|
export * from './on-delete-group'
|
||||||
export * from './score-contracts'
|
export * from './score-contracts'
|
||||||
|
@ -31,8 +29,7 @@ export * from './weekly-markets-emails'
|
||||||
export * from './reset-betting-streaks'
|
export * from './reset-betting-streaks'
|
||||||
export * from './reset-weekly-emails-flag'
|
export * from './reset-weekly-emails-flag'
|
||||||
export * from './on-update-contract-follow'
|
export * from './on-update-contract-follow'
|
||||||
export * from './on-create-like'
|
export * from './on-update-like'
|
||||||
export * from './on-delete-like'
|
|
||||||
|
|
||||||
// v2
|
// v2
|
||||||
export * from './health'
|
export * from './health'
|
||||||
|
@ -73,7 +70,6 @@ import { unsubscribe } from './unsubscribe'
|
||||||
import { stripewebhook, createcheckoutsession } from './stripe'
|
import { stripewebhook, createcheckoutsession } from './stripe'
|
||||||
import { getcurrentuser } from './get-current-user'
|
import { getcurrentuser } from './get-current-user'
|
||||||
import { acceptchallenge } from './accept-challenge'
|
import { acceptchallenge } from './accept-challenge'
|
||||||
import { getcustomtoken } from './get-custom-token'
|
|
||||||
import { createpost } from './create-post'
|
import { createpost } from './create-post'
|
||||||
import { savetwitchcredentials } from './save-twitch-credentials'
|
import { savetwitchcredentials } from './save-twitch-credentials'
|
||||||
|
|
||||||
|
@ -100,7 +96,6 @@ const stripeWebhookFunction = toCloudFunction(stripewebhook)
|
||||||
const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession)
|
const createCheckoutSessionFunction = toCloudFunction(createcheckoutsession)
|
||||||
const getCurrentUserFunction = toCloudFunction(getcurrentuser)
|
const getCurrentUserFunction = toCloudFunction(getcurrentuser)
|
||||||
const acceptChallenge = toCloudFunction(acceptchallenge)
|
const acceptChallenge = toCloudFunction(acceptchallenge)
|
||||||
const getCustomTokenFunction = toCloudFunction(getcustomtoken)
|
|
||||||
const createPostFunction = toCloudFunction(createpost)
|
const createPostFunction = toCloudFunction(createpost)
|
||||||
const saveTwitchCredentials = toCloudFunction(savetwitchcredentials)
|
const saveTwitchCredentials = toCloudFunction(savetwitchcredentials)
|
||||||
|
|
||||||
|
@ -125,7 +120,6 @@ export {
|
||||||
createCheckoutSessionFunction as createcheckoutsession,
|
createCheckoutSessionFunction as createcheckoutsession,
|
||||||
getCurrentUserFunction as getcurrentuser,
|
getCurrentUserFunction as getcurrentuser,
|
||||||
acceptChallenge as acceptchallenge,
|
acceptChallenge as acceptchallenge,
|
||||||
getCustomTokenFunction as getcustomtoken,
|
|
||||||
createPostFunction as createpost,
|
createPostFunction as createpost,
|
||||||
saveTwitchCredentials as savetwitchcredentials
|
saveTwitchCredentials as savetwitchcredentials
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,7 +7,7 @@ import { getUser, getValues, isProd, log } from './utils'
|
||||||
import {
|
import {
|
||||||
createBetFillNotification,
|
createBetFillNotification,
|
||||||
createBettingStreakBonusNotification,
|
createBettingStreakBonusNotification,
|
||||||
createNotification,
|
createUniqueBettorBonusNotification,
|
||||||
} from './create-notification'
|
} from './create-notification'
|
||||||
import { filterDefined } from '../../common/util/array'
|
import { filterDefined } from '../../common/util/array'
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
|
@ -54,11 +54,11 @@ export const onCreateBet = functions.firestore
|
||||||
log(`Could not find contract ${contractId}`)
|
log(`Could not find contract ${contractId}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await updateUniqueBettorsAndGiveCreatorBonus(contract, eventId, bet.userId)
|
|
||||||
|
|
||||||
const bettor = await getUser(bet.userId)
|
const bettor = await getUser(bet.userId)
|
||||||
if (!bettor) return
|
if (!bettor) return
|
||||||
|
|
||||||
|
await updateUniqueBettorsAndGiveCreatorBonus(contract, eventId, bettor)
|
||||||
await notifyFills(bet, contract, eventId, bettor)
|
await notifyFills(bet, contract, eventId, bettor)
|
||||||
await updateBettingStreak(bettor, bet, contract, eventId)
|
await updateBettingStreak(bettor, bet, contract, eventId)
|
||||||
|
|
||||||
|
@ -126,7 +126,7 @@ const updateBettingStreak = async (
|
||||||
const updateUniqueBettorsAndGiveCreatorBonus = async (
|
const updateUniqueBettorsAndGiveCreatorBonus = async (
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
eventId: string,
|
eventId: string,
|
||||||
bettorId: string
|
bettor: User
|
||||||
) => {
|
) => {
|
||||||
let previousUniqueBettorIds = contract.uniqueBettorIds
|
let previousUniqueBettorIds = contract.uniqueBettorIds
|
||||||
|
|
||||||
|
@ -147,13 +147,13 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const isNewUniqueBettor = !previousUniqueBettorIds.includes(bettorId)
|
const isNewUniqueBettor = !previousUniqueBettorIds.includes(bettor.id)
|
||||||
|
|
||||||
const newUniqueBettorIds = uniq([...previousUniqueBettorIds, bettorId])
|
const newUniqueBettorIds = uniq([...previousUniqueBettorIds, bettor.id])
|
||||||
// Update contract unique bettors
|
// Update contract unique bettors
|
||||||
if (!contract.uniqueBettorIds || isNewUniqueBettor) {
|
if (!contract.uniqueBettorIds || isNewUniqueBettor) {
|
||||||
log(`Got ${previousUniqueBettorIds} unique bettors`)
|
log(`Got ${previousUniqueBettorIds} unique bettors`)
|
||||||
isNewUniqueBettor && log(`And a new unique bettor ${bettorId}`)
|
isNewUniqueBettor && log(`And a new unique bettor ${bettor.id}`)
|
||||||
await firestore.collection(`contracts`).doc(contract.id).update({
|
await firestore.collection(`contracts`).doc(contract.id).update({
|
||||||
uniqueBettorIds: newUniqueBettorIds,
|
uniqueBettorIds: newUniqueBettorIds,
|
||||||
uniqueBettorCount: newUniqueBettorIds.length,
|
uniqueBettorCount: newUniqueBettorIds.length,
|
||||||
|
@ -161,7 +161,7 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
|
||||||
}
|
}
|
||||||
|
|
||||||
// No need to give a bonus for the creator's bet
|
// No need to give a bonus for the creator's bet
|
||||||
if (!isNewUniqueBettor || bettorId == contract.creatorId) return
|
if (!isNewUniqueBettor || bettor.id == contract.creatorId) return
|
||||||
|
|
||||||
// Create combined txn for all new unique bettors
|
// Create combined txn for all new unique bettors
|
||||||
const bonusTxnDetails = {
|
const bonusTxnDetails = {
|
||||||
|
@ -192,18 +192,13 @@ const updateUniqueBettorsAndGiveCreatorBonus = async (
|
||||||
log(`No bonus for user: ${contract.creatorId} - reason:`, result.status)
|
log(`No bonus for user: ${contract.creatorId} - reason:`, result.status)
|
||||||
} else {
|
} else {
|
||||||
log(`Bonus txn for user: ${contract.creatorId} completed:`, result.txn?.id)
|
log(`Bonus txn for user: ${contract.creatorId} completed:`, result.txn?.id)
|
||||||
await createNotification(
|
await createUniqueBettorBonusNotification(
|
||||||
|
contract.creatorId,
|
||||||
|
bettor,
|
||||||
result.txn.id,
|
result.txn.id,
|
||||||
'bonus',
|
|
||||||
'created',
|
|
||||||
fromUser,
|
|
||||||
eventId + '-bonus',
|
|
||||||
result.txn.amount + '',
|
|
||||||
{
|
|
||||||
contract,
|
contract,
|
||||||
slug: contract.slug,
|
result.txn.amount,
|
||||||
title: contract.question,
|
eventId + '-unique-bettor-bonus'
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,7 +6,10 @@ import { ContractComment } from '../../common/comment'
|
||||||
import { sendNewCommentEmail } from './emails'
|
import { sendNewCommentEmail } from './emails'
|
||||||
import { Bet } from '../../common/bet'
|
import { Bet } from '../../common/bet'
|
||||||
import { Answer } from '../../common/answer'
|
import { Answer } from '../../common/answer'
|
||||||
import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
|
import {
|
||||||
|
createCommentOrAnswerOrUpdatedContractNotification,
|
||||||
|
filterUserIdsForOnlyFollowerIds,
|
||||||
|
} from './create-notification'
|
||||||
import { parseMentions, richTextToString } from '../../common/util/parse'
|
import { parseMentions, richTextToString } from '../../common/util/parse'
|
||||||
import { addUserToContractFollowers } from './follow-market'
|
import { addUserToContractFollowers } from './follow-market'
|
||||||
|
|
||||||
|
@ -95,10 +98,13 @@ export const onCreateCommentOnContract = functions
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const recipientUserIds = uniq([
|
const recipientUserIds = await filterUserIdsForOnlyFollowerIds(
|
||||||
|
uniq([
|
||||||
contract.creatorId,
|
contract.creatorId,
|
||||||
...comments.map((comment) => comment.userId),
|
...comments.map((comment) => comment.userId),
|
||||||
]).filter((id) => id !== comment.userId)
|
]).filter((id) => id !== comment.userId),
|
||||||
|
contractId
|
||||||
|
)
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
recipientUserIds.map((userId) =>
|
recipientUserIds.map((userId) =>
|
||||||
|
|
|
@ -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,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
|
|
@ -1,32 +0,0 @@
|
||||||
import * as functions from 'firebase-functions'
|
|
||||||
import * as admin from 'firebase-admin'
|
|
||||||
import { Like } from '../../common/like'
|
|
||||||
import { getContract, log } from './utils'
|
|
||||||
import { uniq } from 'lodash'
|
|
||||||
|
|
||||||
const firestore = admin.firestore()
|
|
||||||
|
|
||||||
export const onDeleteLike = functions.firestore
|
|
||||||
.document('users/{userId}/likes/{likeId}')
|
|
||||||
.onDelete(async (change) => {
|
|
||||||
const like = change.data() as Like
|
|
||||||
if (like.type === 'contract') {
|
|
||||||
await removeContractLike(like)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const removeContractLike = async (like: Like) => {
|
|
||||||
const contract = await getContract(like.id)
|
|
||||||
if (!contract) {
|
|
||||||
log('Could not find contract')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const likedByUserIds = uniq(contract.likedByUserIds ?? [])
|
|
||||||
const newLikedByUserIds = likedByUserIds.filter(
|
|
||||||
(userId) => userId !== like.userId
|
|
||||||
)
|
|
||||||
await firestore.collection('contracts').doc(like.id).update({
|
|
||||||
likedByUserIds: newLikedByUserIds,
|
|
||||||
likedByUserCount: newLikedByUserIds.length,
|
|
||||||
})
|
|
||||||
}
|
|
|
@ -15,21 +15,68 @@ export const onUpdateGroup = functions.firestore
|
||||||
if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime)
|
if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime)
|
||||||
return
|
return
|
||||||
|
|
||||||
if (prevGroup.contractIds.length < group.contractIds.length) {
|
|
||||||
await firestore
|
|
||||||
.collection('groups')
|
|
||||||
.doc(group.id)
|
|
||||||
.update({ mostRecentContractAddedTime: Date.now() })
|
|
||||||
//TODO: create notification with isSeeOnHref set to the group's /group/slug/questions url
|
|
||||||
// but first, let the new /group/slug/chat notification permeate so that we can differentiate between the two
|
|
||||||
}
|
|
||||||
|
|
||||||
await firestore
|
await firestore
|
||||||
.collection('groups')
|
.collection('groups')
|
||||||
.doc(group.id)
|
.doc(group.id)
|
||||||
.update({ mostRecentActivityTime: Date.now() })
|
.update({ mostRecentActivityTime: Date.now() })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const onCreateGroupContract = functions.firestore
|
||||||
|
.document('groups/{groupId}/groupContracts/{contractId}')
|
||||||
|
.onCreate(async (change) => {
|
||||||
|
const groupId = change.ref.parent.parent?.id
|
||||||
|
if (groupId)
|
||||||
|
await firestore
|
||||||
|
.collection('groups')
|
||||||
|
.doc(groupId)
|
||||||
|
.update({
|
||||||
|
mostRecentContractAddedTime: Date.now(),
|
||||||
|
totalContracts: admin.firestore.FieldValue.increment(1),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
export const onDeleteGroupContract = functions.firestore
|
||||||
|
.document('groups/{groupId}/groupContracts/{contractId}')
|
||||||
|
.onDelete(async (change) => {
|
||||||
|
const groupId = change.ref.parent.parent?.id
|
||||||
|
if (groupId)
|
||||||
|
await firestore
|
||||||
|
.collection('groups')
|
||||||
|
.doc(groupId)
|
||||||
|
.update({
|
||||||
|
mostRecentContractAddedTime: Date.now(),
|
||||||
|
totalContracts: admin.firestore.FieldValue.increment(-1),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
export const onCreateGroupMember = functions.firestore
|
||||||
|
.document('groups/{groupId}/groupMembers/{memberId}')
|
||||||
|
.onCreate(async (change) => {
|
||||||
|
const groupId = change.ref.parent.parent?.id
|
||||||
|
if (groupId)
|
||||||
|
await firestore
|
||||||
|
.collection('groups')
|
||||||
|
.doc(groupId)
|
||||||
|
.update({
|
||||||
|
mostRecentActivityTime: Date.now(),
|
||||||
|
totalMembers: admin.firestore.FieldValue.increment(1),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
export const onDeleteGroupMember = functions.firestore
|
||||||
|
.document('groups/{groupId}/groupMembers/{memberId}')
|
||||||
|
.onDelete(async (change) => {
|
||||||
|
const groupId = change.ref.parent.parent?.id
|
||||||
|
if (groupId)
|
||||||
|
await firestore
|
||||||
|
.collection('groups')
|
||||||
|
.doc(groupId)
|
||||||
|
.update({
|
||||||
|
mostRecentActivityTime: Date.now(),
|
||||||
|
totalMembers: admin.firestore.FieldValue.increment(-1),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
export async function removeGroupLinks(group: Group, contractIds: string[]) {
|
export async function removeGroupLinks(group: Group, contractIds: string[]) {
|
||||||
for (const contractId of contractIds) {
|
for (const contractId of contractIds) {
|
||||||
const contract = await getContract(contractId)
|
const contract = await getContract(contractId)
|
||||||
|
|
|
@ -19,14 +19,36 @@ export const onCreateLike = functions.firestore
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const onUpdateLike = functions.firestore
|
||||||
|
.document('users/{userId}/likes/{likeId}')
|
||||||
|
.onUpdate(async (change, context) => {
|
||||||
|
const like = change.after.data() as Like
|
||||||
|
const prevLike = change.before.data() as Like
|
||||||
|
const { eventId } = context
|
||||||
|
if (like.type === 'contract' && like.tipTxnId !== prevLike.tipTxnId) {
|
||||||
|
await handleCreateLikeNotification(like, eventId)
|
||||||
|
await updateContractLikes(like)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const onDeleteLike = functions.firestore
|
||||||
|
.document('users/{userId}/likes/{likeId}')
|
||||||
|
.onDelete(async (change) => {
|
||||||
|
const like = change.data() as Like
|
||||||
|
if (like.type === 'contract') {
|
||||||
|
await removeContractLike(like)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const updateContractLikes = async (like: Like) => {
|
const updateContractLikes = async (like: Like) => {
|
||||||
const contract = await getContract(like.id)
|
const contract = await getContract(like.id)
|
||||||
if (!contract) {
|
if (!contract) {
|
||||||
log('Could not find contract')
|
log('Could not find contract')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const likedByUserIds = uniq(contract.likedByUserIds ?? [])
|
const likedByUserIds = uniq(
|
||||||
likedByUserIds.push(like.userId)
|
(contract.likedByUserIds ?? []).concat(like.userId)
|
||||||
|
)
|
||||||
await firestore
|
await firestore
|
||||||
.collection('contracts')
|
.collection('contracts')
|
||||||
.doc(like.id)
|
.doc(like.id)
|
||||||
|
@ -69,3 +91,19 @@ const handleCreateLikeNotification = async (like: Like, eventId: string) => {
|
||||||
tipTxnData
|
tipTxnData
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const removeContractLike = async (like: Like) => {
|
||||||
|
const contract = await getContract(like.id)
|
||||||
|
if (!contract) {
|
||||||
|
log('Could not find contract')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const likedByUserIds = uniq(contract.likedByUserIds ?? [])
|
||||||
|
const newLikedByUserIds = likedByUserIds.filter(
|
||||||
|
(userId) => userId !== like.userId
|
||||||
|
)
|
||||||
|
await firestore.collection('contracts').doc(like.id).update({
|
||||||
|
likedByUserIds: newLikedByUserIds,
|
||||||
|
likedByUserCount: newLikedByUserIds.length,
|
||||||
|
})
|
||||||
|
}
|
|
@ -15,7 +15,10 @@ export const resetBettingStreaksForUsers = functions.pubsub
|
||||||
})
|
})
|
||||||
|
|
||||||
const resetBettingStreaksInternal = async () => {
|
const resetBettingStreaksInternal = async () => {
|
||||||
const usersSnap = await firestore.collection('users').get()
|
const usersSnap = await firestore
|
||||||
|
.collection('users')
|
||||||
|
.where('currentBettingStreak', '>', 0)
|
||||||
|
.get()
|
||||||
|
|
||||||
const users = usersSnap.docs.map((doc) => doc.data() as User)
|
const users = usersSnap.docs.map((doc) => doc.data() as User)
|
||||||
|
|
||||||
|
|
|
@ -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 { initAdmin } from './script-init'
|
||||||
import { isProd, log } from '../utils'
|
import { isProd, log } from '../utils'
|
||||||
import { getSlug } from '../create-group'
|
import { getSlug } from '../create-group'
|
||||||
import { Group } from '../../../common/group'
|
import { Group, GroupLink } from '../../../common/group'
|
||||||
|
import { uniq } from 'lodash'
|
||||||
|
import { Contract } from 'common/contract'
|
||||||
|
|
||||||
const getTaggedContractIds = async (tag: string) => {
|
const getTaggedContracts = async (tag: string) => {
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
const results = await firestore
|
const results = await firestore
|
||||||
.collection('contracts')
|
.collection('contracts')
|
||||||
.where('lowercaseTags', 'array-contains', tag.toLowerCase())
|
.where('lowercaseTags', 'array-contains', tag.toLowerCase())
|
||||||
.get()
|
.get()
|
||||||
return results.docs.map((d) => d.id)
|
return results.docs.map((d) => d.data() as Contract)
|
||||||
}
|
}
|
||||||
|
|
||||||
const createGroup = async (
|
const createGroup = async (
|
||||||
name: string,
|
name: string,
|
||||||
about: string,
|
about: string,
|
||||||
contractIds: string[]
|
contracts: Contract[]
|
||||||
) => {
|
) => {
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
const creatorId = isProd()
|
const creatorId = isProd()
|
||||||
|
@ -36,21 +38,60 @@ const createGroup = async (
|
||||||
about,
|
about,
|
||||||
createdTime: now,
|
createdTime: now,
|
||||||
mostRecentActivityTime: now,
|
mostRecentActivityTime: now,
|
||||||
contractIds: contractIds,
|
|
||||||
anyoneCanJoin: true,
|
anyoneCanJoin: true,
|
||||||
memberIds: [],
|
totalContracts: contracts.length,
|
||||||
|
totalMembers: 1,
|
||||||
}
|
}
|
||||||
return await groupRef.create(group)
|
await groupRef.create(group)
|
||||||
|
// create a GroupMemberDoc for the creator
|
||||||
|
const memberDoc = groupRef.collection('groupMembers').doc(creatorId)
|
||||||
|
await memberDoc.create({
|
||||||
|
userId: creatorId,
|
||||||
|
createdTime: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
// create GroupContractDocs for each contractId
|
||||||
|
await Promise.all(
|
||||||
|
contracts
|
||||||
|
.map((c) => c.id)
|
||||||
|
.map((contractId) =>
|
||||||
|
groupRef.collection('groupContracts').doc(contractId).create({
|
||||||
|
contractId,
|
||||||
|
createdTime: now,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for (const market of contracts) {
|
||||||
|
if (market.groupLinks?.map((l) => l.groupId).includes(group.id)) continue // already in that group
|
||||||
|
|
||||||
|
const newGroupLinks = [
|
||||||
|
...(market.groupLinks ?? []),
|
||||||
|
{
|
||||||
|
groupId: group.id,
|
||||||
|
createdTime: Date.now(),
|
||||||
|
slug: group.slug,
|
||||||
|
name: group.name,
|
||||||
|
} as GroupLink,
|
||||||
|
]
|
||||||
|
await firestore
|
||||||
|
.collection('contracts')
|
||||||
|
.doc(market.id)
|
||||||
|
.update({
|
||||||
|
groupSlugs: uniq([...(market.groupSlugs ?? []), group.slug]),
|
||||||
|
groupLinks: newGroupLinks,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return { status: 'success', group: group }
|
||||||
}
|
}
|
||||||
|
|
||||||
const convertTagToGroup = async (tag: string, groupName: string) => {
|
const convertTagToGroup = async (tag: string, groupName: string) => {
|
||||||
log(`Looking up contract IDs with tag ${tag}...`)
|
log(`Looking up contract IDs with tag ${tag}...`)
|
||||||
const contractIds = await getTaggedContractIds(tag)
|
const contracts = await getTaggedContracts(tag)
|
||||||
log(`${contractIds.length} contracts found.`)
|
log(`${contracts.length} contracts found.`)
|
||||||
if (contractIds.length > 0) {
|
if (contracts.length > 0) {
|
||||||
log(`Creating group ${groupName}...`)
|
log(`Creating group ${groupName}...`)
|
||||||
const about = `Contracts that used to be tagged ${tag}.`
|
const about = `Contracts that used to be tagged ${tag}.`
|
||||||
const result = await createGroup(groupName, about, contractIds)
|
const result = await createGroup(groupName, about, contracts)
|
||||||
log(`Done. Group: `, result)
|
log(`Done. Group: `, result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
109
functions/src/scripts/update-groups.ts
Normal file
109
functions/src/scripts/update-groups.ts
Normal file
|
@ -0,0 +1,109 @@
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
initAdmin()
|
||||||
|
// convertGroupFieldsToGroupDocuments()
|
||||||
|
updateTotalContractsAndMembers()
|
||||||
|
}
|
|
@ -26,7 +26,6 @@ import { resolvemarket } from './resolve-market'
|
||||||
import { unsubscribe } from './unsubscribe'
|
import { unsubscribe } from './unsubscribe'
|
||||||
import { stripewebhook, createcheckoutsession } from './stripe'
|
import { stripewebhook, createcheckoutsession } from './stripe'
|
||||||
import { getcurrentuser } from './get-current-user'
|
import { getcurrentuser } from './get-current-user'
|
||||||
import { getcustomtoken } from './get-custom-token'
|
|
||||||
import { createpost } from './create-post'
|
import { createpost } from './create-post'
|
||||||
|
|
||||||
type Middleware = (req: Request, res: Response, next: NextFunction) => void
|
type Middleware = (req: Request, res: Response, next: NextFunction) => void
|
||||||
|
@ -66,7 +65,6 @@ addJsonEndpointRoute('/resolvemarket', resolvemarket)
|
||||||
addJsonEndpointRoute('/unsubscribe', unsubscribe)
|
addJsonEndpointRoute('/unsubscribe', unsubscribe)
|
||||||
addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession)
|
addJsonEndpointRoute('/createcheckoutsession', createcheckoutsession)
|
||||||
addJsonEndpointRoute('/getcurrentuser', getcurrentuser)
|
addJsonEndpointRoute('/getcurrentuser', getcurrentuser)
|
||||||
addEndpointRoute('/getcustomtoken', getcustomtoken)
|
|
||||||
addEndpointRoute('/stripewebhook', stripewebhook, express.raw())
|
addEndpointRoute('/stripewebhook', stripewebhook, express.raw())
|
||||||
addEndpointRoute('/createpost', createpost)
|
addEndpointRoute('/createpost', createpost)
|
||||||
|
|
||||||
|
|
|
@ -1,43 +1,27 @@
|
||||||
import * as functions from 'firebase-functions'
|
import * as functions from 'firebase-functions'
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import { groupBy, isEmpty, keyBy, sum, sumBy } from 'lodash'
|
import { groupBy, isEmpty, keyBy, last } from 'lodash'
|
||||||
import { getValues, log, logMemory, writeAsync } from './utils'
|
import { getValues, log, logMemory, writeAsync } from './utils'
|
||||||
import { Bet } from '../../common/bet'
|
import { Bet } from '../../common/bet'
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
import { PortfolioMetrics, User } from '../../common/user'
|
import { PortfolioMetrics, User } from '../../common/user'
|
||||||
import { calculatePayout } from '../../common/calculate'
|
|
||||||
import { DAY_MS } from '../../common/util/time'
|
import { DAY_MS } from '../../common/util/time'
|
||||||
import { last } from 'lodash'
|
|
||||||
import { getLoanUpdates } from '../../common/loans'
|
import { getLoanUpdates } from '../../common/loans'
|
||||||
|
import {
|
||||||
|
calculateCreatorVolume,
|
||||||
|
calculateNewPortfolioMetrics,
|
||||||
|
calculateNewProfit,
|
||||||
|
computeVolume,
|
||||||
|
} from '../../common/calculate-metrics'
|
||||||
|
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
const computeInvestmentValue = (
|
export const updateMetrics = functions
|
||||||
bets: Bet[],
|
.runWith({ memory: '2GB', timeoutSeconds: 540 })
|
||||||
contractsDict: { [k: string]: Contract }
|
.pubsub.schedule('every 15 minutes')
|
||||||
) => {
|
.onRun(updateMetricsCore)
|
||||||
return sumBy(bets, (bet) => {
|
|
||||||
const contract = contractsDict[bet.contractId]
|
|
||||||
if (!contract || contract.isResolved) return 0
|
|
||||||
if (bet.sale || bet.isSold) return 0
|
|
||||||
|
|
||||||
const payout = calculatePayout(contract, bet, 'MKT')
|
export async function updateMetricsCore() {
|
||||||
const value = payout - (bet.loanAmount ?? 0)
|
|
||||||
if (isNaN(value)) return 0
|
|
||||||
return value
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const computeTotalPool = (userContracts: Contract[], startTime = 0) => {
|
|
||||||
const periodFilteredContracts = userContracts.filter(
|
|
||||||
(contract) => contract.createdTime >= startTime
|
|
||||||
)
|
|
||||||
return sum(
|
|
||||||
periodFilteredContracts.map((contract) => sum(Object.values(contract.pool)))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const updateMetricsCore = async () => {
|
|
||||||
const [users, contracts, bets, allPortfolioHistories] = await Promise.all([
|
const [users, contracts, bets, allPortfolioHistories] = await Promise.all([
|
||||||
getValues<User>(firestore.collection('users')),
|
getValues<User>(firestore.collection('users')),
|
||||||
getValues<Contract>(firestore.collection('contracts')),
|
getValues<Contract>(firestore.collection('contracts')),
|
||||||
|
@ -88,23 +72,20 @@ export const updateMetricsCore = async () => {
|
||||||
currentBets
|
currentBets
|
||||||
)
|
)
|
||||||
const lastPortfolio = last(portfolioHistory)
|
const lastPortfolio = last(portfolioHistory)
|
||||||
const didProfitChange =
|
const didPortfolioChange =
|
||||||
lastPortfolio === undefined ||
|
lastPortfolio === undefined ||
|
||||||
lastPortfolio.balance !== newPortfolio.balance ||
|
lastPortfolio.balance !== newPortfolio.balance ||
|
||||||
lastPortfolio.totalDeposits !== newPortfolio.totalDeposits ||
|
lastPortfolio.totalDeposits !== newPortfolio.totalDeposits ||
|
||||||
lastPortfolio.investmentValue !== newPortfolio.investmentValue
|
lastPortfolio.investmentValue !== newPortfolio.investmentValue
|
||||||
|
|
||||||
const newProfit = calculateNewProfit(
|
const newProfit = calculateNewProfit(portfolioHistory, newPortfolio)
|
||||||
portfolioHistory,
|
|
||||||
newPortfolio,
|
|
||||||
didProfitChange
|
|
||||||
)
|
|
||||||
return {
|
return {
|
||||||
user,
|
user,
|
||||||
newCreatorVolume,
|
newCreatorVolume,
|
||||||
newPortfolio,
|
newPortfolio,
|
||||||
newProfit,
|
newProfit,
|
||||||
didProfitChange,
|
didPortfolioChange,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -120,16 +101,20 @@ export const updateMetricsCore = async () => {
|
||||||
const nextLoanByUser = keyBy(userPayouts, (payout) => payout.user.id)
|
const nextLoanByUser = keyBy(userPayouts, (payout) => payout.user.id)
|
||||||
|
|
||||||
const userUpdates = userMetrics.map(
|
const userUpdates = userMetrics.map(
|
||||||
({ user, newCreatorVolume, newPortfolio, newProfit, didProfitChange }) => {
|
({
|
||||||
|
user,
|
||||||
|
newCreatorVolume,
|
||||||
|
newPortfolio,
|
||||||
|
newProfit,
|
||||||
|
didPortfolioChange,
|
||||||
|
}) => {
|
||||||
const nextLoanCached = nextLoanByUser[user.id]?.payout ?? 0
|
const nextLoanCached = nextLoanByUser[user.id]?.payout ?? 0
|
||||||
return {
|
return {
|
||||||
fieldUpdates: {
|
fieldUpdates: {
|
||||||
doc: firestore.collection('users').doc(user.id),
|
doc: firestore.collection('users').doc(user.id),
|
||||||
fields: {
|
fields: {
|
||||||
creatorVolumeCached: newCreatorVolume,
|
creatorVolumeCached: newCreatorVolume,
|
||||||
...(didProfitChange && {
|
|
||||||
profitCached: newProfit,
|
profitCached: newProfit,
|
||||||
}),
|
|
||||||
nextLoanCached,
|
nextLoanCached,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -140,11 +125,7 @@ export const updateMetricsCore = async () => {
|
||||||
.doc(user.id)
|
.doc(user.id)
|
||||||
.collection('portfolioHistory')
|
.collection('portfolioHistory')
|
||||||
.doc(),
|
.doc(),
|
||||||
fields: {
|
fields: didPortfolioChange ? newPortfolio : {},
|
||||||
...(didProfitChange && {
|
|
||||||
...newPortfolio,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -162,108 +143,3 @@ export const updateMetricsCore = async () => {
|
||||||
)
|
)
|
||||||
log(`Updated metrics for ${users.length} users.`)
|
log(`Updated metrics for ${users.length} users.`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const computeVolume = (contractBets: Bet[], since: number) => {
|
|
||||||
return sumBy(contractBets, (b) =>
|
|
||||||
b.createdTime > since && !b.isRedemption ? Math.abs(b.amount) : 0
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const calculateProfitForPeriod = (
|
|
||||||
startTime: number,
|
|
||||||
portfolioHistory: PortfolioMetrics[],
|
|
||||||
currentProfit: number
|
|
||||||
) => {
|
|
||||||
const startingPortfolio = [...portfolioHistory]
|
|
||||||
.reverse() // so we search in descending order (most recent first), for efficiency
|
|
||||||
.find((p) => p.timestamp < startTime)
|
|
||||||
|
|
||||||
if (startingPortfolio === undefined) {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const startingProfit = calculateTotalProfit(startingPortfolio)
|
|
||||||
|
|
||||||
return currentProfit - startingProfit
|
|
||||||
}
|
|
||||||
|
|
||||||
const calculateTotalProfit = (portfolio: PortfolioMetrics) => {
|
|
||||||
return portfolio.investmentValue + portfolio.balance - portfolio.totalDeposits
|
|
||||||
}
|
|
||||||
|
|
||||||
const calculateCreatorVolume = (userContracts: Contract[]) => {
|
|
||||||
const allTimeCreatorVolume = computeTotalPool(userContracts, 0)
|
|
||||||
const monthlyCreatorVolume = computeTotalPool(
|
|
||||||
userContracts,
|
|
||||||
Date.now() - 30 * DAY_MS
|
|
||||||
)
|
|
||||||
const weeklyCreatorVolume = computeTotalPool(
|
|
||||||
userContracts,
|
|
||||||
Date.now() - 7 * DAY_MS
|
|
||||||
)
|
|
||||||
|
|
||||||
const dailyCreatorVolume = computeTotalPool(
|
|
||||||
userContracts,
|
|
||||||
Date.now() - 1 * DAY_MS
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
daily: dailyCreatorVolume,
|
|
||||||
weekly: weeklyCreatorVolume,
|
|
||||||
monthly: monthlyCreatorVolume,
|
|
||||||
allTime: allTimeCreatorVolume,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const calculateNewPortfolioMetrics = (
|
|
||||||
user: User,
|
|
||||||
contractsById: { [k: string]: Contract },
|
|
||||||
currentBets: Bet[]
|
|
||||||
) => {
|
|
||||||
const investmentValue = computeInvestmentValue(currentBets, contractsById)
|
|
||||||
const newPortfolio = {
|
|
||||||
investmentValue: investmentValue,
|
|
||||||
balance: user.balance,
|
|
||||||
totalDeposits: user.totalDeposits,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
userId: user.id,
|
|
||||||
}
|
|
||||||
return newPortfolio
|
|
||||||
}
|
|
||||||
|
|
||||||
const calculateNewProfit = (
|
|
||||||
portfolioHistory: PortfolioMetrics[],
|
|
||||||
newPortfolio: PortfolioMetrics,
|
|
||||||
didProfitChange: boolean
|
|
||||||
) => {
|
|
||||||
if (!didProfitChange) {
|
|
||||||
return {} // early return for performance
|
|
||||||
}
|
|
||||||
|
|
||||||
const allTimeProfit = calculateTotalProfit(newPortfolio)
|
|
||||||
const newProfit = {
|
|
||||||
daily: calculateProfitForPeriod(
|
|
||||||
Date.now() - 1 * DAY_MS,
|
|
||||||
portfolioHistory,
|
|
||||||
allTimeProfit
|
|
||||||
),
|
|
||||||
weekly: calculateProfitForPeriod(
|
|
||||||
Date.now() - 7 * DAY_MS,
|
|
||||||
portfolioHistory,
|
|
||||||
allTimeProfit
|
|
||||||
),
|
|
||||||
monthly: calculateProfitForPeriod(
|
|
||||||
Date.now() - 30 * DAY_MS,
|
|
||||||
portfolioHistory,
|
|
||||||
allTimeProfit
|
|
||||||
),
|
|
||||||
allTime: allTimeProfit,
|
|
||||||
}
|
|
||||||
|
|
||||||
return newProfit
|
|
||||||
}
|
|
||||||
|
|
||||||
export const updateMetrics = functions
|
|
||||||
.runWith({ memory: '2GB', timeoutSeconds: 540 })
|
|
||||||
.pubsub.schedule('every 15 minutes')
|
|
||||||
.onRun(updateMetricsCore)
|
|
||||||
|
|
|
@ -2,10 +2,18 @@ import * as functions from 'firebase-functions'
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
|
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
import { getAllPrivateUsers, getUser, getValues, log } from './utils'
|
import {
|
||||||
|
getAllPrivateUsers,
|
||||||
|
getPrivateUser,
|
||||||
|
getUser,
|
||||||
|
getValues,
|
||||||
|
isProd,
|
||||||
|
log,
|
||||||
|
} from './utils'
|
||||||
import { sendInterestingMarketsEmail } from './emails'
|
import { sendInterestingMarketsEmail } from './emails'
|
||||||
import { createRNG, shuffle } from '../../common/util/random'
|
import { createRNG, shuffle } from '../../common/util/random'
|
||||||
import { DAY_MS } from '../../common/util/time'
|
import { DAY_MS } from '../../common/util/time'
|
||||||
|
import { filterDefined } from '../../common/util/array'
|
||||||
|
|
||||||
export const weeklyMarketsEmails = functions
|
export const weeklyMarketsEmails = functions
|
||||||
.runWith({ secrets: ['MAILGUN_KEY'] })
|
.runWith({ secrets: ['MAILGUN_KEY'] })
|
||||||
|
@ -34,7 +42,9 @@ export async function getTrendingContracts() {
|
||||||
|
|
||||||
async function sendTrendingMarketsEmailsToAllUsers() {
|
async function sendTrendingMarketsEmailsToAllUsers() {
|
||||||
const numContractsToSend = 6
|
const numContractsToSend = 6
|
||||||
const privateUsers = await getAllPrivateUsers()
|
const privateUsers = isProd()
|
||||||
|
? await getAllPrivateUsers()
|
||||||
|
: filterDefined([await getPrivateUser('6hHpzvRG0pMq8PNJs7RZj2qlZGn2')])
|
||||||
// get all users that haven't unsubscribed from weekly emails
|
// get all users that haven't unsubscribed from weekly emails
|
||||||
const privateUsersToSendEmailsTo = privateUsers.filter((user) => {
|
const privateUsersToSendEmailsTo = privateUsers.filter((user) => {
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -1,85 +1,5 @@
|
||||||
import { sanitizeHtml } from './sanitizer'
|
|
||||||
import { ParsedRequest } from './types'
|
import { ParsedRequest } from './types'
|
||||||
|
import { getTemplateCss } from './template-css'
|
||||||
function getCss(theme: string, fontSize: string) {
|
|
||||||
let background = 'white'
|
|
||||||
let foreground = 'black'
|
|
||||||
let radial = 'lightgray'
|
|
||||||
|
|
||||||
if (theme === 'dark') {
|
|
||||||
background = 'black'
|
|
||||||
foreground = 'white'
|
|
||||||
radial = 'dimgray'
|
|
||||||
}
|
|
||||||
// To use Readex Pro: `font-family: 'Readex Pro', sans-serif;`
|
|
||||||
return `
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Major+Mono+Display&family=Readex+Pro:wght@400;700&display=swap');
|
|
||||||
|
|
||||||
body {
|
|
||||||
background: ${background};
|
|
||||||
background-image: radial-gradient(circle at 25px 25px, ${radial} 2%, transparent 0%), radial-gradient(circle at 75px 75px, ${radial} 2%, transparent 0%);
|
|
||||||
background-size: 100px 100px;
|
|
||||||
height: 100vh;
|
|
||||||
font-family: "Readex Pro", sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
code {
|
|
||||||
color: #D400FF;
|
|
||||||
font-family: 'Vera';
|
|
||||||
white-space: pre-wrap;
|
|
||||||
letter-spacing: -5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
code:before, code:after {
|
|
||||||
content: '\`';
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-wrapper {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
align-content: center;
|
|
||||||
justify-content: center;
|
|
||||||
justify-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
margin: 0 75px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plus {
|
|
||||||
color: #BBB;
|
|
||||||
font-family: Times New Roman, Verdana;
|
|
||||||
font-size: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spacer {
|
|
||||||
margin: 150px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emoji {
|
|
||||||
height: 1em;
|
|
||||||
width: 1em;
|
|
||||||
margin: 0 .05em 0 .1em;
|
|
||||||
vertical-align: -0.1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.heading {
|
|
||||||
font-family: 'Major Mono Display', monospace;
|
|
||||||
font-size: ${sanitizeHtml(fontSize)};
|
|
||||||
font-style: normal;
|
|
||||||
color: ${foreground};
|
|
||||||
line-height: 1.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.font-major-mono {
|
|
||||||
font-family: "Major Mono Display", monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-primary {
|
|
||||||
color: #11b981;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getChallengeHtml(parsedReq: ParsedRequest) {
|
export function getChallengeHtml(parsedReq: ParsedRequest) {
|
||||||
const {
|
const {
|
||||||
|
@ -112,7 +32,7 @@ export function getChallengeHtml(parsedReq: ParsedRequest) {
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
</head>
|
</head>
|
||||||
<style>
|
<style>
|
||||||
${getCss(theme, fontSize)}
|
${getTemplateCss(theme, fontSize)}
|
||||||
</style>
|
</style>
|
||||||
<body>
|
<body>
|
||||||
<div class="px-24">
|
<div class="px-24">
|
||||||
|
|
|
@ -21,6 +21,7 @@ export function parseRequest(req: IncomingMessage) {
|
||||||
creatorName,
|
creatorName,
|
||||||
creatorUsername,
|
creatorUsername,
|
||||||
creatorAvatarUrl,
|
creatorAvatarUrl,
|
||||||
|
resolution,
|
||||||
|
|
||||||
// Challenge attributes:
|
// Challenge attributes:
|
||||||
challengerAmount,
|
challengerAmount,
|
||||||
|
@ -71,6 +72,7 @@ export function parseRequest(req: IncomingMessage) {
|
||||||
|
|
||||||
question:
|
question:
|
||||||
getString(question) || 'Will you create a prediction market on Manifold?',
|
getString(question) || 'Will you create a prediction market on Manifold?',
|
||||||
|
resolution: getString(resolution),
|
||||||
probability: getString(probability),
|
probability: getString(probability),
|
||||||
numericValue: getString(numericValue) || '',
|
numericValue: getString(numericValue) || '',
|
||||||
metadata: getString(metadata) || 'Jan 1 • M$ 123 pool',
|
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'
|
import { ParsedRequest } from './types'
|
||||||
|
import { getTemplateCss } from './template-css'
|
||||||
function getCss(theme: string, fontSize: string) {
|
|
||||||
let background = 'white'
|
|
||||||
let foreground = 'black'
|
|
||||||
let radial = 'lightgray'
|
|
||||||
|
|
||||||
if (theme === 'dark') {
|
|
||||||
background = 'black'
|
|
||||||
foreground = 'white'
|
|
||||||
radial = 'dimgray'
|
|
||||||
}
|
|
||||||
// To use Readex Pro: `font-family: 'Readex Pro', sans-serif;`
|
|
||||||
return `
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Major+Mono+Display&family=Readex+Pro:wght@400;700&display=swap');
|
|
||||||
|
|
||||||
body {
|
|
||||||
background: ${background};
|
|
||||||
background-image: radial-gradient(circle at 25px 25px, ${radial} 2%, transparent 0%), radial-gradient(circle at 75px 75px, ${radial} 2%, transparent 0%);
|
|
||||||
background-size: 100px 100px;
|
|
||||||
height: 100vh;
|
|
||||||
font-family: "Readex Pro", sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
code {
|
|
||||||
color: #D400FF;
|
|
||||||
font-family: 'Vera';
|
|
||||||
white-space: pre-wrap;
|
|
||||||
letter-spacing: -5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
code:before, code:after {
|
|
||||||
content: '\`';
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-wrapper {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
align-content: center;
|
|
||||||
justify-content: center;
|
|
||||||
justify-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
margin: 0 75px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plus {
|
|
||||||
color: #BBB;
|
|
||||||
font-family: Times New Roman, Verdana;
|
|
||||||
font-size: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spacer {
|
|
||||||
margin: 150px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emoji {
|
|
||||||
height: 1em;
|
|
||||||
width: 1em;
|
|
||||||
margin: 0 .05em 0 .1em;
|
|
||||||
vertical-align: -0.1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.heading {
|
|
||||||
font-family: 'Major Mono Display', monospace;
|
|
||||||
font-size: ${sanitizeHtml(fontSize)};
|
|
||||||
font-style: normal;
|
|
||||||
color: ${foreground};
|
|
||||||
line-height: 1.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.font-major-mono {
|
|
||||||
font-family: "Major Mono Display", monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-primary {
|
|
||||||
color: #11b981;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getHtml(parsedReq: ParsedRequest) {
|
export function getHtml(parsedReq: ParsedRequest) {
|
||||||
const {
|
const {
|
||||||
|
@ -92,6 +12,7 @@ export function getHtml(parsedReq: ParsedRequest) {
|
||||||
creatorUsername,
|
creatorUsername,
|
||||||
creatorAvatarUrl,
|
creatorAvatarUrl,
|
||||||
numericValue,
|
numericValue,
|
||||||
|
resolution,
|
||||||
} = parsedReq
|
} = parsedReq
|
||||||
const MAX_QUESTION_CHARS = 100
|
const MAX_QUESTION_CHARS = 100
|
||||||
const truncatedQuestion =
|
const truncatedQuestion =
|
||||||
|
@ -99,6 +20,49 @@ export function getHtml(parsedReq: ParsedRequest) {
|
||||||
? question.slice(0, MAX_QUESTION_CHARS) + '...'
|
? question.slice(0, MAX_QUESTION_CHARS) + '...'
|
||||||
: question
|
: question
|
||||||
const hideAvatar = creatorAvatarUrl ? '' : 'hidden'
|
const hideAvatar = creatorAvatarUrl ? '' : 'hidden'
|
||||||
|
|
||||||
|
let resolutionColor = 'text-primary'
|
||||||
|
let resolutionString = 'YES'
|
||||||
|
switch (resolution) {
|
||||||
|
case 'YES':
|
||||||
|
break
|
||||||
|
case 'NO':
|
||||||
|
resolutionColor = 'text-red-500'
|
||||||
|
resolutionString = 'NO'
|
||||||
|
break
|
||||||
|
case 'CANCEL':
|
||||||
|
resolutionColor = 'text-yellow-500'
|
||||||
|
resolutionString = 'N/A'
|
||||||
|
break
|
||||||
|
case 'MKT':
|
||||||
|
resolutionColor = 'text-blue-500'
|
||||||
|
resolutionString = numericValue ? numericValue : probability
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolutionDiv = `
|
||||||
|
<span class='text-center ${resolutionColor}'>
|
||||||
|
<div class="text-8xl">
|
||||||
|
${resolutionString}
|
||||||
|
</div>
|
||||||
|
<div class="text-4xl">${
|
||||||
|
resolution === 'CANCEL' ? '' : 'resolved'
|
||||||
|
}</div>
|
||||||
|
</span>`
|
||||||
|
|
||||||
|
const probabilityDiv = `
|
||||||
|
<span class='text-primary text-center'>
|
||||||
|
<div class="text-8xl">${probability}</div>
|
||||||
|
<div class="text-4xl">chance</div>
|
||||||
|
</span>`
|
||||||
|
|
||||||
|
const numericValueDiv = `
|
||||||
|
<span class='text-blue-500 text-center'>
|
||||||
|
<div class="text-8xl ">${numericValue}</div>
|
||||||
|
<div class="text-4xl">expected</div>
|
||||||
|
</span>
|
||||||
|
`
|
||||||
|
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
|
@ -108,7 +72,7 @@ export function getHtml(parsedReq: ParsedRequest) {
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
</head>
|
</head>
|
||||||
<style>
|
<style>
|
||||||
${getCss(theme, fontSize)}
|
${getTemplateCss(theme, fontSize)}
|
||||||
</style>
|
</style>
|
||||||
<body>
|
<body>
|
||||||
<div class="px-24">
|
<div class="px-24">
|
||||||
|
@ -148,21 +112,20 @@ export function getHtml(parsedReq: ParsedRequest) {
|
||||||
<div class="text-indigo-700 text-6xl leading-tight">
|
<div class="text-indigo-700 text-6xl leading-tight">
|
||||||
${truncatedQuestion}
|
${truncatedQuestion}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col text-primary">
|
<div class="flex flex-col">
|
||||||
<div class="text-8xl">${probability}</div>
|
${
|
||||||
<div class="text-4xl">${probability !== '' ? 'chance' : ''}</div>
|
resolution
|
||||||
<span class='text-blue-500 text-center'>
|
? resolutionDiv
|
||||||
<div class="text-8xl ">${
|
: numericValue
|
||||||
numericValue !== '' && probability === '' ? numericValue : ''
|
? numericValueDiv
|
||||||
}</div>
|
: probabilityDiv
|
||||||
<div class="text-4xl">${numericValue !== '' ? 'expected' : ''}</div>
|
}
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Metadata -->
|
<!-- Metadata -->
|
||||||
<div class="absolute bottom-16">
|
<div class="absolute bottom-16">
|
||||||
<div class="text-gray-500 text-3xl">
|
<div class="text-gray-500 text-3xl max-w-[80vw]">
|
||||||
${metadata}
|
${metadata}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -19,6 +19,7 @@ export interface ParsedRequest {
|
||||||
creatorName: string
|
creatorName: string
|
||||||
creatorUsername: string
|
creatorUsername: string
|
||||||
creatorAvatarUrl: string
|
creatorAvatarUrl: string
|
||||||
|
resolution: string
|
||||||
// Challenge attributes:
|
// Challenge attributes:
|
||||||
challengerAmount: string
|
challengerAmount: string
|
||||||
challengerOutcome: string
|
challengerOutcome: string
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
import { XIcon } from '@heroicons/react/solid'
|
import { XIcon } from '@heroicons/react/solid'
|
||||||
|
|
||||||
import { Answer } from 'common/answer'
|
import { Answer } from 'common/answer'
|
||||||
|
@ -132,7 +132,10 @@ export function AnswerBetPanel(props: {
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
<div className="my-3 text-left text-sm text-gray-500">Amount </div>
|
<Row className="my-3 justify-between text-left text-sm text-gray-500">
|
||||||
|
Amount
|
||||||
|
<span>(balance: {formatMoney(user?.balance ?? 0)})</span>
|
||||||
|
</Row>
|
||||||
<BuyAmountInput
|
<BuyAmountInput
|
||||||
inputClassName="w-full max-w-none"
|
inputClassName="w-full max-w-none"
|
||||||
amount={betAmount}
|
amount={betAmount}
|
||||||
|
|
|
@ -18,19 +18,20 @@ export const AnswersGraph = memo(function AnswersGraph(props: {
|
||||||
}) {
|
}) {
|
||||||
const { contract, bets, height } = props
|
const { contract, bets, height } = props
|
||||||
const { createdTime, resolutionTime, closeTime, answers } = contract
|
const { createdTime, resolutionTime, closeTime, answers } = contract
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
const { probsByOutcome, sortedOutcomes } = computeProbsByOutcome(
|
const { probsByOutcome, sortedOutcomes } = computeProbsByOutcome(
|
||||||
bets,
|
bets,
|
||||||
contract
|
contract
|
||||||
)
|
)
|
||||||
|
|
||||||
const isClosed = !!closeTime && Date.now() > closeTime
|
const isClosed = !!closeTime && now > closeTime
|
||||||
const latestTime = dayjs(
|
const latestTime = dayjs(
|
||||||
resolutionTime && isClosed
|
resolutionTime && isClosed
|
||||||
? Math.min(resolutionTime, closeTime)
|
? Math.min(resolutionTime, closeTime)
|
||||||
: isClosed
|
: isClosed
|
||||||
? closeTime
|
? closeTime
|
||||||
: resolutionTime ?? Date.now()
|
: resolutionTime ?? now
|
||||||
)
|
)
|
||||||
|
|
||||||
const { width } = useWindowSize()
|
const { width } = useWindowSize()
|
||||||
|
@ -71,14 +72,14 @@ export const AnswersGraph = memo(function AnswersGraph(props: {
|
||||||
const yTickValues = [0, 25, 50, 75, 100]
|
const yTickValues = [0, 25, 50, 75, 100]
|
||||||
|
|
||||||
const numXTickValues = isLargeWidth ? 5 : 2
|
const numXTickValues = isLargeWidth ? 5 : 2
|
||||||
const startDate = new Date(contract.createdTime)
|
const startDate = dayjs(contract.createdTime)
|
||||||
const endDate = dayjs(startDate).add(1, 'hour').isAfter(latestTime)
|
const endDate = startDate.add(1, 'hour').isAfter(latestTime)
|
||||||
? latestTime.add(1, 'hours').toDate()
|
? latestTime.add(1, 'hours')
|
||||||
: latestTime.toDate()
|
: latestTime
|
||||||
const includeMinute = dayjs(endDate).diff(startDate, 'hours') < 2
|
const includeMinute = endDate.diff(startDate, 'hours') < 2
|
||||||
|
|
||||||
const multiYear = !dayjs(startDate).isSame(latestTime, 'year')
|
const multiYear = !startDate.isSame(latestTime, 'year')
|
||||||
const lessThanAWeek = dayjs(startDate).add(1, 'week').isAfter(latestTime)
|
const lessThanAWeek = startDate.add(1, 'week').isAfter(latestTime)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
@ -96,16 +97,16 @@ export const AnswersGraph = memo(function AnswersGraph(props: {
|
||||||
}}
|
}}
|
||||||
xScale={{
|
xScale={{
|
||||||
type: 'time',
|
type: 'time',
|
||||||
min: startDate,
|
min: startDate.toDate(),
|
||||||
max: endDate,
|
max: endDate.toDate(),
|
||||||
}}
|
}}
|
||||||
xFormat={(d) =>
|
xFormat={(d) =>
|
||||||
formatTime(+d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek)
|
formatTime(now, +d.valueOf(), multiYear, lessThanAWeek, lessThanAWeek)
|
||||||
}
|
}
|
||||||
axisBottom={{
|
axisBottom={{
|
||||||
tickValues: numXTickValues,
|
tickValues: numXTickValues,
|
||||||
format: (time) =>
|
format: (time) =>
|
||||||
formatTime(+time, multiYear, lessThanAWeek, includeMinute),
|
formatTime(now, +time, multiYear, lessThanAWeek, includeMinute),
|
||||||
}}
|
}}
|
||||||
colors={[
|
colors={[
|
||||||
'#fca5a5', // red-300
|
'#fca5a5', // red-300
|
||||||
|
@ -158,23 +159,20 @@ function formatPercent(y: DatumValue) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(
|
function formatTime(
|
||||||
|
now: number,
|
||||||
time: number,
|
time: number,
|
||||||
includeYear: boolean,
|
includeYear: boolean,
|
||||||
includeHour: boolean,
|
includeHour: boolean,
|
||||||
includeMinute: boolean
|
includeMinute: boolean
|
||||||
) {
|
) {
|
||||||
const d = dayjs(time)
|
const d = dayjs(time)
|
||||||
|
if (d.add(1, 'minute').isAfter(now) && d.subtract(1, 'minute').isBefore(now))
|
||||||
if (
|
|
||||||
d.add(1, 'minute').isAfter(Date.now()) &&
|
|
||||||
d.subtract(1, 'minute').isBefore(Date.now())
|
|
||||||
)
|
|
||||||
return 'Now'
|
return 'Now'
|
||||||
|
|
||||||
let format: string
|
let format: string
|
||||||
if (d.isSame(Date.now(), 'day')) {
|
if (d.isSame(now, 'day')) {
|
||||||
format = '[Today]'
|
format = '[Today]'
|
||||||
} else if (d.add(1, 'day').isSame(Date.now(), 'day')) {
|
} else if (d.add(1, 'day').isSame(now, 'day')) {
|
||||||
format = '[Yesterday]'
|
format = '[Yesterday]'
|
||||||
} else {
|
} else {
|
||||||
format = 'MMM D'
|
format = 'MMM D'
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import Textarea from 'react-expanding-textarea'
|
import Textarea from 'react-expanding-textarea'
|
||||||
import { findBestMatch } from 'string-similarity'
|
import { findBestMatch } from 'string-similarity'
|
||||||
|
|
||||||
|
@ -149,7 +149,12 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
|
||||||
{text && (
|
{text && (
|
||||||
<>
|
<>
|
||||||
<Col className="mt-1 gap-2">
|
<Col className="mt-1 gap-2">
|
||||||
<div className="text-sm text-gray-500">Bet amount</div>
|
<Row className="my-3 justify-between text-left text-sm text-gray-500">
|
||||||
|
Bet Amount
|
||||||
|
<span className={'sm:hidden'}>
|
||||||
|
(balance: {formatMoney(user?.balance ?? 0)})
|
||||||
|
</span>
|
||||||
|
</Row>{' '}
|
||||||
<BuyAmountInput
|
<BuyAmountInput
|
||||||
amount={betAmount}
|
amount={betAmount}
|
||||||
onChange={setBetAmount}
|
onChange={setBetAmount}
|
||||||
|
|
142
web/components/arrange-home.tsx
Normal file
142
web/components/arrange-home.tsx
Normal file
|
@ -0,0 +1,142 @@
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd'
|
||||||
|
import { MenuIcon } from '@heroicons/react/solid'
|
||||||
|
|
||||||
|
import { Col } from 'web/components/layout/col'
|
||||||
|
import { Row } from 'web/components/layout/row'
|
||||||
|
import { Subtitle } from 'web/components/subtitle'
|
||||||
|
import { useMemberGroups } from 'web/hooks/use-group'
|
||||||
|
import { filterDefined } from 'common/util/array'
|
||||||
|
import { keyBy } from 'lodash'
|
||||||
|
import { User } from 'common/user'
|
||||||
|
import { Group } from 'common/group'
|
||||||
|
|
||||||
|
export function ArrangeHome(props: {
|
||||||
|
user: User | null
|
||||||
|
homeSections: { visible: string[]; hidden: string[] }
|
||||||
|
setHomeSections: (homeSections: {
|
||||||
|
visible: string[]
|
||||||
|
hidden: string[]
|
||||||
|
}) => void
|
||||||
|
}) {
|
||||||
|
const { user, homeSections, setHomeSections } = props
|
||||||
|
|
||||||
|
const groups = useMemberGroups(user?.id) ?? []
|
||||||
|
const { itemsById, visibleItems, hiddenItems } = getHomeItems(
|
||||||
|
groups,
|
||||||
|
homeSections
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DragDropContext
|
||||||
|
onDragEnd={(e) => {
|
||||||
|
console.log('drag end', e)
|
||||||
|
const { destination, source, draggableId } = e
|
||||||
|
if (!destination) return
|
||||||
|
|
||||||
|
const item = itemsById[draggableId]
|
||||||
|
|
||||||
|
const newHomeSections = {
|
||||||
|
visible: visibleItems.map((item) => item.id),
|
||||||
|
hidden: hiddenItems.map((item) => item.id),
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceSection = source.droppableId as 'visible' | 'hidden'
|
||||||
|
newHomeSections[sourceSection].splice(source.index, 1)
|
||||||
|
|
||||||
|
const destSection = destination.droppableId as 'visible' | 'hidden'
|
||||||
|
newHomeSections[destSection].splice(destination.index, 0, item.id)
|
||||||
|
|
||||||
|
setHomeSections(newHomeSections)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Row className="relative max-w-lg gap-4">
|
||||||
|
<DraggableList items={visibleItems} title="Visible" />
|
||||||
|
<DraggableList items={hiddenItems} title="Hidden" />
|
||||||
|
</Row>
|
||||||
|
</DragDropContext>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DraggableList(props: {
|
||||||
|
title: string
|
||||||
|
items: { id: string; label: string }[]
|
||||||
|
}) {
|
||||||
|
const { title, items } = props
|
||||||
|
return (
|
||||||
|
<Droppable droppableId={title.toLowerCase()}>
|
||||||
|
{(provided, snapshot) => (
|
||||||
|
<Col
|
||||||
|
{...provided.droppableProps}
|
||||||
|
ref={provided.innerRef}
|
||||||
|
className={clsx(
|
||||||
|
'width-[220px] flex-1 items-start rounded bg-gray-50 p-2',
|
||||||
|
snapshot.isDraggingOver && 'bg-gray-100'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Subtitle text={title} className="mx-2 !my-2" />
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<Draggable key={item.id} draggableId={item.id} index={index}>
|
||||||
|
{(provided, snapshot) => (
|
||||||
|
<div
|
||||||
|
ref={provided.innerRef}
|
||||||
|
{...provided.draggableProps}
|
||||||
|
{...provided.dragHandleProps}
|
||||||
|
style={provided.draggableProps.style}
|
||||||
|
className={clsx(
|
||||||
|
'flex flex-row items-center gap-4 rounded bg-gray-50 p-2',
|
||||||
|
snapshot.isDragging && 'z-[9000] bg-gray-300'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<MenuIcon
|
||||||
|
className="h-5 w-5 flex-shrink-0 text-gray-500"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>{' '}
|
||||||
|
{item.label}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Draggable>
|
||||||
|
))}
|
||||||
|
{provided.placeholder}
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
</Droppable>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getHomeItems = (
|
||||||
|
groups: Group[],
|
||||||
|
homeSections: { visible: string[]; hidden: string[] }
|
||||||
|
) => {
|
||||||
|
const items = [
|
||||||
|
{ label: 'Trending', id: 'score' },
|
||||||
|
{ label: 'Newest', id: 'newest' },
|
||||||
|
{ label: 'Close date', id: 'close-date' },
|
||||||
|
{ label: 'Your bets', id: 'your-bets' },
|
||||||
|
...groups.map((g) => ({
|
||||||
|
label: g.name,
|
||||||
|
id: g.id,
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
const itemsById = keyBy(items, 'id')
|
||||||
|
|
||||||
|
const { visible, hidden } = homeSections
|
||||||
|
|
||||||
|
const [visibleItems, hiddenItems] = [
|
||||||
|
filterDefined(visible.map((id) => itemsById[id])),
|
||||||
|
filterDefined(hidden.map((id) => itemsById[id])),
|
||||||
|
]
|
||||||
|
|
||||||
|
// Add unmentioned items to the visible list.
|
||||||
|
visibleItems.push(
|
||||||
|
...items.filter(
|
||||||
|
(item) => !visibleItems.includes(item) && !hiddenItems.includes(item)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
visibleItems,
|
||||||
|
hiddenItems,
|
||||||
|
itemsById,
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,17 +8,20 @@ import {
|
||||||
getUserAndPrivateUser,
|
getUserAndPrivateUser,
|
||||||
setCachedReferralInfoForUser,
|
setCachedReferralInfoForUser,
|
||||||
} from 'web/lib/firebase/users'
|
} from 'web/lib/firebase/users'
|
||||||
import { deleteTokenCookies, setTokenCookies } from 'web/lib/firebase/auth'
|
|
||||||
import { createUser } from 'web/lib/firebase/api'
|
import { createUser } from 'web/lib/firebase/api'
|
||||||
import { randomString } from 'common/util/random'
|
import { randomString } from 'common/util/random'
|
||||||
import { identifyUser, setUserProperty } from 'web/lib/service/analytics'
|
import { identifyUser, setUserProperty } from 'web/lib/service/analytics'
|
||||||
import { useStateCheckEquality } from 'web/hooks/use-state-check-equality'
|
import { useStateCheckEquality } from 'web/hooks/use-state-check-equality'
|
||||||
|
import { AUTH_COOKIE_NAME } from 'common/envs/constants'
|
||||||
|
import { setCookie } from 'web/lib/util/cookie'
|
||||||
|
|
||||||
// Either we haven't looked up the logged in user yet (undefined), or we know
|
// Either we haven't looked up the logged in user yet (undefined), or we know
|
||||||
// the user is not logged in (null), or we know the user is logged in.
|
// the user is not logged in (null), or we know the user is logged in.
|
||||||
type AuthUser = undefined | null | UserAndPrivateUser
|
type AuthUser = undefined | null | UserAndPrivateUser
|
||||||
|
|
||||||
|
const TEN_YEARS_SECS = 60 * 60 * 24 * 365 * 10
|
||||||
const CACHED_USER_KEY = 'CACHED_USER_KEY_V2'
|
const CACHED_USER_KEY = 'CACHED_USER_KEY_V2'
|
||||||
|
|
||||||
// Proxy localStorage in case it's not available (eg in incognito iframe)
|
// Proxy localStorage in case it's not available (eg in incognito iframe)
|
||||||
const localStorage =
|
const localStorage =
|
||||||
typeof window !== 'undefined'
|
typeof window !== 'undefined'
|
||||||
|
@ -38,6 +41,16 @@ const ensureDeviceToken = () => {
|
||||||
return deviceToken
|
return deviceToken
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const setUserCookie = (cookie: string | undefined) => {
|
||||||
|
const data = setCookie(AUTH_COOKIE_NAME, cookie ?? '', [
|
||||||
|
['path', '/'],
|
||||||
|
['max-age', (cookie === undefined ? 0 : TEN_YEARS_SECS).toString()],
|
||||||
|
['samesite', 'lax'],
|
||||||
|
['secure'],
|
||||||
|
])
|
||||||
|
document.cookie = data
|
||||||
|
}
|
||||||
|
|
||||||
export const AuthContext = createContext<AuthUser>(undefined)
|
export const AuthContext = createContext<AuthUser>(undefined)
|
||||||
|
|
||||||
export function AuthProvider(props: {
|
export function AuthProvider(props: {
|
||||||
|
@ -54,30 +67,33 @@ export function AuthProvider(props: {
|
||||||
}
|
}
|
||||||
}, [setAuthUser, serverUser])
|
}, [setAuthUser, serverUser])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (authUser != null) {
|
||||||
|
// Persist to local storage, to reduce login blink next time.
|
||||||
|
// Note: Cap on localStorage size is ~5mb
|
||||||
|
localStorage.setItem(CACHED_USER_KEY, JSON.stringify(authUser))
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(CACHED_USER_KEY)
|
||||||
|
}
|
||||||
|
}, [authUser])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return onIdTokenChanged(
|
return onIdTokenChanged(
|
||||||
auth,
|
auth,
|
||||||
async (fbUser) => {
|
async (fbUser) => {
|
||||||
if (fbUser) {
|
if (fbUser) {
|
||||||
setTokenCookies({
|
setUserCookie(JSON.stringify(fbUser.toJSON()))
|
||||||
id: await fbUser.getIdToken(),
|
|
||||||
refresh: fbUser.refreshToken,
|
|
||||||
})
|
|
||||||
let current = await getUserAndPrivateUser(fbUser.uid)
|
let current = await getUserAndPrivateUser(fbUser.uid)
|
||||||
if (!current.user || !current.privateUser) {
|
if (!current.user || !current.privateUser) {
|
||||||
const deviceToken = ensureDeviceToken()
|
const deviceToken = ensureDeviceToken()
|
||||||
current = (await createUser({ deviceToken })) as UserAndPrivateUser
|
current = (await createUser({ deviceToken })) as UserAndPrivateUser
|
||||||
|
setCachedReferralInfoForUser(current.user)
|
||||||
}
|
}
|
||||||
setAuthUser(current)
|
setAuthUser(current)
|
||||||
// Persist to local storage, to reduce login blink next time.
|
|
||||||
// Note: Cap on localStorage size is ~5mb
|
|
||||||
localStorage.setItem(CACHED_USER_KEY, JSON.stringify(current))
|
|
||||||
setCachedReferralInfoForUser(current.user)
|
|
||||||
} else {
|
} else {
|
||||||
// User logged out; reset to null
|
// User logged out; reset to null
|
||||||
deleteTokenCookies()
|
setUserCookie(undefined)
|
||||||
setAuthUser(null)
|
setAuthUser(null)
|
||||||
localStorage.removeItem(CACHED_USER_KEY)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
(e) => {
|
(e) => {
|
||||||
|
@ -87,29 +103,32 @@ export function AuthProvider(props: {
|
||||||
}, [setAuthUser])
|
}, [setAuthUser])
|
||||||
|
|
||||||
const uid = authUser?.user.id
|
const uid = authUser?.user.id
|
||||||
const username = authUser?.user.username
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (uid && username) {
|
if (uid) {
|
||||||
identifyUser(uid)
|
identifyUser(uid)
|
||||||
setUserProperty('username', username)
|
const userListener = listenForUser(uid, (user) => {
|
||||||
const userListener = listenForUser(uid, (user) =>
|
setAuthUser((currAuthUser) =>
|
||||||
setAuthUser((authUser) => {
|
currAuthUser && user ? { ...currAuthUser, user } : null
|
||||||
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
|
|
||||||
return { ...authUser!, user: user! }
|
|
||||||
})
|
|
||||||
)
|
)
|
||||||
const privateUserListener = listenForPrivateUser(uid, (privateUser) => {
|
|
||||||
setAuthUser((authUser) => {
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
|
|
||||||
return { ...authUser!, privateUser: privateUser! }
|
|
||||||
})
|
})
|
||||||
|
const privateUserListener = listenForPrivateUser(uid, (privateUser) => {
|
||||||
|
setAuthUser((currAuthUser) =>
|
||||||
|
currAuthUser && privateUser ? { ...currAuthUser, privateUser } : null
|
||||||
|
)
|
||||||
})
|
})
|
||||||
return () => {
|
return () => {
|
||||||
userListener()
|
userListener()
|
||||||
privateUserListener()
|
privateUserListener()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [uid, username, setAuthUser])
|
}, [uid, setAuthUser])
|
||||||
|
|
||||||
|
const username = authUser?.user.username
|
||||||
|
useEffect(() => {
|
||||||
|
if (username != null) {
|
||||||
|
setUserProperty('username', username)
|
||||||
|
}
|
||||||
|
}, [username])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={authUser}>{children}</AuthContext.Provider>
|
<AuthContext.Provider value={authUser}>{children}</AuthContext.Provider>
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { clamp, partition, sumBy } from 'lodash'
|
import { clamp, partition, sumBy } from 'lodash'
|
||||||
|
|
||||||
import { useUser } from 'web/hooks/use-user'
|
import { useUser } from 'web/hooks/use-user'
|
||||||
|
@ -8,6 +8,7 @@ import { Col } from './layout/col'
|
||||||
import { Row } from './layout/row'
|
import { Row } from './layout/row'
|
||||||
import { Spacer } from './layout/spacer'
|
import { Spacer } from './layout/spacer'
|
||||||
import {
|
import {
|
||||||
|
formatLargeNumber,
|
||||||
formatMoney,
|
formatMoney,
|
||||||
formatPercent,
|
formatPercent,
|
||||||
formatWithCommas,
|
formatWithCommas,
|
||||||
|
@ -28,11 +29,10 @@ import { getProbability } from 'common/calculate'
|
||||||
import { useFocus } from 'web/hooks/use-focus'
|
import { useFocus } from 'web/hooks/use-focus'
|
||||||
import { useUserContractBets } from 'web/hooks/use-user-bets'
|
import { useUserContractBets } from 'web/hooks/use-user-bets'
|
||||||
import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm'
|
import { calculateCpmmSale, getCpmmProbability } from 'common/calculate-cpmm'
|
||||||
import { getFormattedMappedValue } from 'common/pseudo-numeric'
|
import { getFormattedMappedValue, getMappedValue } from 'common/pseudo-numeric'
|
||||||
import { SellRow } from './sell-row'
|
import { SellRow } from './sell-row'
|
||||||
import { useSaveBinaryShares } from './use-save-binary-shares'
|
import { useSaveBinaryShares } from './use-save-binary-shares'
|
||||||
import { BetSignUpPrompt } from './sign-up-prompt'
|
import { BetSignUpPrompt } from './sign-up-prompt'
|
||||||
import { isIOS } from 'web/lib/util/device'
|
|
||||||
import { ProbabilityOrNumericInput } from './probability-input'
|
import { ProbabilityOrNumericInput } from './probability-input'
|
||||||
import { track } from 'web/lib/service/analytics'
|
import { track } from 'web/lib/service/analytics'
|
||||||
import { useUnfilledBets } from 'web/hooks/use-bets'
|
import { useUnfilledBets } from 'web/hooks/use-bets'
|
||||||
|
@ -68,6 +68,8 @@ export function BetPanel(props: {
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{user ? (
|
||||||
|
<>
|
||||||
<QuickOrLimitBet
|
<QuickOrLimitBet
|
||||||
isLimitOrder={isLimitOrder}
|
isLimitOrder={isLimitOrder}
|
||||||
setIsLimitOrder={setIsLimitOrder}
|
setIsLimitOrder={setIsLimitOrder}
|
||||||
|
@ -85,10 +87,13 @@ export function BetPanel(props: {
|
||||||
user={user}
|
user={user}
|
||||||
unfilledBets={unfilledBets}
|
unfilledBets={unfilledBets}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<BetSignUpPrompt />
|
<BetSignUpPrompt />
|
||||||
|
<PlayMoneyDisclaimer />
|
||||||
{!user && <PlayMoneyDisclaimer />}
|
</>
|
||||||
|
)}
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
{user && unfilledBets.length > 0 && (
|
{user && unfilledBets.length > 0 && (
|
||||||
|
@ -179,12 +184,12 @@ function BuyPanel(props: {
|
||||||
|
|
||||||
const [inputRef, focusAmountInput] = useFocus()
|
const [inputRef, focusAmountInput] = useFocus()
|
||||||
|
|
||||||
useEffect(() => {
|
// useEffect(() => {
|
||||||
if (selected) {
|
// if (selected) {
|
||||||
if (isIOS()) window.scrollTo(0, window.scrollY + 200)
|
// if (isIOS()) window.scrollTo(0, window.scrollY + 200)
|
||||||
focusAmountInput()
|
// focusAmountInput()
|
||||||
}
|
// }
|
||||||
}, [selected, focusAmountInput])
|
// }, [selected, focusAmountInput])
|
||||||
|
|
||||||
function onBetChoice(choice: 'YES' | 'NO') {
|
function onBetChoice(choice: 'YES' | 'NO') {
|
||||||
setOutcome(choice)
|
setOutcome(choice)
|
||||||
|
@ -252,17 +257,43 @@ function BuyPanel(props: {
|
||||||
const resultProb = getCpmmProbability(newPool, newP)
|
const resultProb = getCpmmProbability(newPool, newP)
|
||||||
const probStayedSame =
|
const probStayedSame =
|
||||||
formatPercent(resultProb) === formatPercent(initialProb)
|
formatPercent(resultProb) === formatPercent(initialProb)
|
||||||
|
|
||||||
const probChange = Math.abs(resultProb - initialProb)
|
const probChange = Math.abs(resultProb - initialProb)
|
||||||
|
|
||||||
const currentPayout = newBet.shares
|
const currentPayout = newBet.shares
|
||||||
|
|
||||||
const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0
|
const currentReturn = betAmount ? (currentPayout - betAmount) / betAmount : 0
|
||||||
const currentReturnPercent = formatPercent(currentReturn)
|
const currentReturnPercent = formatPercent(currentReturn)
|
||||||
|
|
||||||
const format = getFormattedMappedValue(contract)
|
const format = getFormattedMappedValue(contract)
|
||||||
|
|
||||||
|
const getValue = getMappedValue(contract)
|
||||||
|
const rawDifference = Math.abs(getValue(resultProb) - getValue(initialProb))
|
||||||
|
const displayedDifference = isPseudoNumeric
|
||||||
|
? formatLargeNumber(rawDifference)
|
||||||
|
: formatPercent(rawDifference)
|
||||||
|
|
||||||
const bankrollFraction = (betAmount ?? 0) / (user?.balance ?? 1e9)
|
const bankrollFraction = (betAmount ?? 0) / (user?.balance ?? 1e9)
|
||||||
|
|
||||||
|
const warning =
|
||||||
|
(betAmount ?? 0) > 10 &&
|
||||||
|
bankrollFraction >= 0.5 &&
|
||||||
|
bankrollFraction <= 1 ? (
|
||||||
|
<AlertBox
|
||||||
|
title="Whoa, there!"
|
||||||
|
text={`You might not want to spend ${formatPercent(
|
||||||
|
bankrollFraction
|
||||||
|
)} of your balance on a single bet. \n\nCurrent balance: ${formatMoney(
|
||||||
|
user?.balance ?? 0
|
||||||
|
)}`}
|
||||||
|
/>
|
||||||
|
) : (betAmount ?? 0) > 10 && probChange >= 0.3 && bankrollFraction <= 1 ? (
|
||||||
|
<AlertBox
|
||||||
|
title="Whoa, there!"
|
||||||
|
text={`Are you sure you want to move the market by ${displayedDifference}?`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Col className={hidden ? 'hidden' : ''}>
|
<Col className={hidden ? 'hidden' : ''}>
|
||||||
<div className="my-3 text-left text-sm text-gray-500">
|
<div className="my-3 text-left text-sm text-gray-500">
|
||||||
|
@ -276,7 +307,12 @@ function BuyPanel(props: {
|
||||||
isPseudoNumeric={isPseudoNumeric}
|
isPseudoNumeric={isPseudoNumeric}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="my-3 text-left text-sm text-gray-500">Amount</div>
|
<Row className="my-3 justify-between text-left text-sm text-gray-500">
|
||||||
|
Amount
|
||||||
|
<span className={'xl:hidden'}>
|
||||||
|
(balance: {formatMoney(user?.balance ?? 0)})
|
||||||
|
</span>
|
||||||
|
</Row>
|
||||||
<BuyAmountInput
|
<BuyAmountInput
|
||||||
inputClassName="w-full max-w-none"
|
inputClassName="w-full max-w-none"
|
||||||
amount={betAmount}
|
amount={betAmount}
|
||||||
|
@ -287,33 +323,7 @@ function BuyPanel(props: {
|
||||||
inputRef={inputRef}
|
inputRef={inputRef}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{(betAmount ?? 0) > 10 &&
|
{warning}
|
||||||
bankrollFraction >= 0.5 &&
|
|
||||||
bankrollFraction <= 1 ? (
|
|
||||||
<AlertBox
|
|
||||||
title="Whoa, there!"
|
|
||||||
text={`You might not want to spend ${formatPercent(
|
|
||||||
bankrollFraction
|
|
||||||
)} of your balance on a single bet. \n\nCurrent balance: ${formatMoney(
|
|
||||||
user?.balance ?? 0
|
|
||||||
)}`}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
''
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(betAmount ?? 0) > 10 && probChange >= 0.3 ? (
|
|
||||||
<AlertBox
|
|
||||||
title="Whoa, there!"
|
|
||||||
text={`Are you sure you want to move the market ${
|
|
||||||
isPseudoNumeric && contract.isLogScale
|
|
||||||
? 'this much'
|
|
||||||
: format(probChange)
|
|
||||||
}?`}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
''
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Col className="mt-3 w-full gap-3">
|
<Col className="mt-3 w-full gap-3">
|
||||||
<Row className="items-center justify-between text-sm">
|
<Row className="items-center justify-between text-sm">
|
||||||
|
@ -342,9 +352,6 @@ function BuyPanel(props: {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* <InfoTooltip
|
|
||||||
text={`Includes ${formatMoneyWithDecimals(totalFees)} in fees`}
|
|
||||||
/> */}
|
|
||||||
</Row>
|
</Row>
|
||||||
<div>
|
<div>
|
||||||
<span className="mr-2 whitespace-nowrap">
|
<span className="mr-2 whitespace-nowrap">
|
||||||
|
@ -594,9 +601,14 @@ function LimitOrderPanel(props: {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-1 mb-3 text-left text-sm text-gray-500">
|
<Row className="mt-1 mb-3 justify-between text-left text-sm text-gray-500">
|
||||||
|
<span>
|
||||||
Max amount<span className="ml-1 text-red-500">*</span>
|
Max amount<span className="ml-1 text-red-500">*</span>
|
||||||
</div>
|
</span>
|
||||||
|
<span className={'xl:hidden'}>
|
||||||
|
(balance: {formatMoney(user?.balance ?? 0)})
|
||||||
|
</span>
|
||||||
|
</Row>
|
||||||
<BuyAmountInput
|
<BuyAmountInput
|
||||||
inputClassName="w-full max-w-none"
|
inputClassName="w-full max-w-none"
|
||||||
amount={betAmount}
|
amount={betAmount}
|
||||||
|
|
|
@ -1,14 +1,13 @@
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { keyBy, groupBy, mapValues, sortBy, partition, sumBy } from 'lodash'
|
import { keyBy, groupBy, mapValues, sortBy, partition, sumBy } from 'lodash'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid'
|
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/solid'
|
||||||
|
|
||||||
import { Bet } from 'web/lib/firebase/bets'
|
import { Bet } from 'web/lib/firebase/bets'
|
||||||
import { User } from 'web/lib/firebase/users'
|
import { User } from 'web/lib/firebase/users'
|
||||||
import {
|
import {
|
||||||
formatLargeNumber,
|
|
||||||
formatMoney,
|
formatMoney,
|
||||||
formatPercent,
|
formatPercent,
|
||||||
formatWithCommas,
|
formatWithCommas,
|
||||||
|
@ -35,8 +34,6 @@ import {
|
||||||
resolvedPayout,
|
resolvedPayout,
|
||||||
getContractBetNullMetrics,
|
getContractBetNullMetrics,
|
||||||
} from 'common/calculate'
|
} from 'common/calculate'
|
||||||
import { useTimeSinceFirstRender } from 'web/hooks/use-time-since-first-render'
|
|
||||||
import { trackLatency } from 'web/lib/firebase/tracking'
|
|
||||||
import { NumericContract } from 'common/contract'
|
import { NumericContract } from 'common/contract'
|
||||||
import { formatNumericProbability } from 'common/pseudo-numeric'
|
import { formatNumericProbability } from 'common/pseudo-numeric'
|
||||||
import { useUser } from 'web/hooks/use-user'
|
import { useUser } from 'web/hooks/use-user'
|
||||||
|
@ -85,13 +82,6 @@ export function BetsList(props: { user: User }) {
|
||||||
const start = page * CONTRACTS_PER_PAGE
|
const start = page * CONTRACTS_PER_PAGE
|
||||||
const end = start + CONTRACTS_PER_PAGE
|
const end = start + CONTRACTS_PER_PAGE
|
||||||
|
|
||||||
const getTime = useTimeSinceFirstRender()
|
|
||||||
useEffect(() => {
|
|
||||||
if (bets && contractsById && signedInUser) {
|
|
||||||
trackLatency(signedInUser.id, 'portfolio', getTime())
|
|
||||||
}
|
|
||||||
}, [signedInUser, bets, contractsById, getTime])
|
|
||||||
|
|
||||||
if (!bets || !contractsById) {
|
if (!bets || !contractsById) {
|
||||||
return <LoadingIndicator />
|
return <LoadingIndicator />
|
||||||
}
|
}
|
||||||
|
@ -219,9 +209,10 @@ export function BetsList(props: { user: User }) {
|
||||||
|
|
||||||
<Col className="mt-6 divide-y">
|
<Col className="mt-6 divide-y">
|
||||||
{displayedContracts.length === 0 ? (
|
{displayedContracts.length === 0 ? (
|
||||||
<NoBets user={user} />
|
<NoMatchingBets />
|
||||||
) : (
|
) : (
|
||||||
displayedContracts.map((contract) => (
|
<>
|
||||||
|
{displayedContracts.map((contract) => (
|
||||||
<ContractBets
|
<ContractBets
|
||||||
key={contract.id}
|
key={contract.id}
|
||||||
contract={contract}
|
contract={contract}
|
||||||
|
@ -229,16 +220,16 @@ export function BetsList(props: { user: User }) {
|
||||||
metric={sort === 'profit' ? 'profit' : 'value'}
|
metric={sort === 'profit' ? 'profit' : 'value'}
|
||||||
isYourBets={isYourBets}
|
isYourBets={isYourBets}
|
||||||
/>
|
/>
|
||||||
))
|
))}
|
||||||
)}
|
|
||||||
</Col>
|
|
||||||
|
|
||||||
<Pagination
|
<Pagination
|
||||||
page={page}
|
page={page}
|
||||||
itemsPerPage={CONTRACTS_PER_PAGE}
|
itemsPerPage={CONTRACTS_PER_PAGE}
|
||||||
totalItems={filteredContracts.length}
|
totalItems={filteredContracts.length}
|
||||||
setPage={setPage}
|
setPage={setPage}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Col>
|
||||||
</Col>
|
</Col>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -246,7 +237,7 @@ export function BetsList(props: { user: User }) {
|
||||||
const NoBets = ({ user }: { user: User }) => {
|
const NoBets = ({ user }: { user: User }) => {
|
||||||
const me = useUser()
|
const me = useUser()
|
||||||
return (
|
return (
|
||||||
<div className="mx-4 text-gray-500">
|
<div className="mx-4 py-4 text-gray-500">
|
||||||
{user.id === me?.id ? (
|
{user.id === me?.id ? (
|
||||||
<>
|
<>
|
||||||
You have not made any bets yet.{' '}
|
You have not made any bets yet.{' '}
|
||||||
|
@ -260,6 +251,11 @@ const NoBets = ({ user }: { user: User }) => {
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
const NoMatchingBets = () => (
|
||||||
|
<div className="mx-4 py-4 text-gray-500">
|
||||||
|
No bets matching the current filter.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
function ContractBets(props: {
|
function ContractBets(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
|
@ -483,23 +479,6 @@ export function BetsSummary(props: {
|
||||||
<div className="whitespace-nowrap">{formatMoney(noWinnings)}</div>
|
<div className="whitespace-nowrap">{formatMoney(noWinnings)}</div>
|
||||||
</Col>
|
</Col>
|
||||||
</>
|
</>
|
||||||
) : isPseudoNumeric ? (
|
|
||||||
<>
|
|
||||||
<Col>
|
|
||||||
<div className="whitespace-nowrap text-sm text-gray-500">
|
|
||||||
Payout if {'>='} {formatLargeNumber(contract.max)}
|
|
||||||
</div>
|
|
||||||
<div className="whitespace-nowrap">
|
|
||||||
{formatMoney(yesWinnings)}
|
|
||||||
</div>
|
|
||||||
</Col>
|
|
||||||
<Col>
|
|
||||||
<div className="whitespace-nowrap text-sm text-gray-500">
|
|
||||||
Payout if {'<='} {formatLargeNumber(contract.min)}
|
|
||||||
</div>
|
|
||||||
<div className="whitespace-nowrap">{formatMoney(noWinnings)}</div>
|
|
||||||
</Col>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<Col>
|
<Col>
|
||||||
<div className="whitespace-nowrap text-sm text-gray-500">
|
<div className="whitespace-nowrap text-sm text-gray-500">
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { ReactNode } from 'react'
|
import { MouseEventHandler, ReactNode } from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
export type SizeType = '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
|
export type SizeType = '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
|
||||||
|
@ -14,7 +14,7 @@ export type ColorType =
|
||||||
|
|
||||||
export function Button(props: {
|
export function Button(props: {
|
||||||
className?: string
|
className?: string
|
||||||
onClick?: () => void
|
onClick?: MouseEventHandler<any> | undefined
|
||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
size?: SizeType
|
size?: SizeType
|
||||||
color?: ColorType
|
color?: ColorType
|
||||||
|
|
72
web/components/carousel.tsx
Normal file
72
web/components/carousel.tsx
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/solid'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { throttle } from 'lodash'
|
||||||
|
import { ReactNode, useRef, useState, useEffect } from 'react'
|
||||||
|
import { Row } from './layout/row'
|
||||||
|
import { VisibilityObserver } from 'web/components/visibility-observer'
|
||||||
|
|
||||||
|
export function Carousel(props: {
|
||||||
|
children: ReactNode
|
||||||
|
loadMore?: () => void
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const { children, loadMore, className } = props
|
||||||
|
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const th = (f: () => any) => throttle(f, 500, { trailing: false })
|
||||||
|
const scrollLeft = th(() =>
|
||||||
|
ref.current?.scrollBy({ left: -ref.current.clientWidth })
|
||||||
|
)
|
||||||
|
const scrollRight = th(() =>
|
||||||
|
ref.current?.scrollBy({ left: ref.current.clientWidth })
|
||||||
|
)
|
||||||
|
|
||||||
|
const [atFront, setAtFront] = useState(true)
|
||||||
|
const [atBack, setAtBack] = useState(false)
|
||||||
|
const onScroll = throttle(() => {
|
||||||
|
if (ref.current) {
|
||||||
|
const { scrollLeft, clientWidth, scrollWidth } = ref.current
|
||||||
|
setAtFront(scrollLeft < 80)
|
||||||
|
setAtBack(scrollWidth - (clientWidth + scrollLeft) < 80)
|
||||||
|
}
|
||||||
|
}, 500)
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
useEffect(onScroll, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx('relative', className)}>
|
||||||
|
<Row
|
||||||
|
className="scrollbar-hide w-full gap-4 overflow-x-auto scroll-smooth"
|
||||||
|
ref={ref}
|
||||||
|
onScroll={onScroll}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{loadMore && (
|
||||||
|
<VisibilityObserver
|
||||||
|
className="relative -left-96"
|
||||||
|
onVisibilityUpdated={(visible) => visible && loadMore()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
{!atFront && (
|
||||||
|
<div
|
||||||
|
className="absolute left-0 top-0 bottom-0 z-10 flex w-10 cursor-pointer items-center justify-center hover:bg-indigo-100/30"
|
||||||
|
onMouseDown={scrollLeft}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon className="h-7 w-7 rounded-full bg-indigo-50 text-indigo-700" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!atBack && (
|
||||||
|
<div
|
||||||
|
className="absolute right-0 top-0 bottom-0 z-10 flex w-10 cursor-pointer items-center justify-center hover:bg-indigo-100/30"
|
||||||
|
onMouseDown={scrollRight}
|
||||||
|
>
|
||||||
|
<ChevronRightIcon className="h-7 w-7 rounded-full bg-indigo-50 text-indigo-700" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
|
@ -18,7 +18,6 @@ import { NoLabel, YesLabel } from '../outcome-label'
|
||||||
import { QRCode } from '../qr-code'
|
import { QRCode } from '../qr-code'
|
||||||
import { copyToClipboard } from 'web/lib/util/copy'
|
import { copyToClipboard } from 'web/lib/util/copy'
|
||||||
import { AmountInput } from '../amount-input'
|
import { AmountInput } from '../amount-input'
|
||||||
import { getProbability } from 'common/calculate'
|
|
||||||
import { createMarket } from 'web/lib/firebase/api'
|
import { createMarket } from 'web/lib/firebase/api'
|
||||||
import { removeUndefinedProps } from 'common/util/object'
|
import { removeUndefinedProps } from 'common/util/object'
|
||||||
import { FIXED_ANTE } from 'common/economy'
|
import { FIXED_ANTE } from 'common/economy'
|
||||||
|
@ -26,6 +25,7 @@ import Textarea from 'react-expanding-textarea'
|
||||||
import { useTextEditor } from 'web/components/editor'
|
import { useTextEditor } from 'web/components/editor'
|
||||||
import { LoadingIndicator } from 'web/components/loading-indicator'
|
import { LoadingIndicator } from 'web/components/loading-indicator'
|
||||||
import { track } from 'web/lib/service/analytics'
|
import { track } from 'web/lib/service/analytics'
|
||||||
|
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||||
|
|
||||||
type challengeInfo = {
|
type challengeInfo = {
|
||||||
amount: number
|
amount: number
|
||||||
|
@ -110,8 +110,9 @@ function CreateChallengeForm(props: {
|
||||||
const [isCreating, setIsCreating] = useState(false)
|
const [isCreating, setIsCreating] = useState(false)
|
||||||
const [finishedCreating, setFinishedCreating] = useState(false)
|
const [finishedCreating, setFinishedCreating] = useState(false)
|
||||||
const [error, setError] = useState<string>('')
|
const [error, setError] = useState<string>('')
|
||||||
const [editingAcceptorAmount, setEditingAcceptorAmount] = useState(false)
|
|
||||||
const defaultExpire = 'week'
|
const defaultExpire = 'week'
|
||||||
|
const { width } = useWindowSize()
|
||||||
|
const isMobile = (width ?? 0) < 768
|
||||||
|
|
||||||
const [challengeInfo, setChallengeInfo] = useState<challengeInfo>({
|
const [challengeInfo, setChallengeInfo] = useState<challengeInfo>({
|
||||||
expiresTime: dayjs().add(2, defaultExpire).valueOf(),
|
expiresTime: dayjs().add(2, defaultExpire).valueOf(),
|
||||||
|
@ -147,7 +148,7 @@ function CreateChallengeForm(props: {
|
||||||
setFinishedCreating(true)
|
setFinishedCreating(true)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Title className="!mt-2" text="Challenge bet " />
|
<Title className="!mt-2 hidden sm:block" text="Challenge bet " />
|
||||||
|
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
Challenge a friend to bet on{' '}
|
Challenge a friend to bet on{' '}
|
||||||
|
@ -157,7 +158,7 @@ function CreateChallengeForm(props: {
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder="e.g. Will a Democrat be the next president?"
|
placeholder="e.g. Will a Democrat be the next president?"
|
||||||
className="input input-bordered mt-1 w-full resize-none"
|
className="input input-bordered mt-1 w-full resize-none"
|
||||||
autoFocus={true}
|
autoFocus={!isMobile}
|
||||||
maxLength={MAX_QUESTION_LENGTH}
|
maxLength={MAX_QUESTION_LENGTH}
|
||||||
value={challengeInfo.question}
|
value={challengeInfo.question}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
|
@ -170,7 +171,8 @@ function CreateChallengeForm(props: {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 flex flex-col flex-wrap justify-center gap-x-5 gap-y-2">
|
<Col className="mt-2 flex-wrap justify-center gap-x-5 gap-y-0 sm:gap-y-2">
|
||||||
|
<Col>
|
||||||
<div>You'll bet:</div>
|
<div>You'll bet:</div>
|
||||||
<Row
|
<Row
|
||||||
className={
|
className={
|
||||||
|
@ -184,9 +186,7 @@ function CreateChallengeForm(props: {
|
||||||
return {
|
return {
|
||||||
...m,
|
...m,
|
||||||
amount: newAmount ?? 0,
|
amount: newAmount ?? 0,
|
||||||
acceptorAmount: editingAcceptorAmount
|
acceptorAmount: newAmount ?? 0,
|
||||||
? m.acceptorAmount
|
|
||||||
: newAmount ?? 0,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -197,7 +197,7 @@ function CreateChallengeForm(props: {
|
||||||
<span className={''}>on</span>
|
<span className={''}>on</span>
|
||||||
{challengeInfo.outcome === 'YES' ? <YesLabel /> : <NoLabel />}
|
{challengeInfo.outcome === 'YES' ? <YesLabel /> : <NoLabel />}
|
||||||
</Row>
|
</Row>
|
||||||
<Row className={'mt-3 max-w-xs justify-end'}>
|
<Row className={'max-w-xs justify-end'}>
|
||||||
<Button
|
<Button
|
||||||
color={'gray-white'}
|
color={'gray-white'}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
|
@ -212,47 +212,18 @@ function CreateChallengeForm(props: {
|
||||||
<SwitchVerticalIcon className={'h-6 w-6'} />
|
<SwitchVerticalIcon className={'h-6 w-6'} />
|
||||||
</Button>
|
</Button>
|
||||||
</Row>
|
</Row>
|
||||||
|
</Col>
|
||||||
<Row className={'items-center'}>If they bet:</Row>
|
<Row className={'items-center'}>If they bet:</Row>
|
||||||
<Row className={'max-w-xs items-center justify-between gap-4 pr-3'}>
|
<Row className={'max-w-xs items-center justify-between gap-4 pr-3'}>
|
||||||
<div className={'w-32 sm:mr-1'}>
|
<div className={'mt-1 w-32 sm:mr-1'}>
|
||||||
<AmountInput
|
<span className={'ml-2 font-bold'}>
|
||||||
amount={challengeInfo.acceptorAmount || undefined}
|
{formatMoney(challengeInfo.acceptorAmount)}
|
||||||
onChange={(newAmount) => {
|
</span>
|
||||||
setEditingAcceptorAmount(true)
|
|
||||||
|
|
||||||
setChallengeInfo((m: challengeInfo) => {
|
|
||||||
return {
|
|
||||||
...m,
|
|
||||||
acceptorAmount: newAmount ?? 0,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
error={undefined}
|
|
||||||
label={'M$'}
|
|
||||||
inputClassName="w-24"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<span>on</span>
|
<span>on</span>
|
||||||
{challengeInfo.outcome === 'YES' ? <NoLabel /> : <YesLabel />}
|
{challengeInfo.outcome === 'YES' ? <NoLabel /> : <YesLabel />}
|
||||||
</Row>
|
</Row>
|
||||||
</div>
|
</Col>
|
||||||
{contract && (
|
|
||||||
<Button
|
|
||||||
size="2xs"
|
|
||||||
color="gray"
|
|
||||||
onClick={() => {
|
|
||||||
setEditingAcceptorAmount(true)
|
|
||||||
|
|
||||||
const p = getProbability(contract)
|
|
||||||
const prob = challengeInfo.outcome === 'YES' ? p : 1 - p
|
|
||||||
const { amount } = challengeInfo
|
|
||||||
const acceptorAmount = Math.round(amount / prob - amount)
|
|
||||||
setChallengeInfo({ ...challengeInfo, acceptorAmount })
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Use market odds
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
If the challenge is accepted, whoever is right will earn{' '}
|
If the challenge is accepted, whoever is right will earn{' '}
|
||||||
<span className="font-semibold">
|
<span className="font-semibold">
|
||||||
|
|
|
@ -10,7 +10,7 @@ import {
|
||||||
} from './contract/contracts-grid'
|
} from './contract/contracts-grid'
|
||||||
import { ShowTime } from './contract/contract-details'
|
import { ShowTime } from './contract/contract-details'
|
||||||
import { Row } from './layout/row'
|
import { Row } from './layout/row'
|
||||||
import { useEffect, useLayoutEffect, useRef, useMemo } from 'react'
|
import { useEffect, useLayoutEffect, useRef, useMemo, ReactNode } from 'react'
|
||||||
import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
|
import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
|
||||||
import { useFollows } from 'web/hooks/use-follows'
|
import { useFollows } from 'web/hooks/use-follows'
|
||||||
import {
|
import {
|
||||||
|
@ -38,7 +38,7 @@ const searchClient = algoliasearch(
|
||||||
const indexPrefix = ENV === 'DEV' ? 'dev-' : ''
|
const indexPrefix = ENV === 'DEV' ? 'dev-' : ''
|
||||||
const searchIndexName = ENV === 'DEV' ? 'dev-contracts' : 'contractsIndex'
|
const searchIndexName = ENV === 'DEV' ? 'dev-contracts' : 'contractsIndex'
|
||||||
|
|
||||||
const SORTS = [
|
export const SORTS = [
|
||||||
{ label: 'Newest', value: 'newest' },
|
{ label: 'Newest', value: 'newest' },
|
||||||
{ label: 'Trending', value: 'score' },
|
{ label: 'Trending', value: 'score' },
|
||||||
{ label: 'Most traded', value: 'most-traded' },
|
{ label: 'Most traded', value: 'most-traded' },
|
||||||
|
@ -65,6 +65,7 @@ type AdditionalFilter = {
|
||||||
tag?: string
|
tag?: string
|
||||||
excludeContractIds?: string[]
|
excludeContractIds?: string[]
|
||||||
groupSlug?: string
|
groupSlug?: string
|
||||||
|
yourBets?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContractSearch(props: {
|
export function ContractSearch(props: {
|
||||||
|
@ -83,8 +84,11 @@ export function ContractSearch(props: {
|
||||||
persistPrefix?: string
|
persistPrefix?: string
|
||||||
useQueryUrlParam?: boolean
|
useQueryUrlParam?: boolean
|
||||||
isWholePage?: boolean
|
isWholePage?: boolean
|
||||||
maxItems?: number
|
|
||||||
noControls?: boolean
|
noControls?: boolean
|
||||||
|
renderContracts?: (
|
||||||
|
contracts: Contract[] | undefined,
|
||||||
|
loadMore: () => void
|
||||||
|
) => ReactNode
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
user,
|
user,
|
||||||
|
@ -99,8 +103,8 @@ export function ContractSearch(props: {
|
||||||
persistPrefix,
|
persistPrefix,
|
||||||
useQueryUrlParam,
|
useQueryUrlParam,
|
||||||
isWholePage,
|
isWholePage,
|
||||||
maxItems,
|
|
||||||
noControls,
|
noControls,
|
||||||
|
renderContracts,
|
||||||
} = props
|
} = props
|
||||||
|
|
||||||
const [state, setState] = usePersistentState(
|
const [state, setState] = usePersistentState(
|
||||||
|
@ -182,8 +186,7 @@ export function ContractSearch(props: {
|
||||||
const contracts = state.pages
|
const contracts = state.pages
|
||||||
.flat()
|
.flat()
|
||||||
.filter((c) => !additionalFilter?.excludeContractIds?.includes(c.id))
|
.filter((c) => !additionalFilter?.excludeContractIds?.includes(c.id))
|
||||||
const renderedContracts =
|
const renderedContracts = state.pages.length === 0 ? undefined : contracts
|
||||||
state.pages.length === 0 ? undefined : contracts.slice(0, maxItems)
|
|
||||||
|
|
||||||
if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) {
|
if (IS_PRIVATE_MANIFOLD || process.env.NEXT_PUBLIC_FIREBASE_EMULATE) {
|
||||||
return <ContractSearchFirestore additionalFilter={additionalFilter} />
|
return <ContractSearchFirestore additionalFilter={additionalFilter} />
|
||||||
|
@ -203,6 +206,9 @@ export function ContractSearch(props: {
|
||||||
onSearchParametersChanged={onSearchParametersChanged}
|
onSearchParametersChanged={onSearchParametersChanged}
|
||||||
noControls={noControls}
|
noControls={noControls}
|
||||||
/>
|
/>
|
||||||
|
{renderContracts ? (
|
||||||
|
renderContracts(renderedContracts, performQuery)
|
||||||
|
) : (
|
||||||
<ContractsGrid
|
<ContractsGrid
|
||||||
contracts={renderedContracts}
|
contracts={renderedContracts}
|
||||||
loadMore={noControls ? undefined : performQuery}
|
loadMore={noControls ? undefined : performQuery}
|
||||||
|
@ -211,6 +217,7 @@ export function ContractSearch(props: {
|
||||||
highlightOptions={highlightOptions}
|
highlightOptions={highlightOptions}
|
||||||
cardHideOptions={cardHideOptions}
|
cardHideOptions={cardHideOptions}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</Col>
|
</Col>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -275,8 +282,8 @@ function ContractSearchControls(props: {
|
||||||
: DEFAULT_CATEGORY_GROUPS.map((g) => g.slug)
|
: DEFAULT_CATEGORY_GROUPS.map((g) => g.slug)
|
||||||
|
|
||||||
const memberPillGroups = sortBy(
|
const memberPillGroups = sortBy(
|
||||||
memberGroups.filter((group) => group.contractIds.length > 0),
|
memberGroups.filter((group) => group.totalContracts > 0),
|
||||||
(group) => group.contractIds.length
|
(group) => group.totalContracts
|
||||||
).reverse()
|
).reverse()
|
||||||
|
|
||||||
const pillGroups: { name: string; slug: string }[] =
|
const pillGroups: { name: string; slug: string }[] =
|
||||||
|
@ -290,6 +297,10 @@ function ContractSearchControls(props: {
|
||||||
additionalFilter?.groupSlug
|
additionalFilter?.groupSlug
|
||||||
? `groupLinks.slug:${additionalFilter.groupSlug}`
|
? `groupLinks.slug:${additionalFilter.groupSlug}`
|
||||||
: '',
|
: '',
|
||||||
|
additionalFilter?.yourBets && user
|
||||||
|
? // Show contracts bet on by the user
|
||||||
|
`uniqueBettorIds:${user.id}`
|
||||||
|
: '',
|
||||||
]
|
]
|
||||||
const facetFilters = query
|
const facetFilters = query
|
||||||
? additionalFilters
|
? additionalFilters
|
||||||
|
@ -312,10 +323,6 @@ function ContractSearchControls(props: {
|
||||||
.map((slug) => `groupLinks.slug:${slug}`)
|
.map((slug) => `groupLinks.slug:${slug}`)
|
||||||
// Show contracts created by users the user follows
|
// Show contracts created by users the user follows
|
||||||
.concat(follows?.map((followId) => `creatorId:${followId}`) ?? [])
|
.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.
|
// Subtract contracts you bet on from For you.
|
||||||
state.pillFilter === 'personal' && user
|
state.pillFilter === 'personal' && user
|
||||||
|
|
|
@ -32,7 +32,6 @@ import { track } from '@amplitude/analytics-browser'
|
||||||
import { trackCallback } from 'web/lib/service/analytics'
|
import { trackCallback } from 'web/lib/service/analytics'
|
||||||
import { getMappedValue } from 'common/pseudo-numeric'
|
import { getMappedValue } from 'common/pseudo-numeric'
|
||||||
import { Tooltip } from '../tooltip'
|
import { Tooltip } from '../tooltip'
|
||||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
|
||||||
|
|
||||||
export function ContractCard(props: {
|
export function ContractCard(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
|
@ -64,11 +63,7 @@ export function ContractCard(props: {
|
||||||
const marketClosed =
|
const marketClosed =
|
||||||
(contract.closeTime || Infinity) < Date.now() || !!resolution
|
(contract.closeTime || Infinity) < Date.now() || !!resolution
|
||||||
|
|
||||||
const { width } = useWindowSize()
|
|
||||||
const isMobile = (width ?? 0) < 768
|
|
||||||
|
|
||||||
const showQuickBet =
|
const showQuickBet =
|
||||||
!isMobile &&
|
|
||||||
user &&
|
user &&
|
||||||
!marketClosed &&
|
!marketClosed &&
|
||||||
(outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') &&
|
(outcomeType === 'BINARY' || outcomeType === 'PSEUDO_NUMERIC') &&
|
||||||
|
|
|
@ -6,6 +6,7 @@ import Textarea from 'react-expanding-textarea'
|
||||||
import { Contract, MAX_DESCRIPTION_LENGTH } from 'common/contract'
|
import { Contract, MAX_DESCRIPTION_LENGTH } from 'common/contract'
|
||||||
import { exhibitExts, parseTags } from 'common/util/parse'
|
import { exhibitExts, parseTags } from 'common/util/parse'
|
||||||
import { useAdmin } from 'web/hooks/use-admin'
|
import { useAdmin } from 'web/hooks/use-admin'
|
||||||
|
import { useUser } from 'web/hooks/use-user'
|
||||||
import { updateContract } from 'web/lib/firebase/contracts'
|
import { updateContract } from 'web/lib/firebase/contracts'
|
||||||
import { Row } from '../layout/row'
|
import { Row } from '../layout/row'
|
||||||
import { Content } from '../editor'
|
import { Content } from '../editor'
|
||||||
|
@ -17,11 +18,12 @@ import { insertContent } from '../editor/utils'
|
||||||
|
|
||||||
export function ContractDescription(props: {
|
export function ContractDescription(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
isCreator: boolean
|
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
const { contract, isCreator, className } = props
|
const { contract, className } = props
|
||||||
const isAdmin = useAdmin()
|
const isAdmin = useAdmin()
|
||||||
|
const user = useUser()
|
||||||
|
const isCreator = user?.id === contract.creatorId
|
||||||
return (
|
return (
|
||||||
<div className={clsx('mt-2 text-gray-700', className)}>
|
<div className={clsx('mt-2 text-gray-700', className)}>
|
||||||
{isCreator || isAdmin ? (
|
{isCreator || isAdmin ? (
|
||||||
|
|
|
@ -5,10 +5,10 @@ import {
|
||||||
TrendingUpIcon,
|
TrendingUpIcon,
|
||||||
UserGroupIcon,
|
UserGroupIcon,
|
||||||
} from '@heroicons/react/outline'
|
} from '@heroicons/react/outline'
|
||||||
import Router from 'next/router'
|
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { Editor } from '@tiptap/react'
|
import { Editor } from '@tiptap/react'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
import { Row } from '../layout/row'
|
import { Row } from '../layout/row'
|
||||||
import { formatMoney } from 'common/util/format'
|
import { formatMoney } from 'common/util/format'
|
||||||
|
@ -18,7 +18,6 @@ import { fromNow } from 'web/lib/util/time'
|
||||||
import { Avatar } from '../avatar'
|
import { Avatar } from '../avatar'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { ContractInfoDialog } from './contract-info-dialog'
|
import { ContractInfoDialog } from './contract-info-dialog'
|
||||||
import { Bet } from 'common/bet'
|
|
||||||
import NewContractBadge from '../new-contract-badge'
|
import NewContractBadge from '../new-contract-badge'
|
||||||
import { UserFollowButton } from '../follow-button'
|
import { UserFollowButton } from '../follow-button'
|
||||||
import { DAY_MS } from 'common/util/time'
|
import { DAY_MS } from 'common/util/time'
|
||||||
|
@ -28,13 +27,14 @@ import { Button } from 'web/components/button'
|
||||||
import { Modal } from 'web/components/layout/modal'
|
import { Modal } from 'web/components/layout/modal'
|
||||||
import { Col } from 'web/components/layout/col'
|
import { Col } from 'web/components/layout/col'
|
||||||
import { ContractGroupsList } from 'web/components/groups/contract-groups-list'
|
import { ContractGroupsList } from 'web/components/groups/contract-groups-list'
|
||||||
import { SiteLink } from 'web/components/site-link'
|
import { linkClass } from 'web/components/site-link'
|
||||||
import { groupPath } from 'web/lib/firebase/groups'
|
import { getGroupLinkToDisplay, groupPath } from 'web/lib/firebase/groups'
|
||||||
import { insertContent } from '../editor/utils'
|
import { insertContent } from '../editor/utils'
|
||||||
import { contractMetrics } from 'common/contract-details'
|
import { contractMetrics } from 'common/contract-details'
|
||||||
import { User } from 'common/user'
|
|
||||||
import { UserLink } from 'web/components/user-link'
|
import { UserLink } from 'web/components/user-link'
|
||||||
import { FeaturedContractBadge } from 'web/components/contract/featured-contract-badge'
|
import { FeaturedContractBadge } from 'web/components/contract/featured-contract-badge'
|
||||||
|
import { Tooltip } from 'web/components/tooltip'
|
||||||
|
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||||
|
|
||||||
export type ShowTime = 'resolve-date' | 'close-date'
|
export type ShowTime = 'resolve-date' | 'close-date'
|
||||||
|
|
||||||
|
@ -52,10 +52,10 @@ export function MiscDetails(props: {
|
||||||
isResolved,
|
isResolved,
|
||||||
createdTime,
|
createdTime,
|
||||||
resolutionTime,
|
resolutionTime,
|
||||||
groupLinks,
|
|
||||||
} = contract
|
} = contract
|
||||||
|
|
||||||
const isNew = createdTime > Date.now() - DAY_MS && !isResolved
|
const isNew = createdTime > Date.now() - DAY_MS && !isResolved
|
||||||
|
const groupToDisplay = getGroupLinkToDisplay(contract)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row className="items-center gap-3 truncate text-sm text-gray-400">
|
<Row className="items-center gap-3 truncate text-sm text-gray-400">
|
||||||
|
@ -78,18 +78,17 @@ export function MiscDetails(props: {
|
||||||
) : (contract?.featuredOnHomeRank ?? 0) > 0 ? (
|
) : (contract?.featuredOnHomeRank ?? 0) > 0 ? (
|
||||||
<FeaturedContractBadge />
|
<FeaturedContractBadge />
|
||||||
) : volume > 0 || !isNew ? (
|
) : volume > 0 || !isNew ? (
|
||||||
<Row className={'shrink-0'}>{formatMoney(contract.volume)} bet</Row>
|
<Row className={'shrink-0'}>{formatMoney(volume)} bet</Row>
|
||||||
) : (
|
) : (
|
||||||
<NewContractBadge />
|
<NewContractBadge />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!hideGroupLink && groupLinks && groupLinks.length > 0 && (
|
{!hideGroupLink && groupToDisplay && (
|
||||||
<SiteLink
|
<Link prefetch={false} href={groupPath(groupToDisplay.slug)}>
|
||||||
href={groupPath(groupLinks[0].slug)}
|
<a className={clsx(linkClass, 'truncate text-sm text-gray-400')}>
|
||||||
className="truncate text-sm text-gray-400"
|
{groupToDisplay.name}
|
||||||
>
|
</a>
|
||||||
{groupLinks[0].name}
|
</Link>
|
||||||
</SiteLink>
|
|
||||||
)}
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
)
|
)
|
||||||
|
@ -101,7 +100,7 @@ export function AvatarDetails(props: {
|
||||||
short?: boolean
|
short?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { contract, short, className } = props
|
const { contract, short, className } = props
|
||||||
const { creatorName, creatorUsername } = contract
|
const { creatorName, creatorUsername, creatorAvatarUrl } = contract
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row
|
<Row
|
||||||
|
@ -109,7 +108,7 @@ export function AvatarDetails(props: {
|
||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
username={creatorUsername}
|
username={creatorUsername}
|
||||||
avatarUrl={contract.creatorAvatarUrl}
|
avatarUrl={creatorAvatarUrl}
|
||||||
size={6}
|
size={6}
|
||||||
/>
|
/>
|
||||||
<UserLink name={creatorName} username={creatorUsername} short={short} />
|
<UserLink name={creatorName} username={creatorUsername} short={short} />
|
||||||
|
@ -117,49 +116,51 @@ export function AvatarDetails(props: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AbbrContractDetails(props: {
|
|
||||||
contract: Contract
|
|
||||||
showHotVolume?: boolean
|
|
||||||
showTime?: ShowTime
|
|
||||||
}) {
|
|
||||||
const { contract, showHotVolume, showTime } = props
|
|
||||||
return (
|
|
||||||
<Row className="items-center justify-between">
|
|
||||||
<AvatarDetails contract={contract} />
|
|
||||||
|
|
||||||
<MiscDetails
|
|
||||||
contract={contract}
|
|
||||||
showHotVolume={showHotVolume}
|
|
||||||
showTime={showTime}
|
|
||||||
/>
|
|
||||||
</Row>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ContractDetails(props: {
|
export function ContractDetails(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
bets: Bet[]
|
|
||||||
user: User | null | undefined
|
|
||||||
isCreator?: boolean
|
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { contract, bets, isCreator, disabled } = props
|
const { contract, disabled } = props
|
||||||
const { closeTime, creatorName, creatorUsername, creatorId, groupLinks } =
|
const {
|
||||||
contract
|
closeTime,
|
||||||
|
creatorName,
|
||||||
|
creatorUsername,
|
||||||
|
creatorId,
|
||||||
|
creatorAvatarUrl,
|
||||||
|
resolutionTime,
|
||||||
|
} = contract
|
||||||
const { volumeLabel, resolvedDate } = contractMetrics(contract)
|
const { volumeLabel, resolvedDate } = contractMetrics(contract)
|
||||||
|
|
||||||
const groupToDisplay =
|
|
||||||
groupLinks?.sort((a, b) => a.createdTime - b.createdTime)[0] ?? null
|
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
|
const isCreator = user?.id === creatorId
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
const { width } = useWindowSize()
|
||||||
const groupInfo = (
|
const isMobile = (width ?? 0) < 600
|
||||||
|
const groupToDisplay = getGroupLinkToDisplay(contract)
|
||||||
|
const groupInfo = groupToDisplay ? (
|
||||||
|
<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'}
|
||||||
|
className={'max-w-[200px] pr-2'}
|
||||||
|
color={'gray-white'}
|
||||||
|
onClick={() => !groupToDisplay && setOpen(true)}
|
||||||
|
>
|
||||||
<Row>
|
<Row>
|
||||||
<UserGroupIcon className="mx-1 inline h-5 w-5 shrink-0" />
|
<UserGroupIcon className="mx-1 inline h-5 w-5 shrink-0" />
|
||||||
<span className="truncate">
|
<span className="truncate">No Group</span>
|
||||||
{groupToDisplay ? groupToDisplay.name : 'No group'}
|
|
||||||
</span>
|
|
||||||
</Row>
|
</Row>
|
||||||
|
</Button>
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -167,7 +168,7 @@ export function ContractDetails(props: {
|
||||||
<Row className="items-center gap-2">
|
<Row className="items-center gap-2">
|
||||||
<Avatar
|
<Avatar
|
||||||
username={creatorUsername}
|
username={creatorUsername}
|
||||||
avatarUrl={contract.creatorAvatarUrl}
|
avatarUrl={creatorAvatarUrl}
|
||||||
noLink={disabled}
|
noLink={disabled}
|
||||||
size={6}
|
size={6}
|
||||||
/>
|
/>
|
||||||
|
@ -178,6 +179,7 @@ export function ContractDetails(props: {
|
||||||
className="whitespace-nowrap"
|
className="whitespace-nowrap"
|
||||||
name={creatorName}
|
name={creatorName}
|
||||||
username={creatorUsername}
|
username={creatorUsername}
|
||||||
|
short={isMobile}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!disabled && <UserFollowButton userId={creatorId} small />}
|
{!disabled && <UserFollowButton userId={creatorId} small />}
|
||||||
|
@ -189,19 +191,8 @@ export function ContractDetails(props: {
|
||||||
<div />
|
<div />
|
||||||
) : (
|
) : (
|
||||||
<Row>
|
<Row>
|
||||||
<Button
|
|
||||||
size={'xs'}
|
|
||||||
className={'max-w-[200px] pr-2'}
|
|
||||||
color={'gray-white'}
|
|
||||||
onClick={() =>
|
|
||||||
groupToDisplay
|
|
||||||
? Router.push(groupPath(groupToDisplay.slug))
|
|
||||||
: setOpen(!open)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{groupInfo}
|
{groupInfo}
|
||||||
</Button>
|
{user && groupToDisplay && (
|
||||||
{user && (
|
|
||||||
<Button
|
<Button
|
||||||
size={'xs'}
|
size={'xs'}
|
||||||
color={'gray-white'}
|
color={'gray-white'}
|
||||||
|
@ -219,23 +210,16 @@ export function ContractDetails(props: {
|
||||||
'max-h-[70vh] min-h-[20rem] overflow-auto rounded bg-white p-6'
|
'max-h-[70vh] min-h-[20rem] overflow-auto rounded bg-white p-6'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ContractGroupsList
|
<ContractGroupsList contract={contract} user={user} />
|
||||||
groupLinks={groupLinks ?? []}
|
|
||||||
contract={contract}
|
|
||||||
user={user}
|
|
||||||
/>
|
|
||||||
</Col>
|
</Col>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{(!!closeTime || !!resolvedDate) && (
|
{(!!closeTime || !!resolvedDate) && (
|
||||||
<Row className="items-center gap-1">
|
<Row className="hidden items-center gap-1 md:inline-flex">
|
||||||
{resolvedDate && contract.resolutionTime ? (
|
{resolvedDate && resolutionTime ? (
|
||||||
<>
|
<>
|
||||||
<ClockIcon className="h-5 w-5" />
|
<ClockIcon className="h-5 w-5" />
|
||||||
<DateTimeTooltip
|
<DateTimeTooltip text="Market resolved:" time={resolutionTime}>
|
||||||
text="Market resolved:"
|
|
||||||
time={contract.resolutionTime}
|
|
||||||
>
|
|
||||||
{resolvedDate}
|
{resolvedDate}
|
||||||
</DateTimeTooltip>
|
</DateTimeTooltip>
|
||||||
</>
|
</>
|
||||||
|
@ -255,17 +239,84 @@ export function ContractDetails(props: {
|
||||||
)}
|
)}
|
||||||
{user && (
|
{user && (
|
||||||
<>
|
<>
|
||||||
<Row className="items-center gap-1">
|
<Row className="hidden items-center gap-1 md:inline-flex">
|
||||||
<DatabaseIcon className="h-5 w-5" />
|
<DatabaseIcon className="h-5 w-5" />
|
||||||
<div className="whitespace-nowrap">{volumeLabel}</div>
|
<div className="whitespace-nowrap">{volumeLabel}</div>
|
||||||
</Row>
|
</Row>
|
||||||
{!disabled && <ContractInfoDialog contract={contract} bets={bets} />}
|
{!disabled && (
|
||||||
|
<ContractInfoDialog
|
||||||
|
contract={contract}
|
||||||
|
className={'hidden md:inline-flex'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ExtraMobileContractDetails(props: {
|
||||||
|
contract: Contract
|
||||||
|
forceShowVolume?: boolean
|
||||||
|
}) {
|
||||||
|
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
|
||||||
|
? 'High'
|
||||||
|
: volume > 300 || uniqueBettors >= 10
|
||||||
|
? 'Medium'
|
||||||
|
: 'Low'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row
|
||||||
|
className={clsx(
|
||||||
|
'items-center justify-around md:hidden',
|
||||||
|
user ? 'w-full' : ''
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{resolvedDate && resolutionTime ? (
|
||||||
|
<Col className={'items-center text-sm'}>
|
||||||
|
<Row className={'text-gray-500'}>
|
||||||
|
<DateTimeTooltip text="Market resolved:" time={resolutionTime}>
|
||||||
|
{resolvedDate}
|
||||||
|
</DateTimeTooltip>
|
||||||
|
</Row>
|
||||||
|
<Row className={'text-gray-400'}>Ended</Row>
|
||||||
|
</Col>
|
||||||
|
) : (
|
||||||
|
!resolvedDate &&
|
||||||
|
closeTime && (
|
||||||
|
<Col className={'items-center text-sm text-gray-500'}>
|
||||||
|
<EditableCloseDate
|
||||||
|
closeTime={closeTime}
|
||||||
|
contract={contract}
|
||||||
|
isCreator={creatorId === user?.id}
|
||||||
|
/>
|
||||||
|
<Row className={'text-gray-400'}>Ends</Row>
|
||||||
|
</Col>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{(user || forceShowVolume) && (
|
||||||
|
<Col className={'items-center text-sm text-gray-500'}>
|
||||||
|
<Tooltip
|
||||||
|
text={`${formatMoney(
|
||||||
|
volume
|
||||||
|
)} bet - ${uniqueBettors} unique bettors`}
|
||||||
|
>
|
||||||
|
{volumeTranslation}
|
||||||
|
</Tooltip>
|
||||||
|
<Row className={'text-gray-400'}>Activity</Row>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function EditableCloseDate(props: {
|
function EditableCloseDate(props: {
|
||||||
closeTime: number
|
closeTime: number
|
||||||
contract: Contract
|
contract: Contract
|
||||||
|
@ -318,10 +369,10 @@ function EditableCloseDate(props: {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isEditingCloseTime ? (
|
{isEditingCloseTime ? (
|
||||||
<Row className="mr-1 items-start">
|
<Row className="z-10 mr-2 w-full shrink-0 items-start items-center gap-1">
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
className="input input-bordered"
|
className="input input-bordered shrink-0"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onChange={(e) => setCloseDate(e.target.value)}
|
onChange={(e) => setCloseDate(e.target.value)}
|
||||||
min={Date.now()}
|
min={Date.now()}
|
||||||
|
@ -329,39 +380,35 @@ function EditableCloseDate(props: {
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="time"
|
type="time"
|
||||||
className="input input-bordered ml-2"
|
className="input input-bordered shrink-0"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onChange={(e) => setCloseHoursMinutes(e.target.value)}
|
onChange={(e) => setCloseHoursMinutes(e.target.value)}
|
||||||
min="00:00"
|
min="00:00"
|
||||||
value={closeHoursMinutes}
|
value={closeHoursMinutes}
|
||||||
/>
|
/>
|
||||||
|
<Button size={'xs'} color={'blue'} onClick={onSave}>
|
||||||
|
Done
|
||||||
|
</Button>
|
||||||
</Row>
|
</Row>
|
||||||
) : (
|
) : (
|
||||||
<DateTimeTooltip
|
<DateTimeTooltip
|
||||||
text={closeTime > Date.now() ? 'Trading ends:' : 'Trading ended:'}
|
text={closeTime > Date.now() ? 'Trading ends:' : 'Trading ended:'}
|
||||||
time={closeTime}
|
time={closeTime}
|
||||||
>
|
>
|
||||||
{isSameYear
|
<span
|
||||||
? dayJsCloseTime.format('MMM D')
|
className={isCreator ? 'cursor-pointer' : ''}
|
||||||
: dayJsCloseTime.format('MMM D, YYYY')}
|
onClick={() => isCreator && setIsEditingCloseTime(true)}
|
||||||
{isSameDay && <> ({fromNow(closeTime)})</>}
|
>
|
||||||
|
{isSameDay ? (
|
||||||
|
<span className={'capitalize'}> {fromNow(closeTime)}</span>
|
||||||
|
) : isSameYear ? (
|
||||||
|
dayJsCloseTime.format('MMM D')
|
||||||
|
) : (
|
||||||
|
dayJsCloseTime.format('MMM D, YYYY')
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</DateTimeTooltip>
|
</DateTimeTooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isCreator &&
|
|
||||||
(isEditingCloseTime ? (
|
|
||||||
<button className="btn btn-xs" onClick={onSave}>
|
|
||||||
Done
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
size={'xs'}
|
|
||||||
color={'gray-white'}
|
|
||||||
onClick={() => setIsEditingCloseTime(true)}
|
|
||||||
>
|
|
||||||
<PencilIcon className="!container mr-0.5 mb-0.5 inline h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,7 @@
|
||||||
import { DotsHorizontalIcon } from '@heroicons/react/outline'
|
import { DotsHorizontalIcon } from '@heroicons/react/outline'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { uniqBy } from 'lodash'
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Bet } from 'common/bet'
|
|
||||||
|
|
||||||
import { Contract } from 'common/contract'
|
import { Contract } from 'common/contract'
|
||||||
import { formatMoney } from 'common/util/format'
|
import { formatMoney } from 'common/util/format'
|
||||||
|
@ -18,12 +16,17 @@ import { SiteLink } from '../site-link'
|
||||||
import { firestoreConsolePath } from 'common/envs/constants'
|
import { firestoreConsolePath } from 'common/envs/constants'
|
||||||
import { deleteField } from 'firebase/firestore'
|
import { deleteField } from 'firebase/firestore'
|
||||||
import ShortToggle from '../widgets/short-toggle'
|
import ShortToggle from '../widgets/short-toggle'
|
||||||
|
import { DuplicateContractButton } from '../copy-contract-button'
|
||||||
|
import { Row } from '../layout/row'
|
||||||
|
|
||||||
export const contractDetailsButtonClassName =
|
export const contractDetailsButtonClassName =
|
||||||
'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500'
|
'group flex items-center rounded-md px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-100 text-gray-400 hover:text-gray-500'
|
||||||
|
|
||||||
export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
export function ContractInfoDialog(props: {
|
||||||
const { contract, bets } = props
|
contract: Contract
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const { contract, className } = props
|
||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [featured, setFeatured] = useState(
|
const [featured, setFeatured] = useState(
|
||||||
|
@ -37,11 +40,7 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
||||||
const { createdTime, closeTime, resolutionTime, mechanism, outcomeType, id } =
|
const { createdTime, closeTime, resolutionTime, mechanism, outcomeType, id } =
|
||||||
contract
|
contract
|
||||||
|
|
||||||
const tradersCount = uniqBy(
|
const bettorsCount = contract.uniqueBettorCount ?? 'Unknown'
|
||||||
bets.filter((bet) => !bet.isAnte),
|
|
||||||
'userId'
|
|
||||||
).length
|
|
||||||
|
|
||||||
const typeDisplay =
|
const typeDisplay =
|
||||||
outcomeType === 'BINARY'
|
outcomeType === 'BINARY'
|
||||||
? 'YES / NO'
|
? 'YES / NO'
|
||||||
|
@ -69,7 +68,7 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
className={contractDetailsButtonClassName}
|
className={clsx(contractDetailsButtonClassName, className)}
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
>
|
>
|
||||||
<DotsHorizontalIcon
|
<DotsHorizontalIcon
|
||||||
|
@ -136,8 +135,8 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
||||||
</tr> */}
|
</tr> */}
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td>Traders</td>
|
<td>Bettors</td>
|
||||||
<td>{tradersCount}</td>
|
<td>{bettorsCount}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -188,6 +187,9 @@ export function ContractInfoDialog(props: { contract: Contract; bets: Bet[] }) {
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
<Row className="flex-wrap">
|
||||||
|
<DuplicateContractButton contract={contract} />
|
||||||
|
</Row>
|
||||||
{contract.mechanism === 'cpmm-1' && !contract.resolution && (
|
{contract.mechanism === 'cpmm-1' && !contract.resolution && (
|
||||||
<LiquidityPanel contract={contract} />
|
<LiquidityPanel contract={contract} />
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import clsx from 'clsx'
|
|
||||||
|
|
||||||
import { tradingAllowed } from 'web/lib/firebase/contracts'
|
import { tradingAllowed } from 'web/lib/firebase/contracts'
|
||||||
import { Col } from '../layout/col'
|
import { Col } from '../layout/col'
|
||||||
|
@ -16,143 +15,154 @@ import {
|
||||||
import { Bet } from 'common/bet'
|
import { Bet } from 'common/bet'
|
||||||
import BetButton from '../bet-button'
|
import BetButton from '../bet-button'
|
||||||
import { AnswersGraph } from '../answers/answers-graph'
|
import { AnswersGraph } from '../answers/answers-graph'
|
||||||
import { Contract, CPMMBinaryContract } from 'common/contract'
|
import {
|
||||||
import { ContractDescription } from './contract-description'
|
Contract,
|
||||||
import { ContractDetails } from './contract-details'
|
BinaryContract,
|
||||||
|
CPMMContract,
|
||||||
|
CPMMBinaryContract,
|
||||||
|
FreeResponseContract,
|
||||||
|
MultipleChoiceContract,
|
||||||
|
NumericContract,
|
||||||
|
PseudoNumericContract,
|
||||||
|
} from 'common/contract'
|
||||||
|
import { ContractDetails, ExtraMobileContractDetails } from './contract-details'
|
||||||
import { NumericGraph } from './numeric-graph'
|
import { NumericGraph } from './numeric-graph'
|
||||||
import { ShareRow } from './share-row'
|
|
||||||
import { LikeMarketButton } from 'web/components/contract/like-market-button'
|
const OverviewQuestion = (props: { text: string }) => (
|
||||||
|
<Linkify className="text-2xl text-indigo-700 md:text-3xl" text={props.text} />
|
||||||
|
)
|
||||||
|
|
||||||
|
const BetWidget = (props: { contract: CPMMContract }) => {
|
||||||
|
const user = useUser()
|
||||||
|
return (
|
||||||
|
<Col>
|
||||||
|
<BetButton contract={props.contract} />
|
||||||
|
{!user && (
|
||||||
|
<div className="mt-1 text-center text-sm text-gray-500">
|
||||||
|
(with play money!)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Col>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const NumericOverview = (props: { contract: NumericContract }) => {
|
||||||
|
const { contract } = props
|
||||||
|
return (
|
||||||
|
<Col className="gap-1 md:gap-2">
|
||||||
|
<Col className="gap-3 px-2 sm:gap-4">
|
||||||
|
<ContractDetails contract={contract} />
|
||||||
|
<Row className="justify-between gap-4">
|
||||||
|
<OverviewQuestion text={contract.question} />
|
||||||
|
<NumericResolutionOrExpectation
|
||||||
|
contract={contract}
|
||||||
|
className="hidden items-end xl:flex"
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
<NumericResolutionOrExpectation
|
||||||
|
className="items-center justify-between gap-4 xl:hidden"
|
||||||
|
contract={contract}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<NumericGraph contract={contract} />
|
||||||
|
</Col>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const BinaryOverview = (props: { contract: BinaryContract; bets: Bet[] }) => {
|
||||||
|
const { contract, bets } = props
|
||||||
|
return (
|
||||||
|
<Col className="gap-1 md:gap-2">
|
||||||
|
<Col className="gap-3 px-2 sm:gap-4">
|
||||||
|
<ContractDetails contract={contract} />
|
||||||
|
<Row className="justify-between gap-4">
|
||||||
|
<OverviewQuestion text={contract.question} />
|
||||||
|
<BinaryResolutionOrChance
|
||||||
|
className="hidden items-end xl:flex"
|
||||||
|
contract={contract}
|
||||||
|
large
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
<Row className="items-center justify-between gap-4 xl:hidden">
|
||||||
|
<BinaryResolutionOrChance contract={contract} />
|
||||||
|
<ExtraMobileContractDetails contract={contract} />
|
||||||
|
{tradingAllowed(contract) && (
|
||||||
|
<BetWidget contract={contract as CPMMBinaryContract} />
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
</Col>
|
||||||
|
<ContractProbGraph contract={contract} bets={[...bets].reverse()} />
|
||||||
|
</Col>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChoiceOverview = (props: {
|
||||||
|
contract: FreeResponseContract | MultipleChoiceContract
|
||||||
|
bets: Bet[]
|
||||||
|
}) => {
|
||||||
|
const { contract, bets } = props
|
||||||
|
const { question, resolution } = contract
|
||||||
|
return (
|
||||||
|
<Col className="gap-1 md:gap-2">
|
||||||
|
<Col className="gap-3 px-2 sm:gap-4">
|
||||||
|
<ContractDetails contract={contract} />
|
||||||
|
<OverviewQuestion text={question} />
|
||||||
|
{resolution && (
|
||||||
|
<FreeResponseResolutionOrChance contract={contract} truncate="none" />
|
||||||
|
)}
|
||||||
|
</Col>
|
||||||
|
<Col className={'mb-1 gap-y-2'}>
|
||||||
|
<AnswersGraph contract={contract} bets={[...bets].reverse()} />
|
||||||
|
<ExtraMobileContractDetails
|
||||||
|
contract={contract}
|
||||||
|
forceShowVolume={true}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Col>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const PseudoNumericOverview = (props: {
|
||||||
|
contract: PseudoNumericContract
|
||||||
|
bets: Bet[]
|
||||||
|
}) => {
|
||||||
|
const { contract, bets } = props
|
||||||
|
return (
|
||||||
|
<Col className="gap-1 md:gap-2">
|
||||||
|
<Col className="gap-3 px-2 sm:gap-4">
|
||||||
|
<ContractDetails contract={contract} />
|
||||||
|
<Row className="justify-between gap-4">
|
||||||
|
<OverviewQuestion text={contract.question} />
|
||||||
|
<PseudoNumericResolutionOrExpectation
|
||||||
|
contract={contract}
|
||||||
|
className="hidden items-end xl:flex"
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
<Row className="items-center justify-between gap-4 xl:hidden">
|
||||||
|
<PseudoNumericResolutionOrExpectation contract={contract} />
|
||||||
|
<ExtraMobileContractDetails contract={contract} />
|
||||||
|
{tradingAllowed(contract) && <BetWidget contract={contract} />}
|
||||||
|
</Row>
|
||||||
|
</Col>
|
||||||
|
<ContractProbGraph contract={contract} bets={[...bets].reverse()} />
|
||||||
|
</Col>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const ContractOverview = (props: {
|
export const ContractOverview = (props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
bets: Bet[]
|
bets: Bet[]
|
||||||
className?: string
|
|
||||||
}) => {
|
}) => {
|
||||||
const { contract, bets, className } = props
|
const { contract, bets } = props
|
||||||
const { question, creatorId, outcomeType, resolution } = contract
|
switch (contract.outcomeType) {
|
||||||
|
case 'BINARY':
|
||||||
const user = useUser()
|
return <BinaryOverview contract={contract} bets={bets} />
|
||||||
const isCreator = user?.id === creatorId
|
case 'NUMERIC':
|
||||||
|
return <NumericOverview contract={contract} />
|
||||||
const isBinary = outcomeType === 'BINARY'
|
case 'PSEUDO_NUMERIC':
|
||||||
const isPseudoNumeric = outcomeType === 'PSEUDO_NUMERIC'
|
return <PseudoNumericOverview contract={contract} bets={bets} />
|
||||||
|
case 'FREE_RESPONSE':
|
||||||
return (
|
case 'MULTIPLE_CHOICE':
|
||||||
<Col className={clsx('mb-6', className)}>
|
return <ChoiceOverview contract={contract} bets={bets} />
|
||||||
<Col className="gap-3 px-2 sm:gap-4">
|
}
|
||||||
<Row className="justify-between gap-4">
|
|
||||||
<div className="text-2xl text-indigo-700 md:text-3xl">
|
|
||||||
<Linkify text={question} />
|
|
||||||
</div>
|
|
||||||
{(outcomeType === 'FREE_RESPONSE' ||
|
|
||||||
outcomeType === 'MULTIPLE_CHOICE') &&
|
|
||||||
!resolution && (
|
|
||||||
<div className={'sm:hidden'}>
|
|
||||||
<LikeMarketButton contract={contract} user={user} />
|
|
||||||
</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} />
|
|
||||||
{tradingAllowed(contract) && (
|
|
||||||
<Row>
|
|
||||||
<div className={'sm:hidden'}>
|
|
||||||
<LikeMarketButton contract={contract} user={user} />
|
|
||||||
</div>
|
|
||||||
<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} />
|
|
||||||
{tradingAllowed(contract) && (
|
|
||||||
<Row>
|
|
||||||
<div className={'sm:hidden'}>
|
|
||||||
<LikeMarketButton contract={contract} user={user} />
|
|
||||||
</div>
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ContractDetails
|
|
||||||
contract={contract}
|
|
||||||
bets={bets}
|
|
||||||
isCreator={isCreator}
|
|
||||||
user={user}
|
|
||||||
/>
|
|
||||||
</Col>
|
|
||||||
<div className={'my-1 md:my-2'}></div>
|
|
||||||
{(isBinary || isPseudoNumeric) && (
|
|
||||||
<ContractProbGraph contract={contract} bets={bets} />
|
|
||||||
)}{' '}
|
|
||||||
{(outcomeType === 'FREE_RESPONSE' ||
|
|
||||||
outcomeType === 'MULTIPLE_CHOICE') && (
|
|
||||||
<AnswersGraph contract={contract} bets={bets} />
|
|
||||||
)}
|
|
||||||
{outcomeType === 'NUMERIC' && <NumericGraph contract={contract} />}
|
|
||||||
<ShareRow user={user} contract={contract} />
|
|
||||||
<ContractDescription
|
|
||||||
className="px-2"
|
|
||||||
contract={contract}
|
|
||||||
isCreator={isCreator}
|
|
||||||
/>
|
|
||||||
</Col>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,6 +27,7 @@ export function ContractsGrid(props: {
|
||||||
}
|
}
|
||||||
highlightOptions?: ContractHighlightOptions
|
highlightOptions?: ContractHighlightOptions
|
||||||
trackingPostfix?: string
|
trackingPostfix?: string
|
||||||
|
breakpointColumns?: { [key: string]: number }
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
contracts,
|
contracts,
|
||||||
|
@ -67,7 +68,7 @@ export function ContractsGrid(props: {
|
||||||
<Col className="gap-8">
|
<Col className="gap-8">
|
||||||
<Masonry
|
<Masonry
|
||||||
// Show only 1 column on tailwind's md breakpoint (768px)
|
// Show only 1 column on tailwind's md breakpoint (768px)
|
||||||
breakpointCols={{ default: 2, 768: 1 }}
|
breakpointCols={props.breakpointColumns ?? { default: 2, 768: 1 }}
|
||||||
className="-ml-4 flex w-auto"
|
className="-ml-4 flex w-auto"
|
||||||
columnClassName="pl-4 bg-clip-padding"
|
columnClassName="pl-4 bg-clip-padding"
|
||||||
>
|
>
|
||||||
|
|
|
@ -3,31 +3,30 @@ import { ShareIcon } from '@heroicons/react/outline'
|
||||||
|
|
||||||
import { Row } from '../layout/row'
|
import { Row } from '../layout/row'
|
||||||
import { Contract } from 'web/lib/firebase/contracts'
|
import { Contract } from 'web/lib/firebase/contracts'
|
||||||
import { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { Button } from 'web/components/button'
|
import { Button } from 'web/components/button'
|
||||||
import { CreateChallengeModal } from '../challenges/create-challenge-modal'
|
import { useUser } from 'web/hooks/use-user'
|
||||||
import { User } from 'common/user'
|
|
||||||
import { CHALLENGES_ENABLED } from 'common/challenge'
|
|
||||||
import { ShareModal } from './share-modal'
|
import { ShareModal } from './share-modal'
|
||||||
import { withTracking } from 'web/lib/service/analytics'
|
|
||||||
import { FollowMarketButton } from 'web/components/follow-market-button'
|
import { FollowMarketButton } from 'web/components/follow-market-button'
|
||||||
import { LikeMarketButton } from 'web/components/contract/like-market-button'
|
import { LikeMarketButton } from 'web/components/contract/like-market-button'
|
||||||
|
import { ContractInfoDialog } from 'web/components/contract/contract-info-dialog'
|
||||||
|
import { 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'
|
||||||
|
|
||||||
export function ShareRow(props: {
|
export function ExtraContractActionsRow(props: { contract: Contract }) {
|
||||||
contract: Contract
|
const { contract } = props
|
||||||
user: User | undefined | null
|
|
||||||
}) {
|
|
||||||
const { user, contract } = props
|
|
||||||
const { outcomeType, resolution } = contract
|
const { outcomeType, resolution } = contract
|
||||||
|
const user = useUser()
|
||||||
|
const [isShareOpen, setShareOpen] = useState(false)
|
||||||
|
const [openCreateChallengeModal, setOpenCreateChallengeModal] =
|
||||||
|
useState(false)
|
||||||
const showChallenge =
|
const showChallenge =
|
||||||
user && outcomeType === 'BINARY' && !resolution && CHALLENGES_ENABLED
|
user && outcomeType === 'BINARY' && !resolution && CHALLENGES_ENABLED
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
|
||||||
const [isShareOpen, setShareOpen] = useState(false)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row className="mt-0.5 sm:mt-2">
|
<Row className={'mt-0.5 justify-around sm:mt-2 lg:justify-start'}>
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
color="gray-white"
|
color="gray-white"
|
||||||
|
@ -36,8 +35,14 @@ export function ShareRow(props: {
|
||||||
setShareOpen(true)
|
setShareOpen(true)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ShareIcon className={clsx('mr-2 h-[24px] w-5')} aria-hidden="true" />
|
<Col className={'items-center sm:flex-row'}>
|
||||||
Share
|
<ShareIcon
|
||||||
|
className={clsx('h-[24px] w-5 sm:mr-2')}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span>Share</span>
|
||||||
|
</Col>
|
||||||
|
|
||||||
<ShareModal
|
<ShareModal
|
||||||
isOpen={isShareOpen}
|
isOpen={isShareOpen}
|
||||||
setOpen={setShareOpen}
|
setOpen={setShareOpen}
|
||||||
|
@ -45,29 +50,33 @@ export function ShareRow(props: {
|
||||||
user={user}
|
user={user}
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{showChallenge && (
|
{showChallenge && (
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
color="gray-white"
|
color="gray-white"
|
||||||
|
className={'flex hidden max-w-xs self-center sm:inline-block'}
|
||||||
onClick={withTracking(
|
onClick={withTracking(
|
||||||
() => setIsOpen(true),
|
() => setOpenCreateChallengeModal(true),
|
||||||
'click challenge button'
|
'click challenge button'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
⚔️ Challenge
|
<span>⚔️ Challenge</span>
|
||||||
<CreateChallengeModal
|
<CreateChallengeModal
|
||||||
isOpen={isOpen}
|
isOpen={openCreateChallengeModal}
|
||||||
setOpen={setIsOpen}
|
setOpen={setOpenCreateChallengeModal}
|
||||||
user={user}
|
user={user}
|
||||||
contract={contract}
|
contract={contract}
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<FollowMarketButton contract={contract} user={user} />
|
<FollowMarketButton contract={contract} user={user} />
|
||||||
<div className={'hidden sm:block'}>
|
{user?.id !== contract.creatorId && (
|
||||||
<LikeMarketButton contract={contract} user={user} />
|
<LikeMarketButton contract={contract} user={user} />
|
||||||
</div>
|
)}
|
||||||
|
<Col className={'justify-center md:hidden'}>
|
||||||
|
<ContractInfoDialog contract={contract} />
|
||||||
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
)
|
)
|
||||||
}
|
}
|
|
@ -1,33 +1,37 @@
|
||||||
import { HeartIcon } from '@heroicons/react/outline'
|
import { HeartIcon } from '@heroicons/react/outline'
|
||||||
import { Button } from 'web/components/button'
|
import { Button } from 'web/components/button'
|
||||||
import React from 'react'
|
import React, { useMemo } from 'react'
|
||||||
import { Contract } from 'common/contract'
|
import { Contract } from 'common/contract'
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
import { useUserLikes } from 'web/hooks/use-likes'
|
import { useUserLikes } from 'web/hooks/use-likes'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
import { formatMoney } from 'common/util/format'
|
import { formatMoney } from 'common/util/format'
|
||||||
import { likeContract, unLikeContract } from 'web/lib/firebase/likes'
|
import { likeContract } from 'web/lib/firebase/likes'
|
||||||
import { LIKE_TIP_AMOUNT } from 'common/like'
|
import { LIKE_TIP_AMOUNT } from 'common/like'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { Row } from 'web/components/layout/row'
|
import { Col } from 'web/components/layout/col'
|
||||||
|
import { firebaseLogin } from 'web/lib/firebase/users'
|
||||||
|
import { useMarketTipTxns } from 'web/hooks/use-tip-txns'
|
||||||
|
import { sum } from 'lodash'
|
||||||
|
|
||||||
export function LikeMarketButton(props: {
|
export function LikeMarketButton(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
user: User | null | undefined
|
user: User | null | undefined
|
||||||
}) {
|
}) {
|
||||||
const { contract, user } = props
|
const { contract, user } = props
|
||||||
|
const tips = useMarketTipTxns(contract.id).filter(
|
||||||
|
(txn) => txn.fromId === user?.id
|
||||||
|
)
|
||||||
|
const totalTipped = useMemo(() => {
|
||||||
|
return sum(tips.map((tip) => tip.amount))
|
||||||
|
}, [tips])
|
||||||
const likes = useUserLikes(user?.id)
|
const likes = useUserLikes(user?.id)
|
||||||
const likedContractIds = likes
|
const userLikedContractIds = likes
|
||||||
?.filter((l) => l.type === 'contract')
|
?.filter((l) => l.type === 'contract')
|
||||||
.map((l) => l.id)
|
.map((l) => l.id)
|
||||||
if (!user) return <div />
|
|
||||||
|
|
||||||
const onLike = async () => {
|
const onLike = async () => {
|
||||||
if (likedContractIds?.includes(contract.id)) {
|
if (!user) return firebaseLogin()
|
||||||
await unLikeContract(user.id, contract.id)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await likeContract(user, contract)
|
await likeContract(user, contract)
|
||||||
toast(`You tipped ${contract.creatorName} ${formatMoney(LIKE_TIP_AMOUNT)}!`)
|
toast(`You tipped ${contract.creatorName} ${formatMoney(LIKE_TIP_AMOUNT)}!`)
|
||||||
}
|
}
|
||||||
|
@ -39,18 +43,19 @@ export function LikeMarketButton(props: {
|
||||||
color={'gray-white'}
|
color={'gray-white'}
|
||||||
onClick={onLike}
|
onClick={onLike}
|
||||||
>
|
>
|
||||||
<Row className={'gap-0 sm:gap-2'}>
|
<Col className={'items-center sm:flex-row sm:gap-x-2'}>
|
||||||
<HeartIcon
|
<HeartIcon
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'h-6 w-6',
|
'h-6 w-6',
|
||||||
likedContractIds?.includes(contract.id) ||
|
user &&
|
||||||
(!likes && contract.likedByUserIds?.includes(user.id))
|
(userLikedContractIds?.includes(contract.id) ||
|
||||||
|
(!likes && contract.likedByUserIds?.includes(user.id)))
|
||||||
? 'fill-red-500 text-red-500'
|
? 'fill-red-500 text-red-500'
|
||||||
: ''
|
: ''
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<span className={'hidden sm:block'}>Tip</span>
|
Tip {totalTipped > 0 ? `(${formatMoney(totalTipped)})` : ''}
|
||||||
</Row>
|
</Col>
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,12 +12,15 @@ import { TweetButton } from '../tweet-button'
|
||||||
import { DuplicateContractButton } from '../copy-contract-button'
|
import { DuplicateContractButton } from '../copy-contract-button'
|
||||||
import { Button } from '../button'
|
import { Button } from '../button'
|
||||||
import { copyToClipboard } from 'web/lib/util/copy'
|
import { copyToClipboard } from 'web/lib/util/copy'
|
||||||
import { track } from 'web/lib/service/analytics'
|
import { track, withTracking } from 'web/lib/service/analytics'
|
||||||
import { ENV_CONFIG } from 'common/envs/constants'
|
import { ENV_CONFIG } from 'common/envs/constants'
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
import { SiteLink } from '../site-link'
|
import { SiteLink } from '../site-link'
|
||||||
import { formatMoney } from 'common/util/format'
|
import { formatMoney } from 'common/util/format'
|
||||||
import { REFERRAL_AMOUNT } from 'common/economy'
|
import { REFERRAL_AMOUNT } from 'common/economy'
|
||||||
|
import { CreateChallengeModal } from 'web/components/challenges/create-challenge-modal'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { CHALLENGES_ENABLED } from 'common/challenge'
|
||||||
|
|
||||||
export function ShareModal(props: {
|
export function ShareModal(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
|
@ -26,8 +29,13 @@ export function ShareModal(props: {
|
||||||
setOpen: (open: boolean) => void
|
setOpen: (open: boolean) => void
|
||||||
}) {
|
}) {
|
||||||
const { contract, user, isOpen, setOpen } = props
|
const { contract, user, isOpen, setOpen } = props
|
||||||
|
const { outcomeType, resolution } = contract
|
||||||
|
|
||||||
|
const [openCreateChallengeModal, setOpenCreateChallengeModal] =
|
||||||
|
useState(false)
|
||||||
const linkIcon = <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" />
|
const linkIcon = <LinkIcon className="mr-2 h-6 w-6" aria-hidden="true" />
|
||||||
|
const showChallenge =
|
||||||
|
user && outcomeType === 'BINARY' && !resolution && CHALLENGES_ENABLED
|
||||||
|
|
||||||
const shareUrl = `https://${ENV_CONFIG.domain}${contractPath(contract)}${
|
const shareUrl = `https://${ENV_CONFIG.domain}${contractPath(contract)}${
|
||||||
user?.username && contract.creatorUsername !== user?.username
|
user?.username && contract.creatorUsername !== user?.username
|
||||||
|
@ -37,7 +45,7 @@ export function ShareModal(props: {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={isOpen} setOpen={setOpen} size="md">
|
<Modal open={isOpen} setOpen={setOpen} size="md">
|
||||||
<Col className="gap-4 rounded bg-white p-4">
|
<Col className="gap-2.5 rounded bg-white p-4 sm:gap-4">
|
||||||
<Title className="!mt-0 !mb-2" text="Share this market" />
|
<Title className="!mt-0 !mb-2" text="Share this market" />
|
||||||
<p>
|
<p>
|
||||||
Earn{' '}
|
Earn{' '}
|
||||||
|
@ -46,11 +54,10 @@ export function ShareModal(props: {
|
||||||
</SiteLink>{' '}
|
</SiteLink>{' '}
|
||||||
if a new user signs up using the link!
|
if a new user signs up using the link!
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="2xl"
|
size="2xl"
|
||||||
color="gradient"
|
color="gradient"
|
||||||
className={'mb-2 flex max-w-xs self-center'}
|
className={'flex max-w-xs self-center'}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
copyToClipboard(shareUrl)
|
copyToClipboard(shareUrl)
|
||||||
toast.success('Link copied!', {
|
toast.success('Link copied!', {
|
||||||
|
@ -61,8 +68,32 @@ export function ShareModal(props: {
|
||||||
>
|
>
|
||||||
{linkIcon} Copy link
|
{linkIcon} Copy link
|
||||||
</Button>
|
</Button>
|
||||||
|
<Row className={'justify-center'}>or</Row>
|
||||||
<Row className="z-0 justify-start gap-4 self-center">
|
{showChallenge && (
|
||||||
|
<Button
|
||||||
|
size="2xl"
|
||||||
|
color="gradient"
|
||||||
|
className={'mb-2 flex max-w-xs self-center'}
|
||||||
|
onClick={withTracking(
|
||||||
|
() => setOpenCreateChallengeModal(true),
|
||||||
|
'click challenge button'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>⚔️ Challenge</span>
|
||||||
|
<CreateChallengeModal
|
||||||
|
isOpen={openCreateChallengeModal}
|
||||||
|
setOpen={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setOpenCreateChallengeModal(false)
|
||||||
|
setOpen(false)
|
||||||
|
} else setOpenCreateChallengeModal(open)
|
||||||
|
}}
|
||||||
|
user={user}
|
||||||
|
contract={contract}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Row className="z-0 flex-wrap justify-center gap-4 self-center">
|
||||||
<TweetButton
|
<TweetButton
|
||||||
className="self-start"
|
className="self-start"
|
||||||
tweetText={getTweetText(contract, shareUrl)}
|
tweetText={getTweetText(contract, shareUrl)}
|
||||||
|
|
|
@ -1,27 +1,13 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import clsx from 'clsx'
|
|
||||||
|
|
||||||
import { User } from 'web/lib/firebase/users'
|
|
||||||
import { Button } from './button'
|
import { Button } from './button'
|
||||||
|
|
||||||
export const CreateQuestionButton = (props: {
|
export const CreateQuestionButton = () => {
|
||||||
user: User | null | undefined
|
|
||||||
overrideText?: string
|
|
||||||
className?: string
|
|
||||||
query?: string
|
|
||||||
}) => {
|
|
||||||
const { user, overrideText, className, query } = props
|
|
||||||
|
|
||||||
if (!user || user?.isBannedFromPosting) return <></>
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx('flex justify-center', className)}>
|
<Link href="/create" passHref>
|
||||||
<Link href={`/create${query ? query : ''}`} passHref>
|
|
||||||
<Button color="gradient" size="xl" className="mt-4">
|
<Button color="gradient" size="xl" className="mt-4">
|
||||||
{overrideText ?? 'Create a market'}
|
Create a market
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
52
web/components/double-carousel.tsx
Normal file
52
web/components/double-carousel.tsx
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
import { Contract } from 'common/contract'
|
||||||
|
import { range } from 'lodash'
|
||||||
|
import { Carousel } from 'web/components/carousel'
|
||||||
|
import { ContractCard } from 'web/components/contract/contract-card'
|
||||||
|
import { ShowTime } from 'web/components/contract/contract-details'
|
||||||
|
import { Col } from 'web/components/layout/col'
|
||||||
|
|
||||||
|
export function DoubleCarousel(props: {
|
||||||
|
contracts: Contract[]
|
||||||
|
seeMoreUrl?: string
|
||||||
|
showTime?: ShowTime
|
||||||
|
loadMore?: () => void
|
||||||
|
}) {
|
||||||
|
const { contracts, showTime, loadMore } = props
|
||||||
|
return (
|
||||||
|
<Carousel className="-mx-4 mt-2 sm:-mx-10" loadMore={loadMore}>
|
||||||
|
<div className="shrink-0 sm:w-6" />
|
||||||
|
{contracts.length >= 6
|
||||||
|
? range(0, Math.floor(contracts.length / 2)).map((col) => {
|
||||||
|
const i = col * 2
|
||||||
|
return (
|
||||||
|
<Col key={contracts[i].id}>
|
||||||
|
<ContractCard
|
||||||
|
contract={contracts[i]}
|
||||||
|
className="mb-2 w-96 shrink-0"
|
||||||
|
questionClass="line-clamp-3"
|
||||||
|
trackingPostfix=" tournament"
|
||||||
|
showTime={showTime}
|
||||||
|
/>
|
||||||
|
<ContractCard
|
||||||
|
contract={contracts[i + 1]}
|
||||||
|
className="mb-2 w-96 shrink-0"
|
||||||
|
questionClass="line-clamp-3"
|
||||||
|
trackingPostfix=" tournament"
|
||||||
|
showTime={showTime}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
: contracts.map((c) => (
|
||||||
|
<ContractCard
|
||||||
|
key={c.id}
|
||||||
|
contract={c}
|
||||||
|
className="mb-2 max-h-[220px] w-96 shrink-0"
|
||||||
|
questionClass="line-clamp-3"
|
||||||
|
trackingPostfix=" tournament"
|
||||||
|
showTime={showTime}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Carousel>
|
||||||
|
)
|
||||||
|
}
|
|
@ -73,7 +73,9 @@ export function useTextEditor(props: {
|
||||||
const editorClass = clsx(
|
const editorClass = clsx(
|
||||||
proseClass,
|
proseClass,
|
||||||
!simple && 'min-h-[6em]',
|
!simple && 'min-h-[6em]',
|
||||||
'outline-none pt-2 px-4'
|
'outline-none pt-2 px-4',
|
||||||
|
'prose-img:select-auto',
|
||||||
|
'[&_.ProseMirror-selectednode]:outline-dotted [&_*]:outline-indigo-300' // selected img, emebeds
|
||||||
)
|
)
|
||||||
|
|
||||||
const editor = useEditor(
|
const editor = useEditor(
|
||||||
|
@ -108,10 +110,7 @@ export function useTextEditor(props: {
|
||||||
editor?.setOptions({
|
editor?.setOptions({
|
||||||
editorProps: {
|
editorProps: {
|
||||||
handlePaste(view, event) {
|
handlePaste(view, event) {
|
||||||
const imageFiles = Array.from(event.clipboardData?.files ?? []).filter(
|
const imageFiles = getImages(event.clipboardData)
|
||||||
(file) => file.type.startsWith('image')
|
|
||||||
)
|
|
||||||
|
|
||||||
if (imageFiles.length) {
|
if (imageFiles.length) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
upload.mutate(imageFiles)
|
upload.mutate(imageFiles)
|
||||||
|
@ -126,6 +125,13 @@ export function useTextEditor(props: {
|
||||||
|
|
||||||
return // Otherwise, use default paste handler
|
return // Otherwise, use default paste handler
|
||||||
},
|
},
|
||||||
|
handleDrop(_view, event, _slice, moved) {
|
||||||
|
// if dragged from outside
|
||||||
|
if (!moved) {
|
||||||
|
event.preventDefault()
|
||||||
|
upload.mutate(getImages(event.dataTransfer))
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -136,6 +142,9 @@ export function useTextEditor(props: {
|
||||||
return { editor, upload }
|
return { editor, upload }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getImages = (data: DataTransfer | null) =>
|
||||||
|
Array.from(data?.files ?? []).filter((file) => file.type.startsWith('image'))
|
||||||
|
|
||||||
function isValidIframe(text: string) {
|
function isValidIframe(text: string) {
|
||||||
return /^<iframe.*<\/iframe>$/.test(text)
|
return /^<iframe.*<\/iframe>$/.test(text)
|
||||||
}
|
}
|
||||||
|
@ -157,7 +166,7 @@ export function TextEditor(props: {
|
||||||
<EditorContent editor={editor} />
|
<EditorContent editor={editor} />
|
||||||
{/* Toolbar, with buttons for images and embeds */}
|
{/* Toolbar, with buttons for images and embeds */}
|
||||||
<div className="flex h-9 items-center gap-5 pl-4 pr-1">
|
<div className="flex h-9 items-center gap-5 pl-4 pr-1">
|
||||||
<Tooltip className="flex items-center" text="Add image" noTap>
|
<Tooltip text="Add image" noTap noFade>
|
||||||
<FileUploadButton
|
<FileUploadButton
|
||||||
onFiles={upload.mutate}
|
onFiles={upload.mutate}
|
||||||
className="-m-2.5 flex h-10 w-10 items-center justify-center rounded-full text-gray-400 hover:text-gray-500"
|
className="-m-2.5 flex h-10 w-10 items-center justify-center rounded-full text-gray-400 hover:text-gray-500"
|
||||||
|
@ -165,7 +174,7 @@ export function TextEditor(props: {
|
||||||
<PhotographIcon className="h-5 w-5" aria-hidden="true" />
|
<PhotographIcon className="h-5 w-5" aria-hidden="true" />
|
||||||
</FileUploadButton>
|
</FileUploadButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip className="flex items-center" text="Add embed" noTap>
|
<Tooltip text="Add embed" noTap noFade>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIframeOpen(true)}
|
onClick={() => setIframeOpen(true)}
|
||||||
|
@ -179,7 +188,7 @@ export function TextEditor(props: {
|
||||||
<CodeIcon className="h-5 w-5" aria-hidden="true" />
|
<CodeIcon className="h-5 w-5" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip className="flex items-center" text="Add market" noTap>
|
<Tooltip text="Add market" noTap noFade>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setMarketOpen(true)}
|
onClick={() => setMarketOpen(true)}
|
||||||
|
|
|
@ -7,7 +7,7 @@ import { Col } from '../layout/col'
|
||||||
import { Modal } from '../layout/modal'
|
import { Modal } from '../layout/modal'
|
||||||
import { Row } from '../layout/row'
|
import { Row } from '../layout/row'
|
||||||
import { LoadingIndicator } from '../loading-indicator'
|
import { LoadingIndicator } from '../loading-indicator'
|
||||||
import { embedCode } from '../share-embed-button'
|
import { embedContractCode, embedContractGridCode } from '../share-embed-button'
|
||||||
import { insertContent } from './utils'
|
import { insertContent } from './utils'
|
||||||
|
|
||||||
export function MarketModal(props: {
|
export function MarketModal(props: {
|
||||||
|
@ -28,7 +28,11 @@ export function MarketModal(props: {
|
||||||
|
|
||||||
async function doneAddingContracts() {
|
async function doneAddingContracts() {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
insertContent(editor, ...contracts.map(embedCode))
|
if (contracts.length == 1) {
|
||||||
|
insertContent(editor, embedContractCode(contracts[0]))
|
||||||
|
} else if (contracts.length > 1) {
|
||||||
|
insertContent(editor, embedContractGridCode(contracts))
|
||||||
|
}
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
setContracts([])
|
setContracts([])
|
||||||
|
@ -42,9 +46,14 @@ export function MarketModal(props: {
|
||||||
|
|
||||||
{!loading && (
|
{!loading && (
|
||||||
<Row className="grow justify-end gap-4">
|
<Row className="grow justify-end gap-4">
|
||||||
{contracts.length > 0 && (
|
{contracts.length == 1 && (
|
||||||
<Button onClick={doneAddingContracts} color={'indigo'}>
|
<Button onClick={doneAddingContracts} color={'indigo'}>
|
||||||
Embed {contracts.length} question
|
Embed 1 question
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{contracts.length > 1 && (
|
||||||
|
<Button onClick={doneAddingContracts} color={'indigo'}>
|
||||||
|
Embed grid of {contracts.length} question
|
||||||
{contracts.length > 1 && 's'}
|
{contracts.length > 1 && 's'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -84,7 +84,10 @@ export function ContractCommentsActivity(props: {
|
||||||
user={user}
|
user={user}
|
||||||
contract={contract}
|
contract={contract}
|
||||||
parentComment={parent}
|
parentComment={parent}
|
||||||
threadComments={commentsByParentId[parent.id] ?? []}
|
threadComments={sortBy(
|
||||||
|
commentsByParentId[parent.id] ?? [],
|
||||||
|
(c) => c.createdTime
|
||||||
|
)}
|
||||||
tips={tips}
|
tips={tips}
|
||||||
bets={bets}
|
bets={bets}
|
||||||
betsByUserId={betsByUserId}
|
betsByUserId={betsByUserId}
|
||||||
|
@ -132,7 +135,10 @@ export function FreeResponseContractCommentsActivity(props: {
|
||||||
contract={contract}
|
contract={contract}
|
||||||
user={user}
|
user={user}
|
||||||
answer={answer}
|
answer={answer}
|
||||||
answerComments={commentsByOutcome[answer.number.toString()] ?? []}
|
answerComments={sortBy(
|
||||||
|
commentsByOutcome[answer.number.toString()] ?? [],
|
||||||
|
(c) => c.createdTime
|
||||||
|
)}
|
||||||
tips={tips}
|
tips={tips}
|
||||||
betsByUserId={betsByUserId}
|
betsByUserId={betsByUserId}
|
||||||
commentsByUserId={commentsByUserId}
|
commentsByUserId={commentsByUserId}
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import clsx from 'clsx'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
import { useUser, useUserById } from 'web/hooks/use-user'
|
import { useUser, useUserById } from 'web/hooks/use-user'
|
||||||
|
@ -24,7 +25,6 @@ export function FeedLiquidity(props: {
|
||||||
const isSelf = user?.id === userId
|
const isSelf = user?.id === userId
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<Row className="flex w-full gap-2 pt-3">
|
<Row className="flex w-full gap-2 pt-3">
|
||||||
{isSelf ? (
|
{isSelf ? (
|
||||||
<Avatar avatarUrl={user.avatarUrl} username={user.username} />
|
<Avatar avatarUrl={user.avatarUrl} username={user.username} />
|
||||||
|
@ -35,15 +35,13 @@ export function FeedLiquidity(props: {
|
||||||
<EmptyAvatar />
|
<EmptyAvatar />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={'min-w-0 flex-1 py-1.5'}>
|
|
||||||
<LiquidityStatusText
|
<LiquidityStatusText
|
||||||
liquidity={liquidity}
|
liquidity={liquidity}
|
||||||
isSelf={isSelf}
|
isSelf={isSelf}
|
||||||
bettor={bettor}
|
bettor={bettor}
|
||||||
|
className={'flex-1'}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</Row>
|
</Row>
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -51,8 +49,9 @@ export function LiquidityStatusText(props: {
|
||||||
liquidity: LiquidityProvision
|
liquidity: LiquidityProvision
|
||||||
isSelf: boolean
|
isSelf: boolean
|
||||||
bettor?: User
|
bettor?: User
|
||||||
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
const { liquidity, bettor, isSelf } = props
|
const { liquidity, bettor, isSelf, className } = props
|
||||||
const { amount, createdTime } = liquidity
|
const { amount, createdTime } = liquidity
|
||||||
|
|
||||||
// TODO: Withdrawn liquidity will never be shown, since liquidity amounts currently are zeroed out upon withdrawal.
|
// TODO: Withdrawn liquidity will never be shown, since liquidity amounts currently are zeroed out upon withdrawal.
|
||||||
|
@ -60,7 +59,7 @@ export function LiquidityStatusText(props: {
|
||||||
const money = formatMoney(Math.abs(amount))
|
const money = formatMoney(Math.abs(amount))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="text-sm text-gray-500">
|
<div className={clsx(className, 'text-sm text-gray-500')}>
|
||||||
{bettor ? (
|
{bettor ? (
|
||||||
<UserLink name={bettor.name} username={bettor.username} />
|
<UserLink name={bettor.name} username={bettor.username} />
|
||||||
) : (
|
) : (
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
|
|
@ -13,7 +13,7 @@ import { firebaseLogin, updateUser } from 'web/lib/firebase/users'
|
||||||
import { track } from 'web/lib/service/analytics'
|
import { track } from 'web/lib/service/analytics'
|
||||||
import { FollowMarketModal } from 'web/components/contract/follow-market-modal'
|
import { FollowMarketModal } from 'web/components/contract/follow-market-modal'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Row } from 'web/components/layout/row'
|
import { Col } from 'web/components/layout/col'
|
||||||
|
|
||||||
export const FollowMarketButton = (props: {
|
export const FollowMarketButton = (props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
|
@ -55,15 +55,15 @@ export const FollowMarketButton = (props: {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{followers?.includes(user?.id ?? 'nope') ? (
|
{followers?.includes(user?.id ?? 'nope') ? (
|
||||||
<Row className={'gap-2'}>
|
<Col className={'items-center gap-x-2 sm:flex-row'}>
|
||||||
<EyeOffIcon className={clsx('h-6 w-6')} aria-hidden="true" />
|
<EyeOffIcon className={clsx('h-6 w-6')} aria-hidden="true" />
|
||||||
Unwatch
|
Unwatch
|
||||||
</Row>
|
</Col>
|
||||||
) : (
|
) : (
|
||||||
<Row className={'gap-2'}>
|
<Col className={'items-center gap-x-2 sm:flex-row'}>
|
||||||
<EyeIcon className={clsx('h-6 w-6')} aria-hidden="true" />
|
<EyeIcon className={clsx('h-6 w-6')} aria-hidden="true" />
|
||||||
Watch
|
Watch
|
||||||
</Row>
|
</Col>
|
||||||
)}
|
)}
|
||||||
<FollowMarketModal
|
<FollowMarketModal
|
||||||
open={open}
|
open={open}
|
||||||
|
|
|
@ -2,9 +2,9 @@ import clsx from 'clsx'
|
||||||
import { PencilIcon } from '@heroicons/react/outline'
|
import { PencilIcon } from '@heroicons/react/outline'
|
||||||
|
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
import { useEffect, useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useFollowers, useFollows } from 'web/hooks/use-follows'
|
import { useFollowers, useFollows } from 'web/hooks/use-follows'
|
||||||
import { prefetchUsers, useUser } from 'web/hooks/use-user'
|
import { usePrefetchUsers, useUser } from 'web/hooks/use-user'
|
||||||
import { FollowList } from './follow-list'
|
import { FollowList } from './follow-list'
|
||||||
import { Col } from './layout/col'
|
import { Col } from './layout/col'
|
||||||
import { Modal } from './layout/modal'
|
import { Modal } from './layout/modal'
|
||||||
|
@ -105,16 +105,9 @@ function FollowsDialog(props: {
|
||||||
const { user, followingIds, followerIds, defaultTab, isOpen, setIsOpen } =
|
const { user, followingIds, followerIds, defaultTab, isOpen, setIsOpen } =
|
||||||
props
|
props
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
prefetchUsers([...followingIds, ...followerIds])
|
|
||||||
}, [followingIds, followerIds])
|
|
||||||
|
|
||||||
const currentUser = useUser()
|
const currentUser = useUser()
|
||||||
|
|
||||||
const discoverUserIds = useDiscoverUsers(user?.id)
|
const discoverUserIds = useDiscoverUsers(user?.id)
|
||||||
useEffect(() => {
|
usePrefetchUsers([...followingIds, ...followerIds, ...discoverUserIds])
|
||||||
prefetchUsers(discoverUserIds)
|
|
||||||
}, [discoverUserIds])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={isOpen} setOpen={setIsOpen}>
|
<Modal open={isOpen} setOpen={setIsOpen}>
|
||||||
|
|
|
@ -7,22 +7,30 @@ import { Button } from 'web/components/button'
|
||||||
import { GroupSelector } from 'web/components/groups/group-selector'
|
import { GroupSelector } from 'web/components/groups/group-selector'
|
||||||
import {
|
import {
|
||||||
addContractToGroup,
|
addContractToGroup,
|
||||||
canModifyGroupContracts,
|
|
||||||
removeContractFromGroup,
|
removeContractFromGroup,
|
||||||
} from 'web/lib/firebase/groups'
|
} from 'web/lib/firebase/groups'
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
import { Contract } from 'common/contract'
|
import { Contract } from 'common/contract'
|
||||||
import { SiteLink } from 'web/components/site-link'
|
import { SiteLink } from 'web/components/site-link'
|
||||||
import { GroupLink } from 'common/group'
|
import { useGroupsWithContract, useMemberGroupIds } from 'web/hooks/use-group'
|
||||||
import { useGroupsWithContract } from 'web/hooks/use-group'
|
import { Group } from 'common/group'
|
||||||
|
|
||||||
export function ContractGroupsList(props: {
|
export function ContractGroupsList(props: {
|
||||||
groupLinks: GroupLink[]
|
|
||||||
contract: Contract
|
contract: Contract
|
||||||
user: User | null | undefined
|
user: User | null | undefined
|
||||||
}) {
|
}) {
|
||||||
const { groupLinks, user, contract } = props
|
const { user, contract } = props
|
||||||
|
const { groupLinks } = contract
|
||||||
const groups = useGroupsWithContract(contract)
|
const groups = useGroupsWithContract(contract)
|
||||||
|
const memberGroupIds = useMemberGroupIds(user)
|
||||||
|
|
||||||
|
const canModifyGroupContracts = (group: Group, userId: string) => {
|
||||||
|
return (
|
||||||
|
group.creatorId === userId ||
|
||||||
|
group.anyoneCanJoin ||
|
||||||
|
memberGroupIds?.includes(group.id)
|
||||||
|
)
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Col className={'gap-2'}>
|
<Col className={'gap-2'}>
|
||||||
<span className={'text-xl text-indigo-700'}>
|
<span className={'text-xl text-indigo-700'}>
|
||||||
|
@ -35,7 +43,7 @@ export function ContractGroupsList(props: {
|
||||||
options={{
|
options={{
|
||||||
showSelector: true,
|
showSelector: true,
|
||||||
showLabel: false,
|
showLabel: false,
|
||||||
ignoreGroupIds: groupLinks.map((g) => g.groupId),
|
ignoreGroupIds: groupLinks?.map((g) => g.groupId),
|
||||||
}}
|
}}
|
||||||
setSelectedGroup={(group) =>
|
setSelectedGroup={(group) =>
|
||||||
group && addContractToGroup(group, contract, user.id)
|
group && addContractToGroup(group, contract, user.id)
|
||||||
|
@ -62,7 +70,7 @@ export function ContractGroupsList(props: {
|
||||||
<Button
|
<Button
|
||||||
color={'gray-white'}
|
color={'gray-white'}
|
||||||
size={'xs'}
|
size={'xs'}
|
||||||
onClick={() => removeContractFromGroup(group, contract, user.id)}
|
onClick={() => removeContractFromGroup(group, contract)}
|
||||||
>
|
>
|
||||||
<XIcon className="h-4 w-4 text-gray-500" />
|
<XIcon className="h-4 w-4 text-gray-500" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
@ -3,17 +3,16 @@ import clsx from 'clsx'
|
||||||
import { PencilIcon } from '@heroicons/react/outline'
|
import { PencilIcon } from '@heroicons/react/outline'
|
||||||
|
|
||||||
import { Group } from 'common/group'
|
import { Group } from 'common/group'
|
||||||
import { deleteGroup, updateGroup } from 'web/lib/firebase/groups'
|
import { deleteGroup, joinGroup } from 'web/lib/firebase/groups'
|
||||||
import { Spacer } from '../layout/spacer'
|
import { Spacer } from '../layout/spacer'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { Modal } from 'web/components/layout/modal'
|
import { Modal } from 'web/components/layout/modal'
|
||||||
import { FilterSelectUsers } from 'web/components/filter-select-users'
|
import { FilterSelectUsers } from 'web/components/filter-select-users'
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
import { uniq } from 'lodash'
|
import { useMemberIds } from 'web/hooks/use-group'
|
||||||
|
|
||||||
export function EditGroupButton(props: { group: Group; className?: string }) {
|
export function EditGroupButton(props: { group: Group; className?: string }) {
|
||||||
const { group, className } = props
|
const { group, className } = props
|
||||||
const { memberIds } = group
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const [name, setName] = useState(group.name)
|
const [name, setName] = useState(group.name)
|
||||||
|
@ -21,7 +20,7 @@ export function EditGroupButton(props: { group: Group; className?: string }) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [addMemberUsers, setAddMemberUsers] = useState<User[]>([])
|
const [addMemberUsers, setAddMemberUsers] = useState<User[]>([])
|
||||||
|
const memberIds = useMemberIds(group.id)
|
||||||
function updateOpen(newOpen: boolean) {
|
function updateOpen(newOpen: boolean) {
|
||||||
setAddMemberUsers([])
|
setAddMemberUsers([])
|
||||||
setOpen(newOpen)
|
setOpen(newOpen)
|
||||||
|
@ -33,11 +32,7 @@ export function EditGroupButton(props: { group: Group; className?: string }) {
|
||||||
const onSubmit = async () => {
|
const onSubmit = async () => {
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
|
|
||||||
await updateGroup(group, {
|
await Promise.all(addMemberUsers.map((user) => joinGroup(group, user.id)))
|
||||||
name,
|
|
||||||
about,
|
|
||||||
memberIds: uniq([...memberIds, ...addMemberUsers.map((user) => user.id)]),
|
|
||||||
})
|
|
||||||
|
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
updateOpen(false)
|
updateOpen(false)
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import { useAdmin } from 'web/hooks/use-admin'
|
|
||||||
import { Row } from '../layout/row'
|
import { Row } from '../layout/row'
|
||||||
import { Content } from '../editor'
|
import { Content } from '../editor'
|
||||||
import { TextEditor, useTextEditor } from 'web/components/editor'
|
import { TextEditor, useTextEditor } from 'web/components/editor'
|
||||||
|
@ -16,20 +15,15 @@ import { usePost } from 'web/hooks/use-post'
|
||||||
|
|
||||||
export function GroupAboutPost(props: {
|
export function GroupAboutPost(props: {
|
||||||
group: Group
|
group: Group
|
||||||
isCreator: boolean
|
isEditable: boolean
|
||||||
post: Post
|
post: Post
|
||||||
}) {
|
}) {
|
||||||
const { group, isCreator } = props
|
const { group, isEditable } = props
|
||||||
const post = usePost(group.aboutPostId) ?? props.post
|
const post = usePost(group.aboutPostId) ?? props.post
|
||||||
const isAdmin = useAdmin()
|
|
||||||
|
|
||||||
if (group.aboutPostId == null && !isCreator) {
|
|
||||||
return <p className="text-center">No post has been created </p>
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md bg-white p-4">
|
<div className="rounded-md bg-white p-4">
|
||||||
{isCreator || isAdmin ? (
|
{isEditable ? (
|
||||||
<RichEditGroupAboutPost group={group} post={post} />
|
<RichEditGroupAboutPost group={group} post={post} />
|
||||||
) : (
|
) : (
|
||||||
<Content content={post.content} />
|
<Content content={post.content} />
|
||||||
|
|
|
@ -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}
|
|
||||||
submitOnEnter
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{privateUser && (
|
|
||||||
<GroupChatNotificationsIcon
|
|
||||||
group={group}
|
|
||||||
privateUser={privateUser}
|
|
||||||
shouldSetAsSeen={true}
|
|
||||||
hidden={true}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Col>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GroupChatInBubble(props: {
|
|
||||||
messages: GroupComment[]
|
|
||||||
user: User | null | undefined
|
|
||||||
privateUser: PrivateUser | null | undefined
|
|
||||||
group: Group
|
|
||||||
tips: CommentTipMap
|
|
||||||
}) {
|
|
||||||
const { messages, user, group, tips, privateUser } = props
|
|
||||||
const [shouldShowChat, setShouldShowChat] = useState(false)
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const groupsWithChatEmphasis = [
|
|
||||||
'welcome',
|
|
||||||
'bugs',
|
|
||||||
'manifold-features-25bad7c7792e',
|
|
||||||
'updates',
|
|
||||||
]
|
|
||||||
if (
|
|
||||||
router.asPath.includes('/chat') ||
|
|
||||||
groupsWithChatEmphasis.includes(
|
|
||||||
router.asPath.split('/group/')[1].split('/')[0]
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
setShouldShowChat(true)
|
|
||||||
}
|
|
||||||
// Leave chat open between groups if user is using chat?
|
|
||||||
else {
|
|
||||||
setShouldShowChat(false)
|
|
||||||
}
|
|
||||||
}, [router.asPath])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Col
|
|
||||||
className={clsx(
|
|
||||||
'fixed right-0 bottom-[0px] h-1 w-full sm:bottom-[20px] sm:right-20 sm:w-2/3 md:w-1/2 lg:right-24 lg:w-1/3 xl:right-32 xl:w-1/4',
|
|
||||||
shouldShowChat ? 'p-2m z-10 h-screen bg-white' : ''
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{shouldShowChat && (
|
|
||||||
<GroupChat messages={messages} user={user} group={group} tips={tips} />
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={clsx(
|
|
||||||
'fixed right-1 inline-flex items-center rounded-full border md:right-2 lg:right-5 xl:right-10' +
|
|
||||||
' border-transparent p-3 text-white shadow-sm lg:p-4' +
|
|
||||||
' focus:outline-none focus:ring-2 focus:ring-offset-2 ' +
|
|
||||||
' bottom-[70px] ',
|
|
||||||
shouldShowChat
|
|
||||||
? 'bottom-auto top-2 bg-gray-600 hover:bg-gray-400 focus:ring-gray-500 sm:bottom-[70px] sm:top-auto '
|
|
||||||
: ' bg-indigo-600 hover:bg-indigo-700 focus:ring-indigo-500'
|
|
||||||
)}
|
|
||||||
onClick={() => {
|
|
||||||
// router.push('/chat')
|
|
||||||
setShouldShowChat(!shouldShowChat)
|
|
||||||
track('mobile group chat button')
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{!shouldShowChat ? (
|
|
||||||
<UsersIcon className="h-10 w-10" aria-hidden="true" />
|
|
||||||
) : (
|
|
||||||
<ChevronDownIcon className={'h-10 w-10'} aria-hidden={'true'} />
|
|
||||||
)}
|
|
||||||
{privateUser && (
|
|
||||||
<GroupChatNotificationsIcon
|
|
||||||
group={group}
|
|
||||||
privateUser={privateUser}
|
|
||||||
shouldSetAsSeen={shouldShowChat}
|
|
||||||
hidden={false}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</Col>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function GroupChatNotificationsIcon(props: {
|
|
||||||
group: Group
|
|
||||||
privateUser: PrivateUser
|
|
||||||
shouldSetAsSeen: boolean
|
|
||||||
hidden: boolean
|
|
||||||
}) {
|
|
||||||
const { privateUser, group, shouldSetAsSeen, hidden } = props
|
|
||||||
const notificationsForThisGroup = useUnseenNotifications(
|
|
||||||
privateUser
|
|
||||||
// Disabled tracking by customHref for now.
|
|
||||||
// {
|
|
||||||
// customHref: `/group/${group.slug}`,
|
|
||||||
// }
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!notificationsForThisGroup) return
|
|
||||||
|
|
||||||
notificationsForThisGroup.forEach((notification) => {
|
|
||||||
if (
|
|
||||||
(shouldSetAsSeen && notification.isSeenOnHref?.includes('chat')) ||
|
|
||||||
// old style chat notif that simply ended with the group slug
|
|
||||||
notification.isSeenOnHref?.endsWith(group.slug)
|
|
||||||
) {
|
|
||||||
setNotificationsAsSeen([notification])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}, [group.slug, notificationsForThisGroup, shouldSetAsSeen])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
!hidden &&
|
|
||||||
notificationsForThisGroup &&
|
|
||||||
notificationsForThisGroup.length > 0 &&
|
|
||||||
!shouldSetAsSeen
|
|
||||||
? 'absolute right-4 top-4 h-3 w-3 rounded-full border-2 border-white bg-red-500'
|
|
||||||
: 'hidden'
|
|
||||||
}
|
|
||||||
></div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const GroupMessage = memo(function GroupMessage_(props: {
|
|
||||||
user: User | null | undefined
|
|
||||||
comments: GroupComment[]
|
|
||||||
group: Group
|
|
||||||
onReplyClick?: (comment: Comment) => void
|
|
||||||
setRef?: (ref: HTMLDivElement) => void
|
|
||||||
highlight?: boolean
|
|
||||||
tips: CommentTips
|
|
||||||
}) {
|
|
||||||
const { comments, onReplyClick, group, setRef, highlight, user, tips } = props
|
|
||||||
const first = comments[0]
|
|
||||||
const { id, userUsername, userName, userAvatarUrl, createdTime } = first
|
|
||||||
|
|
||||||
const isCreatorsComment = user && first.userId === user.id
|
|
||||||
return (
|
|
||||||
<Col
|
|
||||||
ref={setRef}
|
|
||||||
className={clsx(
|
|
||||||
isCreatorsComment ? 'mr-2 self-end' : '',
|
|
||||||
'w-fit max-w-sm gap-1 space-x-3 rounded-md bg-white p-1 text-sm text-gray-500 transition-colors duration-1000 sm:max-w-md sm:p-3 sm:leading-[1.3rem]',
|
|
||||||
highlight ? `-m-1 bg-indigo-500/[0.2] p-2` : ''
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Row className={'items-center'}>
|
|
||||||
{!isCreatorsComment && (
|
|
||||||
<Col>
|
|
||||||
<Avatar
|
|
||||||
className={'mx-2 ml-2.5'}
|
|
||||||
size={'xs'}
|
|
||||||
username={userUsername}
|
|
||||||
avatarUrl={userAvatarUrl}
|
|
||||||
/>
|
|
||||||
</Col>
|
|
||||||
)}
|
|
||||||
{!isCreatorsComment ? (
|
|
||||||
<UserLink username={userUsername} name={userName} />
|
|
||||||
) : (
|
|
||||||
<span className={'ml-2.5'}>{'You'}</span>
|
|
||||||
)}
|
|
||||||
<CopyLinkDateTimeComponent
|
|
||||||
prefix={'group'}
|
|
||||||
slug={group.slug}
|
|
||||||
createdTime={createdTime}
|
|
||||||
elementId={id}
|
|
||||||
/>
|
|
||||||
</Row>
|
|
||||||
<div className="mt-2 text-base text-black">
|
|
||||||
{comments.map((comment) => (
|
|
||||||
<Content
|
|
||||||
key={comment.id}
|
|
||||||
content={comment.content || comment.text}
|
|
||||||
smallImage
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<Row>
|
|
||||||
{!isCreatorsComment && onReplyClick && (
|
|
||||||
<button
|
|
||||||
className={
|
|
||||||
'self-start py-1 text-xs font-bold text-gray-500 hover:underline'
|
|
||||||
}
|
|
||||||
onClick={() => onReplyClick(first)}
|
|
||||||
>
|
|
||||||
Reply
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{isCreatorsComment && sum(Object.values(tips)) > 0 && (
|
|
||||||
<span className={'text-primary'}>
|
|
||||||
{formatMoney(sum(Object.values(tips)))}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{!isCreatorsComment && <Tipper comment={first} tips={tips} />}
|
|
||||||
</Row>
|
|
||||||
</Col>
|
|
||||||
)
|
|
||||||
})
|
|
|
@ -1,10 +1,10 @@
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
import { useEffect, useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useUser } from 'web/hooks/use-user'
|
import { useUser } from 'web/hooks/use-user'
|
||||||
import { withTracking } from 'web/lib/service/analytics'
|
import { withTracking } from 'web/lib/service/analytics'
|
||||||
import { Row } from 'web/components/layout/row'
|
import { Row } from 'web/components/layout/row'
|
||||||
import { useMemberGroups } from 'web/hooks/use-group'
|
import { useMemberGroups, useMemberIds } from 'web/hooks/use-group'
|
||||||
import { TextButton } from 'web/components/text-button'
|
import { TextButton } from 'web/components/text-button'
|
||||||
import { Group } from 'common/group'
|
import { Group } from 'common/group'
|
||||||
import { Modal } from 'web/components/layout/modal'
|
import { Modal } from 'web/components/layout/modal'
|
||||||
|
@ -17,9 +17,7 @@ import toast from 'react-hot-toast'
|
||||||
export function GroupsButton(props: { user: User }) {
|
export function GroupsButton(props: { user: User }) {
|
||||||
const { user } = props
|
const { user } = props
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const groups = useMemberGroups(user.id, undefined, {
|
const groups = useMemberGroups(user.id)
|
||||||
by: 'mostRecentChatActivityTime',
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -74,51 +72,34 @@ function GroupsList(props: { groups: Group[] }) {
|
||||||
|
|
||||||
function GroupItem(props: { group: Group; className?: string }) {
|
function GroupItem(props: { group: Group; className?: string }) {
|
||||||
const { group, className } = props
|
const { group, className } = props
|
||||||
|
const user = useUser()
|
||||||
|
const memberIds = useMemberIds(group.id)
|
||||||
return (
|
return (
|
||||||
<Row className={clsx('items-center justify-between gap-2 p-2', className)}>
|
<Row className={clsx('items-center justify-between gap-2 p-2', className)}>
|
||||||
<Row className="line-clamp-1 items-center gap-2">
|
<Row className="line-clamp-1 items-center gap-2">
|
||||||
<GroupLinkItem group={group} />
|
<GroupLinkItem group={group} />
|
||||||
</Row>
|
</Row>
|
||||||
<JoinOrLeaveGroupButton group={group} />
|
<JoinOrLeaveGroupButton
|
||||||
|
group={group}
|
||||||
|
user={user}
|
||||||
|
isMember={user ? memberIds?.includes(user.id) : false}
|
||||||
|
/>
|
||||||
</Row>
|
</Row>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function JoinOrLeaveGroupButton(props: {
|
export function JoinOrLeaveGroupButton(props: {
|
||||||
group: Group
|
group: Group
|
||||||
|
isMember: boolean
|
||||||
|
user: User | undefined | null
|
||||||
small?: boolean
|
small?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
const { group, small, className } = props
|
const { group, small, className, isMember, user } = props
|
||||||
const currentUser = useUser()
|
|
||||||
const [isMember, setIsMember] = useState<boolean>(false)
|
|
||||||
useEffect(() => {
|
|
||||||
if (currentUser && group.memberIds.includes(currentUser.id)) {
|
|
||||||
setIsMember(group.memberIds.includes(currentUser.id))
|
|
||||||
}
|
|
||||||
}, [currentUser, group])
|
|
||||||
|
|
||||||
const onJoinGroup = () => {
|
|
||||||
if (!currentUser) return
|
|
||||||
setIsMember(true)
|
|
||||||
joinGroup(group, currentUser.id).catch(() => {
|
|
||||||
setIsMember(false)
|
|
||||||
toast.error('Failed to join group')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const onLeaveGroup = () => {
|
|
||||||
if (!currentUser) return
|
|
||||||
setIsMember(false)
|
|
||||||
leaveGroup(group, currentUser.id).catch(() => {
|
|
||||||
setIsMember(true)
|
|
||||||
toast.error('Failed to leave group')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const smallStyle =
|
const smallStyle =
|
||||||
'btn !btn-xs border-2 border-gray-500 bg-white normal-case text-gray-500 hover:border-gray-500 hover:bg-white hover:text-gray-500'
|
'btn !btn-xs border-2 border-gray-500 bg-white normal-case text-gray-500 hover:border-gray-500 hover:bg-white hover:text-gray-500'
|
||||||
|
|
||||||
if (!currentUser || isMember === undefined) {
|
if (!user) {
|
||||||
if (!group.anyoneCanJoin)
|
if (!group.anyoneCanJoin)
|
||||||
return <div className={clsx(className, 'text-gray-500')}>Closed</div>
|
return <div className={clsx(className, 'text-gray-500')}>Closed</div>
|
||||||
return (
|
return (
|
||||||
|
@ -130,6 +111,16 @@ export function JoinOrLeaveGroupButton(props: {
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
const onJoinGroup = () => {
|
||||||
|
joinGroup(group, user.id).catch(() => {
|
||||||
|
toast.error('Failed to join group')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const onLeaveGroup = () => {
|
||||||
|
leaveGroup(group, user.id).catch(() => {
|
||||||
|
toast.error('Failed to leave group')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (isMember) {
|
if (isMember) {
|
||||||
return (
|
return (
|
||||||
|
|
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"
|
as="div"
|
||||||
className={clsx(className ? className : 'relative z-40 flex-shrink-0')}
|
className={clsx(className ? className : 'relative z-40 flex-shrink-0')}
|
||||||
>
|
>
|
||||||
<div>
|
|
||||||
<Menu.Button className="w-full rounded-full">
|
<Menu.Button className="w-full rounded-full">
|
||||||
<span className="sr-only">Open user menu</span>
|
<span className="sr-only">Open user menu</span>
|
||||||
{buttonContent}
|
{buttonContent}
|
||||||
</Menu.Button>
|
</Menu.Button>
|
||||||
</div>
|
|
||||||
<Transition
|
<Transition
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
enter="transition ease-out duration-100"
|
enter="transition ease-out duration-100"
|
||||||
|
|
|
@ -11,7 +11,7 @@ export function ProfileSummary(props: { user: User }) {
|
||||||
<Link href={`/${user.username}?tab=bets`}>
|
<Link href={`/${user.username}?tab=bets`}>
|
||||||
<a
|
<a
|
||||||
onClick={trackCallback('sidebar: profile')}
|
onClick={trackCallback('sidebar: profile')}
|
||||||
className="group flex flex-row items-center gap-4 rounded-md py-3 text-gray-500 hover:bg-gray-100 hover:text-gray-700"
|
className="group mb-3 flex flex-row items-center gap-4 truncate rounded-md py-3 text-gray-500 hover:bg-gray-100 hover:text-gray-700"
|
||||||
>
|
>
|
||||||
<Avatar avatarUrl={user.avatarUrl} username={user.username} noLink />
|
<Avatar avatarUrl={user.avatarUrl} username={user.username} noLink />
|
||||||
|
|
||||||
|
|
|
@ -234,11 +234,7 @@ export default function Sidebar(props: { className?: string }) {
|
||||||
|
|
||||||
{!user && <SignInButton className="mb-4" />}
|
{!user && <SignInButton className="mb-4" />}
|
||||||
|
|
||||||
{user && (
|
{user && <ProfileSummary user={user} />}
|
||||||
<div className="min-h-[80px] w-full">
|
|
||||||
<ProfileSummary user={user} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Mobile navigation */}
|
{/* Mobile navigation */}
|
||||||
<div className="flex min-h-0 shrink flex-col gap-1 lg:hidden">
|
<div className="flex min-h-0 shrink flex-col gap-1 lg:hidden">
|
||||||
|
@ -255,7 +251,7 @@ export default function Sidebar(props: { className?: string }) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop navigation */}
|
{/* Desktop navigation */}
|
||||||
<div className="hidden min-h-0 shrink flex-col gap-1 lg:flex">
|
<div className="hidden min-h-0 shrink flex-col items-stretch gap-1 lg:flex ">
|
||||||
{navigationOptions.map((item) => (
|
{navigationOptions.map((item) => (
|
||||||
<SidebarItem key={item.href} item={item} currentPage={currentPage} />
|
<SidebarItem key={item.href} item={item} currentPage={currentPage} />
|
||||||
))}
|
))}
|
||||||
|
@ -264,7 +260,7 @@ export default function Sidebar(props: { className?: string }) {
|
||||||
buttonContent={<MoreButton />}
|
buttonContent={<MoreButton />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{user && <CreateQuestionButton user={user} />}
|
{user && !user.isBannedFromPosting && <CreateQuestionButton />}
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
)
|
)
|
||||||
|
|
|
@ -12,11 +12,9 @@ export default function NotificationsIcon(props: { className?: string }) {
|
||||||
const privateUser = usePrivateUser()
|
const privateUser = usePrivateUser()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row className={clsx('justify-center')}>
|
<Row className="relative justify-center">
|
||||||
<div className={'relative'}>
|
|
||||||
{privateUser && <UnseenNotificationsBubble privateUser={privateUser} />}
|
{privateUser && <UnseenNotificationsBubble privateUser={privateUser} />}
|
||||||
<BellIcon className={clsx(props.className)} />
|
<BellIcon className={clsx(props.className)} />
|
||||||
</div>
|
|
||||||
</Row>
|
</Row>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -32,11 +30,11 @@ function UnseenNotificationsBubble(props: { privateUser: PrivateUser }) {
|
||||||
|
|
||||||
const notifications = useUnseenGroupedNotification(privateUser)
|
const notifications = useUnseenGroupedNotification(privateUser)
|
||||||
if (!notifications || notifications.length === 0 || seen) {
|
if (!notifications || notifications.length === 0 || seen) {
|
||||||
return <div />
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="-mt-0.75 absolute ml-3.5 min-w-[15px] rounded-full bg-indigo-500 p-[2px] text-center text-[10px] leading-3 text-white lg:-mt-1 lg:ml-2">
|
<div className="-mt-0.75 absolute ml-3.5 min-w-[15px] rounded-full bg-indigo-500 p-[2px] text-center text-[10px] leading-3 text-white lg:left-0 lg:-mt-1 lg:ml-2">
|
||||||
{notifications.length > NOTIFICATIONS_PER_PAGE
|
{notifications.length > NOTIFICATIONS_PER_PAGE
|
||||||
? `${NOTIFICATIONS_PER_PAGE}+`
|
? `${NOTIFICATIONS_PER_PAGE}+`
|
||||||
: notifications.length}
|
: notifications.length}
|
||||||
|
|
|
@ -58,7 +58,7 @@ export function Pagination(props: {
|
||||||
|
|
||||||
const maxPage = Math.ceil(totalItems / itemsPerPage) - 1
|
const maxPage = Math.ceil(totalItems / itemsPerPage) - 1
|
||||||
|
|
||||||
if (maxPage === 0) return <Spacer h={4} />
|
if (maxPage <= 0) return <Spacer h={4} />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { prefetchUsers, useUserById } from 'web/hooks/use-user'
|
import { usePrefetchUsers, useUserById } from 'web/hooks/use-user'
|
||||||
import { Col } from './layout/col'
|
import { Col } from './layout/col'
|
||||||
import { Modal } from './layout/modal'
|
import { Modal } from './layout/modal'
|
||||||
import { Tabs } from './layout/tabs'
|
import { Tabs } from './layout/tabs'
|
||||||
|
@ -56,9 +56,7 @@ function ReferralsDialog(props: {
|
||||||
}
|
}
|
||||||
}, [isOpen, referredByUser, user.referredByUserId])
|
}, [isOpen, referredByUser, user.referredByUserId])
|
||||||
|
|
||||||
useEffect(() => {
|
usePrefetchUsers(referralIds)
|
||||||
prefetchUsers(referralIds)
|
|
||||||
}, [referralIds])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={isOpen} setOpen={setIsOpen}>
|
<Modal open={isOpen} setOpen={setIsOpen}>
|
||||||
|
|
|
@ -1,16 +1,15 @@
|
||||||
import { DateTimeTooltip } from './datetime-tooltip'
|
import { DateTimeTooltip } from './datetime-tooltip'
|
||||||
import dayjs from 'dayjs'
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { fromNow } from 'web/lib/util/time'
|
||||||
|
|
||||||
export function RelativeTimestamp(props: { time: number }) {
|
export function RelativeTimestamp(props: { time: number }) {
|
||||||
const { time } = props
|
const { time } = props
|
||||||
const dayJsTime = dayjs(time)
|
|
||||||
return (
|
return (
|
||||||
<DateTimeTooltip
|
<DateTimeTooltip
|
||||||
className="ml-1 whitespace-nowrap text-gray-400"
|
className="ml-1 whitespace-nowrap text-gray-400"
|
||||||
time={time}
|
time={time}
|
||||||
>
|
>
|
||||||
{dayJsTime.fromNow()}
|
{fromNow(time)}
|
||||||
</DateTimeTooltip>
|
</DateTimeTooltip>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,11 +9,18 @@ import { DOMAIN } from 'common/envs/constants'
|
||||||
import { copyToClipboard } from 'web/lib/util/copy'
|
import { copyToClipboard } from 'web/lib/util/copy'
|
||||||
import { track } from 'web/lib/service/analytics'
|
import { track } from 'web/lib/service/analytics'
|
||||||
|
|
||||||
export function embedCode(contract: Contract) {
|
export function embedContractCode(contract: Contract) {
|
||||||
const title = contract.question
|
const title = contract.question
|
||||||
const src = `https://${DOMAIN}/embed${contractPath(contract)}`
|
const src = `https://${DOMAIN}/embed${contractPath(contract)}`
|
||||||
|
return `<iframe src="${src}" title="${title}" frameborder="0"></iframe>`
|
||||||
|
}
|
||||||
|
|
||||||
return `<iframe width="560" height="405" src="${src}" title="${title}" frameborder="0"></iframe>`
|
export function embedContractGridCode(contracts: Contract[]) {
|
||||||
|
const height = (contracts.length - (contracts.length % 2)) * 100 + 'px'
|
||||||
|
const src = `https://${DOMAIN}/embed/grid/${contracts
|
||||||
|
.map((c) => c.slug)
|
||||||
|
.join('/')}`
|
||||||
|
return `<iframe height="${height}" src="${src}" title="Grid of contracts" frameborder="0"></iframe>`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ShareEmbedButton(props: { contract: Contract }) {
|
export function ShareEmbedButton(props: { contract: Contract }) {
|
||||||
|
@ -26,7 +33,7 @@ export function ShareEmbedButton(props: { contract: Contract }) {
|
||||||
as="div"
|
as="div"
|
||||||
className="relative z-10 flex-shrink-0"
|
className="relative z-10 flex-shrink-0"
|
||||||
onMouseUp={() => {
|
onMouseUp={() => {
|
||||||
copyToClipboard(embedCode(contract))
|
copyToClipboard(embedContractCode(contract))
|
||||||
toast.success('Embed code copied!', {
|
toast.success('Embed code copied!', {
|
||||||
icon: codeIcon,
|
icon: codeIcon,
|
||||||
})
|
})
|
||||||
|
|
|
@ -21,8 +21,9 @@ export function Tooltip(props: {
|
||||||
className?: string
|
className?: string
|
||||||
placement?: Placement
|
placement?: Placement
|
||||||
noTap?: boolean
|
noTap?: boolean
|
||||||
|
noFade?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { text, children, className, placement = 'top', noTap } = props
|
const { text, children, className, placement = 'top', noTap, noFade } = props
|
||||||
|
|
||||||
const arrowRef = useRef(null)
|
const arrowRef = useRef(null)
|
||||||
|
|
||||||
|
@ -64,10 +65,10 @@ export function Tooltip(props: {
|
||||||
{/* conditionally render tooltip and fade in/out */}
|
{/* conditionally render tooltip and fade in/out */}
|
||||||
<Transition
|
<Transition
|
||||||
show={open}
|
show={open}
|
||||||
enter="transition ease-out duration-200"
|
enter="transition ease-out duration-50"
|
||||||
enterFrom="opacity-0 "
|
enterFrom="opacity-0"
|
||||||
enterTo="opacity-100"
|
enterTo="opacity-100"
|
||||||
leave="transition ease-in duration-150"
|
leave={noFade ? '' : 'transition ease-in duration-150'}
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
// div attributes
|
// div attributes
|
||||||
|
|
|
@ -1,22 +1,16 @@
|
||||||
import { linkClass, SiteLink } from 'web/components/site-link'
|
import { SiteLink } from 'web/components/site-link'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { Row } from 'web/components/layout/row'
|
|
||||||
import { Modal } from 'web/components/layout/modal'
|
|
||||||
import { Col } from 'web/components/layout/col'
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { Avatar } from 'web/components/avatar'
|
|
||||||
import { formatMoney } from 'common/util/format'
|
|
||||||
|
|
||||||
function shortenName(name: string) {
|
export function shortenName(name: string) {
|
||||||
const firstName = name.split(' ')[0]
|
const firstName = name.split(' ')[0]
|
||||||
const maxLength = 10
|
const maxLength = 11
|
||||||
const shortName =
|
const shortName =
|
||||||
firstName.length >= 3
|
firstName.length >= 3 && name.length > maxLength
|
||||||
? firstName.length < maxLength
|
? firstName.length < maxLength
|
||||||
? firstName
|
? firstName
|
||||||
: firstName.substring(0, maxLength - 3) + '...'
|
: firstName.substring(0, maxLength - 3) + '...'
|
||||||
: name.length > maxLength
|
: name.length > maxLength
|
||||||
? name.substring(0, maxLength) + '...'
|
? name.substring(0, maxLength - 3) + '...'
|
||||||
: name
|
: name
|
||||||
return shortName
|
return shortName
|
||||||
}
|
}
|
||||||
|
@ -24,11 +18,10 @@ function shortenName(name: string) {
|
||||||
export function UserLink(props: {
|
export function UserLink(props: {
|
||||||
name: string
|
name: string
|
||||||
username: string
|
username: string
|
||||||
showUsername?: boolean
|
|
||||||
className?: string
|
className?: string
|
||||||
short?: boolean
|
short?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { name, username, showUsername, className, short } = props
|
const { name, username, className, short } = props
|
||||||
const shortName = short ? shortenName(name) : name
|
const shortName = short ? shortenName(name) : name
|
||||||
return (
|
return (
|
||||||
<SiteLink
|
<SiteLink
|
||||||
|
@ -36,67 +29,6 @@ export function UserLink(props: {
|
||||||
className={clsx('z-10 truncate', className)}
|
className={clsx('z-10 truncate', className)}
|
||||||
>
|
>
|
||||||
{shortName}
|
{shortName}
|
||||||
{showUsername && ` (@${username})`}
|
|
||||||
</SiteLink>
|
</SiteLink>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MultiUserLinkInfo = {
|
|
||||||
name: string
|
|
||||||
username: string
|
|
||||||
avatarUrl: string | undefined
|
|
||||||
amountTipped: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MultiUserTipLink(props: {
|
|
||||||
userInfos: MultiUserLinkInfo[]
|
|
||||||
className?: string
|
|
||||||
}) {
|
|
||||||
const { userInfos, className } = props
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const maxShowCount = 2
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Row
|
|
||||||
className={clsx('mr-1 inline-flex gap-1', linkClass, className)}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
setOpen(true)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{userInfos.map((userInfo, index) =>
|
|
||||||
index < maxShowCount ? (
|
|
||||||
<span key={userInfo.username + 'shortened'} className={linkClass}>
|
|
||||||
{shortenName(userInfo.name) +
|
|
||||||
(index < maxShowCount - 1 ? ', ' : '')}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className={linkClass}>
|
|
||||||
& {userInfos.length - maxShowCount} more
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</Row>
|
|
||||||
<Modal open={open} setOpen={setOpen} size={'sm'}>
|
|
||||||
<Col className="items-start gap-4 rounded-md bg-white p-6">
|
|
||||||
<span className={'text-xl'}>Who tipped you</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.amountTipped)}
|
|
||||||
</span>
|
|
||||||
<Avatar
|
|
||||||
username={userInfo.username}
|
|
||||||
avatarUrl={userInfo.avatarUrl}
|
|
||||||
/>
|
|
||||||
<UserLink name={userInfo.name} username={userInfo.username} />
|
|
||||||
</Row>
|
|
||||||
))}
|
|
||||||
</Col>
|
|
||||||
</Modal>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
@ -253,7 +253,18 @@ export function UserPage(props: { user: User }) {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Comments',
|
title: 'Comments',
|
||||||
content: <UserCommentsList user={user} />,
|
content: (
|
||||||
|
<Col>
|
||||||
|
<Row className={'mt-2 mb-4 flex-wrap items-center gap-6'}>
|
||||||
|
<FollowingButton user={user} />
|
||||||
|
<FollowersButton user={user} />
|
||||||
|
<ReferralsButton user={user} />
|
||||||
|
<GroupsButton user={user} />
|
||||||
|
<UserLikesButton user={user} />
|
||||||
|
</Row>
|
||||||
|
<UserCommentsList user={user} />
|
||||||
|
</Col>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Bets',
|
title: 'Bets',
|
||||||
|
@ -264,20 +275,6 @@ export function UserPage(props: { user: User }) {
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: 'Social',
|
|
||||||
content: (
|
|
||||||
<Row
|
|
||||||
className={'mt-2 flex-wrap items-center justify-center gap-6'}
|
|
||||||
>
|
|
||||||
<FollowingButton user={user} />
|
|
||||||
<FollowersButton user={user} />
|
|
||||||
<ReferralsButton user={user} />
|
|
||||||
<GroupsButton user={user} />
|
|
||||||
<UserLikesButton user={user} />
|
|
||||||
</Row>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useEvent } from '../hooks/use-event'
|
import { useEvent } from '../hooks/use-event'
|
||||||
|
|
||||||
export function VisibilityObserver(props: {
|
export function VisibilityObserver(props: {
|
||||||
|
@ -8,17 +8,18 @@ export function VisibilityObserver(props: {
|
||||||
const { className } = props
|
const { className } = props
|
||||||
const [elem, setElem] = useState<HTMLElement | null>(null)
|
const [elem, setElem] = useState<HTMLElement | null>(null)
|
||||||
const onVisibilityUpdated = useEvent(props.onVisibilityUpdated)
|
const onVisibilityUpdated = useEvent(props.onVisibilityUpdated)
|
||||||
|
const observer = useRef(
|
||||||
useEffect(() => {
|
new IntersectionObserver(([entry]) => {
|
||||||
const hasIOSupport = !!window.IntersectionObserver
|
|
||||||
if (!hasIOSupport || !elem) return
|
|
||||||
|
|
||||||
const observer = new IntersectionObserver(([entry]) => {
|
|
||||||
onVisibilityUpdated(entry.isIntersecting)
|
onVisibilityUpdated(entry.isIntersecting)
|
||||||
}, {})
|
}, {})
|
||||||
|
).current
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (elem) {
|
||||||
observer.observe(elem)
|
observer.observe(elem)
|
||||||
return () => observer.disconnect()
|
return () => observer.unobserve(elem)
|
||||||
}, [elem, onVisibilityUpdated])
|
}
|
||||||
|
}, [elem, observer])
|
||||||
|
|
||||||
return <div ref={setElem} className={className}></div>
|
return <div ref={setElem} className={className}></div>
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,8 +9,10 @@ import {
|
||||||
listenForHotContracts,
|
listenForHotContracts,
|
||||||
listenForInactiveContracts,
|
listenForInactiveContracts,
|
||||||
listenForNewContracts,
|
listenForNewContracts,
|
||||||
|
getUserBetContracts,
|
||||||
getUserBetContractsQuery,
|
getUserBetContractsQuery,
|
||||||
} from 'web/lib/firebase/contracts'
|
} from 'web/lib/firebase/contracts'
|
||||||
|
import { useQueryClient } from 'react-query'
|
||||||
|
|
||||||
export const useContracts = () => {
|
export const useContracts = () => {
|
||||||
const [contracts, setContracts] = useState<Contract[] | undefined>()
|
const [contracts, setContracts] = useState<Contract[] | undefined>()
|
||||||
|
@ -92,6 +94,13 @@ export const useUpdatedContracts = (contracts: Contract[] | undefined) => {
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const usePrefetchUserBetContracts = (userId: string) => {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return queryClient.prefetchQuery(['contracts', 'bets', userId], () =>
|
||||||
|
getUserBetContracts(userId)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const useUserBetContracts = (userId: string) => {
|
export const useUserBetContracts = (userId: string) => {
|
||||||
const result = useFirestoreQueryData(
|
const result = useFirestoreQueryData(
|
||||||
['contracts', 'bets', userId],
|
['contracts', 'bets', userId],
|
||||||
|
|
|
@ -2,16 +2,21 @@ import { useEffect, useState } from 'react'
|
||||||
import { Group } from 'common/group'
|
import { Group } from 'common/group'
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
import {
|
import {
|
||||||
|
GroupMemberDoc,
|
||||||
|
groupMembers,
|
||||||
listenForGroup,
|
listenForGroup,
|
||||||
|
listenForGroupContractDocs,
|
||||||
listenForGroups,
|
listenForGroups,
|
||||||
|
listenForMemberGroupIds,
|
||||||
listenForMemberGroups,
|
listenForMemberGroups,
|
||||||
listenForOpenGroups,
|
listenForOpenGroups,
|
||||||
listGroups,
|
listGroups,
|
||||||
} from 'web/lib/firebase/groups'
|
} from 'web/lib/firebase/groups'
|
||||||
import { getUser, getUsers } from 'web/lib/firebase/users'
|
import { getUser } from 'web/lib/firebase/users'
|
||||||
import { filterDefined } from 'common/util/array'
|
import { filterDefined } from 'common/util/array'
|
||||||
import { Contract } from 'common/contract'
|
import { Contract } from 'common/contract'
|
||||||
import { uniq } from 'lodash'
|
import { uniq } from 'lodash'
|
||||||
|
import { listenForValues } from 'web/lib/firebase/utils'
|
||||||
|
|
||||||
export const useGroup = (groupId: string | undefined) => {
|
export const useGroup = (groupId: string | undefined) => {
|
||||||
const [group, setGroup] = useState<Group | null | undefined>()
|
const [group, setGroup] = useState<Group | null | undefined>()
|
||||||
|
@ -43,29 +48,12 @@ export const useOpenGroups = () => {
|
||||||
return groups
|
return groups
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useMemberGroups = (
|
export const useMemberGroups = (userId: string | null | undefined) => {
|
||||||
userId: string | null | undefined,
|
|
||||||
options?: { withChatEnabled: boolean },
|
|
||||||
sort?: { by: 'mostRecentChatActivityTime' | 'mostRecentContractAddedTime' }
|
|
||||||
) => {
|
|
||||||
const [memberGroups, setMemberGroups] = useState<Group[] | undefined>()
|
const [memberGroups, setMemberGroups] = useState<Group[] | undefined>()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (userId)
|
if (userId)
|
||||||
return listenForMemberGroups(
|
return listenForMemberGroups(userId, (groups) => setMemberGroups(groups))
|
||||||
userId,
|
}, [userId])
|
||||||
(groups) => {
|
|
||||||
if (options?.withChatEnabled)
|
|
||||||
return setMemberGroups(
|
|
||||||
filterDefined(
|
|
||||||
groups.filter((group) => group.chatDisabled !== true)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return setMemberGroups(groups)
|
|
||||||
},
|
|
||||||
sort
|
|
||||||
)
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [options?.withChatEnabled, sort?.by, userId])
|
|
||||||
return memberGroups
|
return memberGroups
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -77,16 +65,8 @@ export const useMemberGroupIds = (user: User | null | undefined) => {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) {
|
if (user) {
|
||||||
const key = `member-groups-${user.id}`
|
return listenForMemberGroupIds(user.id, (groupIds) => {
|
||||||
const memberGroupJson = localStorage.getItem(key)
|
|
||||||
if (memberGroupJson) {
|
|
||||||
setMemberGroupIds(JSON.parse(memberGroupJson))
|
|
||||||
}
|
|
||||||
|
|
||||||
return listenForMemberGroups(user.id, (Groups) => {
|
|
||||||
const groupIds = Groups.map((group) => group.id)
|
|
||||||
setMemberGroupIds(groupIds)
|
setMemberGroupIds(groupIds)
|
||||||
localStorage.setItem(key, JSON.stringify(groupIds))
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [user])
|
}, [user])
|
||||||
|
@ -94,26 +74,29 @@ export const useMemberGroupIds = (user: User | null | undefined) => {
|
||||||
return memberGroupIds
|
return memberGroupIds
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useMembers(group: Group, max?: number) {
|
export function useMembers(groupId: string | undefined) {
|
||||||
const [members, setMembers] = useState<User[]>([])
|
const [members, setMembers] = useState<User[]>([])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const { memberIds } = group
|
if (groupId)
|
||||||
if (memberIds.length > 0) {
|
listenForValues<GroupMemberDoc>(groupMembers(groupId), (memDocs) => {
|
||||||
listMembers(group, max).then((members) => setMembers(members))
|
const memberIds = memDocs.map((memDoc) => memDoc.userId)
|
||||||
}
|
Promise.all(memberIds.map((id) => getUser(id))).then((users) => {
|
||||||
}, [group, max])
|
setMembers(users)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, [groupId])
|
||||||
return members
|
return members
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listMembers(group: Group, max?: number) {
|
export function useMemberIds(groupId: string | null) {
|
||||||
const { memberIds } = group
|
const [memberIds, setMemberIds] = useState<string[]>([])
|
||||||
const numToRetrieve = max ?? memberIds.length
|
useEffect(() => {
|
||||||
if (memberIds.length === 0) return []
|
if (groupId)
|
||||||
if (numToRetrieve > 100)
|
return listenForValues<GroupMemberDoc>(groupMembers(groupId), (docs) => {
|
||||||
return (await getUsers()).filter((user) =>
|
setMemberIds(docs.map((doc) => doc.userId))
|
||||||
group.memberIds.includes(user.id)
|
})
|
||||||
)
|
}, [groupId])
|
||||||
return await Promise.all(group.memberIds.slice(0, numToRetrieve).map(getUser))
|
return memberIds
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useGroupsWithContract = (contract: Contract) => {
|
export const useGroupsWithContract = (contract: Contract) => {
|
||||||
|
@ -128,3 +111,16 @@ export const useGroupsWithContract = (contract: Contract) => {
|
||||||
|
|
||||||
return groups
|
return groups
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useGroupContractIds(groupId: string) {
|
||||||
|
const [contractIds, setContractIds] = useState<string[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (groupId)
|
||||||
|
return listenForGroupContractDocs(groupId, (docs) =>
|
||||||
|
setContractIds(docs.map((doc) => doc.contractId))
|
||||||
|
)
|
||||||
|
}, [groupId])
|
||||||
|
|
||||||
|
return contractIds
|
||||||
|
}
|
||||||
|
|
|
@ -16,11 +16,7 @@ export type NotificationGroup = {
|
||||||
function useNotifications(privateUser: PrivateUser) {
|
function useNotifications(privateUser: PrivateUser) {
|
||||||
const result = useFirestoreQueryData(
|
const result = useFirestoreQueryData(
|
||||||
['notifications-all', privateUser.id],
|
['notifications-all', privateUser.id],
|
||||||
getNotificationsQuery(privateUser.id),
|
getNotificationsQuery(privateUser.id)
|
||||||
{ subscribe: true, includeMetadataChanges: true },
|
|
||||||
// Temporary workaround for react-query bug:
|
|
||||||
// https://github.com/invertase/react-query-firebase/issues/25
|
|
||||||
{ refetchOnMount: 'always' }
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const notifications = useMemo(() => {
|
const notifications = useMemo(() => {
|
||||||
|
|
|
@ -1,11 +1,27 @@
|
||||||
|
import { useQueryClient } from 'react-query'
|
||||||
import { useFirestoreQueryData } from '@react-query-firebase/firestore'
|
import { useFirestoreQueryData } from '@react-query-firebase/firestore'
|
||||||
import { DAY_MS, HOUR_MS } from 'common/util/time'
|
import { DAY_MS, HOUR_MS } from 'common/util/time'
|
||||||
import { getPortfolioHistoryQuery, Period } from 'web/lib/firebase/users'
|
import {
|
||||||
|
getPortfolioHistory,
|
||||||
|
getPortfolioHistoryQuery,
|
||||||
|
Period,
|
||||||
|
} from 'web/lib/firebase/users'
|
||||||
|
|
||||||
|
const getCutoff = (period: Period) => {
|
||||||
|
const nowRounded = Math.round(Date.now() / HOUR_MS) * HOUR_MS
|
||||||
|
return periodToCutoff(nowRounded, period).valueOf()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePrefetchPortfolioHistory = (userId: string, period: Period) => {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const cutoff = getCutoff(period)
|
||||||
|
return queryClient.prefetchQuery(['portfolio-history', userId, cutoff], () =>
|
||||||
|
getPortfolioHistory(userId, cutoff)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const usePortfolioHistory = (userId: string, period: Period) => {
|
export const usePortfolioHistory = (userId: string, period: Period) => {
|
||||||
const nowRounded = Math.round(Date.now() / HOUR_MS) * HOUR_MS
|
const cutoff = getCutoff(period)
|
||||||
const cutoff = periodToCutoff(nowRounded, period).valueOf()
|
|
||||||
|
|
||||||
const result = useFirestoreQueryData(
|
const result = useFirestoreQueryData(
|
||||||
['portfolio-history', userId, cutoff],
|
['portfolio-history', userId, cutoff],
|
||||||
getPortfolioHistoryQuery(userId, cutoff)
|
getPortfolioHistoryQuery(userId, cutoff)
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
import { useUserBetContracts } from './use-contracts'
|
import { usePrefetchUserBetContracts } from './use-contracts'
|
||||||
import { usePortfolioHistory } from './use-portfolio-history'
|
import { usePrefetchPortfolioHistory } from './use-portfolio-history'
|
||||||
import { useUserBets } from './use-user-bets'
|
import { usePrefetchUserBets } from './use-user-bets'
|
||||||
|
|
||||||
export function usePrefetch(userId: string | undefined) {
|
export function usePrefetch(userId: string | undefined) {
|
||||||
const maybeUserId = userId ?? ''
|
const maybeUserId = userId ?? ''
|
||||||
|
return Promise.all([
|
||||||
useUserBets(maybeUserId)
|
usePrefetchUserBets(maybeUserId),
|
||||||
useUserBetContracts(maybeUserId)
|
usePrefetchUserBetContracts(maybeUserId),
|
||||||
usePortfolioHistory(maybeUserId, 'weekly')
|
usePrefetchPortfolioHistory(maybeUserId, 'weekly'),
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,13 +0,0 @@
|
||||||
import { useCallback, useEffect, useRef } from 'react'
|
|
||||||
|
|
||||||
export function useTimeSinceFirstRender() {
|
|
||||||
const startTimeRef = useRef(0)
|
|
||||||
useEffect(() => {
|
|
||||||
startTimeRef.current = Date.now()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return useCallback(() => {
|
|
||||||
if (!startTimeRef.current) return 0
|
|
||||||
return Date.now() - startTimeRef.current
|
|
||||||
}, [])
|
|
||||||
}
|
|
|
@ -29,3 +29,15 @@ export function useTipTxns(on: {
|
||||||
})
|
})
|
||||||
}, [txns])
|
}, [txns])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useMarketTipTxns(contractId: string): TipTxn[] {
|
||||||
|
const [txns, setTxns] = useState<TipTxn[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return listenForTipTxns(contractId, (txns) => {
|
||||||
|
setTxns(txns.filter((txn) => !txn.data.commentId))
|
||||||
|
})
|
||||||
|
}, [contractId])
|
||||||
|
|
||||||
|
return txns
|
||||||
|
}
|
||||||
|
|
|
@ -1,11 +1,18 @@
|
||||||
|
import { useQueryClient } from 'react-query'
|
||||||
import { useFirestoreQueryData } from '@react-query-firebase/firestore'
|
import { useFirestoreQueryData } from '@react-query-firebase/firestore'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Bet,
|
Bet,
|
||||||
|
getUserBets,
|
||||||
getUserBetsQuery,
|
getUserBetsQuery,
|
||||||
listenForUserContractBets,
|
listenForUserContractBets,
|
||||||
} from 'web/lib/firebase/bets'
|
} from 'web/lib/firebase/bets'
|
||||||
|
|
||||||
|
export const usePrefetchUserBets = (userId: string) => {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return queryClient.prefetchQuery(['bets', userId], () => getUserBets(userId))
|
||||||
|
}
|
||||||
|
|
||||||
export const useUserBets = (userId: string) => {
|
export const useUserBets = (userId: string) => {
|
||||||
const result = useFirestoreQueryData(
|
const result = useFirestoreQueryData(
|
||||||
['bets', userId],
|
['bets', userId],
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { useContext } from 'react'
|
import { useContext } from 'react'
|
||||||
import { useFirestoreDocumentData } from '@react-query-firebase/firestore'
|
import { useFirestoreDocumentData } from '@react-query-firebase/firestore'
|
||||||
import { QueryClient } from 'react-query'
|
import { useQueryClient } from 'react-query'
|
||||||
|
|
||||||
import { doc, DocumentData } from 'firebase/firestore'
|
import { doc, DocumentData } from 'firebase/firestore'
|
||||||
import { getUser, User, users } from 'web/lib/firebase/users'
|
import { getUser, User, users } from 'web/lib/firebase/users'
|
||||||
|
@ -28,12 +28,13 @@ export const useUserById = (userId = '_') => {
|
||||||
return result.isLoading ? undefined : result.data
|
return result.isLoading ? undefined : result.data
|
||||||
}
|
}
|
||||||
|
|
||||||
const queryClient = new QueryClient()
|
export const usePrefetchUser = (userId: string) => {
|
||||||
|
return usePrefetchUsers([userId])[0]
|
||||||
|
}
|
||||||
|
|
||||||
export const prefetchUser = (userId: string) => {
|
export const usePrefetchUsers = (userIds: string[]) => {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return userIds.map((userId) =>
|
||||||
queryClient.prefetchQuery(['users', userId], () => getUser(userId))
|
queryClient.prefetchQuery(['users', userId], () => getUser(userId))
|
||||||
}
|
)
|
||||||
|
|
||||||
export const prefetchUsers = (userIds: string[]) => {
|
|
||||||
userIds.forEach(prefetchUser)
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,74 +0,0 @@
|
||||||
import { PROJECT_ID } from 'common/envs/constants'
|
|
||||||
import { setCookie, getCookies } from '../util/cookie'
|
|
||||||
import { IncomingMessage, ServerResponse } from 'http'
|
|
||||||
|
|
||||||
const ONE_HOUR_SECS = 60 * 60
|
|
||||||
const TEN_YEARS_SECS = 60 * 60 * 24 * 365 * 10
|
|
||||||
const TOKEN_KINDS = ['refresh', 'id', 'custom'] as const
|
|
||||||
const TOKEN_AGES = {
|
|
||||||
id: ONE_HOUR_SECS,
|
|
||||||
refresh: TEN_YEARS_SECS,
|
|
||||||
custom: ONE_HOUR_SECS,
|
|
||||||
} as const
|
|
||||||
export type TokenKind = typeof TOKEN_KINDS[number]
|
|
||||||
|
|
||||||
const getAuthCookieName = (kind: TokenKind) => {
|
|
||||||
const suffix = `${PROJECT_ID}_${kind}`.toUpperCase().replace(/-/g, '_')
|
|
||||||
return `FIREBASE_TOKEN_${suffix}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const COOKIE_NAMES = Object.fromEntries(
|
|
||||||
TOKEN_KINDS.map((k) => [k, getAuthCookieName(k)])
|
|
||||||
) as Record<TokenKind, string>
|
|
||||||
|
|
||||||
const getCookieDataIsomorphic = (req?: IncomingMessage) => {
|
|
||||||
if (req != null) {
|
|
||||||
return req.headers.cookie ?? ''
|
|
||||||
} else if (document != null) {
|
|
||||||
return document.cookie
|
|
||||||
} else {
|
|
||||||
throw new Error(
|
|
||||||
'Neither request nor document is available; no way to get cookies.'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const setCookieDataIsomorphic = (cookies: string[], res?: ServerResponse) => {
|
|
||||||
if (res != null) {
|
|
||||||
res.setHeader('Set-Cookie', cookies)
|
|
||||||
} else if (document != null) {
|
|
||||||
for (const ck of cookies) {
|
|
||||||
document.cookie = ck
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new Error(
|
|
||||||
'Neither response nor document is available; no way to set cookies.'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getTokensFromCookies = (req?: IncomingMessage) => {
|
|
||||||
const cookies = getCookies(getCookieDataIsomorphic(req))
|
|
||||||
return Object.fromEntries(
|
|
||||||
TOKEN_KINDS.map((k) => [k, cookies[COOKIE_NAMES[k]]])
|
|
||||||
) as Partial<Record<TokenKind, string>>
|
|
||||||
}
|
|
||||||
|
|
||||||
export const setTokenCookies = (
|
|
||||||
cookies: Partial<Record<TokenKind, string | undefined>>,
|
|
||||||
res?: ServerResponse
|
|
||||||
) => {
|
|
||||||
const data = TOKEN_KINDS.filter((k) => k in cookies).map((k) => {
|
|
||||||
const maxAge = cookies[k] ? TOKEN_AGES[k as TokenKind] : 0
|
|
||||||
return setCookie(COOKIE_NAMES[k], cookies[k] ?? '', [
|
|
||||||
['path', '/'],
|
|
||||||
['max-age', maxAge.toString()],
|
|
||||||
['samesite', 'lax'],
|
|
||||||
['secure'],
|
|
||||||
])
|
|
||||||
})
|
|
||||||
setCookieDataIsomorphic(data, res)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const deleteTokenCookies = (res?: ServerResponse) =>
|
|
||||||
setTokenCookies({ id: undefined, refresh: undefined, custom: undefined }, res)
|
|
|
@ -28,9 +28,9 @@ function getBetsCollection(contractId: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listAllBets(contractId: string) {
|
export async function listAllBets(contractId: string) {
|
||||||
const bets = await getValues<Bet>(getBetsCollection(contractId))
|
return await getValues<Bet>(
|
||||||
bets.sort((bet1, bet2) => bet1.createdTime - bet2.createdTime)
|
query(getBetsCollection(contractId), orderBy('createdTime', 'desc'))
|
||||||
return bets
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const DAY_IN_MS = 24 * 60 * 60 * 1000
|
const DAY_IN_MS = 24 * 60 * 60 * 1000
|
||||||
|
@ -64,26 +64,22 @@ export function listenForBets(
|
||||||
contractId: string,
|
contractId: string,
|
||||||
setBets: (bets: Bet[]) => void
|
setBets: (bets: Bet[]) => void
|
||||||
) {
|
) {
|
||||||
return listenForValues<Bet>(getBetsCollection(contractId), (bets) => {
|
return listenForValues<Bet>(
|
||||||
bets.sort((bet1, bet2) => bet1.createdTime - bet2.createdTime)
|
query(getBetsCollection(contractId), orderBy('createdTime', 'desc')),
|
||||||
setBets(bets)
|
setBets
|
||||||
})
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUserBets(
|
export async function getUserBets(userId: string) {
|
||||||
userId: string,
|
return getValues<Bet>(getUserBetsQuery(userId))
|
||||||
options: { includeRedemptions: boolean }
|
}
|
||||||
) {
|
|
||||||
const { includeRedemptions } = options
|
export function getUserBetsQuery(userId: string) {
|
||||||
return getValues<Bet>(
|
return query(
|
||||||
query(collectionGroup(db, 'bets'), where('userId', '==', userId))
|
collectionGroup(db, 'bets'),
|
||||||
)
|
where('userId', '==', userId),
|
||||||
.then((bets) =>
|
orderBy('createdTime', 'desc')
|
||||||
bets.filter(
|
) as Query<Bet>
|
||||||
(bet) => (includeRedemptions || !bet.isRedemption) && !bet.isAnte
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.catch((reason) => reason)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getBets(options: {
|
export async function getBets(options: {
|
||||||
|
@ -124,22 +120,16 @@ export async function getBets(options: {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getContractsOfUserBets(userId: string) {
|
export async function getContractsOfUserBets(userId: string) {
|
||||||
const bets: Bet[] = await getUserBets(userId, { includeRedemptions: false })
|
const bets = await getUserBets(userId)
|
||||||
const contractIds = uniq(bets.map((bet) => bet.contractId))
|
const contractIds = uniq(
|
||||||
|
bets.filter((b) => !b.isAnte).map((bet) => bet.contractId)
|
||||||
|
)
|
||||||
const contracts = await Promise.all(
|
const contracts = await Promise.all(
|
||||||
contractIds.map((contractId) => getContractFromId(contractId))
|
contractIds.map((contractId) => getContractFromId(contractId))
|
||||||
)
|
)
|
||||||
return filterDefined(contracts)
|
return filterDefined(contracts)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getUserBetsQuery(userId: string) {
|
|
||||||
return query(
|
|
||||||
collectionGroup(db, 'bets'),
|
|
||||||
where('userId', '==', userId),
|
|
||||||
orderBy('createdTime', 'desc')
|
|
||||||
) as Query<Bet>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listenForUserContractBets(
|
export function listenForUserContractBets(
|
||||||
userId: string,
|
userId: string,
|
||||||
contractId: string,
|
contractId: string,
|
||||||
|
@ -147,12 +137,10 @@ export function listenForUserContractBets(
|
||||||
) {
|
) {
|
||||||
const betsQuery = query(
|
const betsQuery = query(
|
||||||
collection(db, 'contracts', contractId, 'bets'),
|
collection(db, 'contracts', contractId, 'bets'),
|
||||||
where('userId', '==', userId)
|
where('userId', '==', userId),
|
||||||
|
orderBy('createdTime', 'desc')
|
||||||
)
|
)
|
||||||
return listenForValues<Bet>(betsQuery, (bets) => {
|
return listenForValues<Bet>(betsQuery, setBets)
|
||||||
bets.sort((bet1, bet2) => bet1.createdTime - bet2.createdTime)
|
|
||||||
setBets(bets)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listenForUnfilledBets(
|
export function listenForUnfilledBets(
|
||||||
|
@ -162,12 +150,10 @@ export function listenForUnfilledBets(
|
||||||
const betsQuery = query(
|
const betsQuery = query(
|
||||||
collection(db, 'contracts', contractId, 'bets'),
|
collection(db, 'contracts', contractId, 'bets'),
|
||||||
where('isFilled', '==', false),
|
where('isFilled', '==', false),
|
||||||
where('isCancelled', '==', false)
|
where('isCancelled', '==', false),
|
||||||
|
orderBy('createdTime', 'desc')
|
||||||
)
|
)
|
||||||
return listenForValues<LimitBet>(betsQuery, (bets) => {
|
return listenForValues<LimitBet>(betsQuery, setBets)
|
||||||
bets.sort((bet1, bet2) => bet1.createdTime - bet2.createdTime)
|
|
||||||
setBets(bets)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function withoutAnteBets(contract: Contract, bets?: Bet[]) {
|
export function withoutAnteBets(contract: Contract, bets?: Bet[]) {
|
||||||
|
|
|
@ -92,17 +92,15 @@ function getCommentsOnGroupCollection(groupId: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listAllComments(contractId: string) {
|
export async function listAllComments(contractId: string) {
|
||||||
const comments = await getValues<Comment>(getCommentsCollection(contractId))
|
return await getValues<Comment>(
|
||||||
comments.sort((c1, c2) => c1.createdTime - c2.createdTime)
|
query(getCommentsCollection(contractId), orderBy('createdTime', 'desc'))
|
||||||
return comments
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listAllCommentsOnGroup(groupId: string) {
|
export async function listAllCommentsOnGroup(groupId: string) {
|
||||||
const comments = await getValues<GroupComment>(
|
return await getValues<GroupComment>(
|
||||||
getCommentsOnGroupCollection(groupId)
|
query(getCommentsOnGroupCollection(groupId), orderBy('createdTime', 'desc'))
|
||||||
)
|
)
|
||||||
comments.sort((c1, c2) => c1.createdTime - c2.createdTime)
|
|
||||||
return comments
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listenForCommentsOnContract(
|
export function listenForCommentsOnContract(
|
||||||
|
@ -110,23 +108,21 @@ export function listenForCommentsOnContract(
|
||||||
setComments: (comments: ContractComment[]) => void
|
setComments: (comments: ContractComment[]) => void
|
||||||
) {
|
) {
|
||||||
return listenForValues<ContractComment>(
|
return listenForValues<ContractComment>(
|
||||||
getCommentsCollection(contractId),
|
query(getCommentsCollection(contractId), orderBy('createdTime', 'desc')),
|
||||||
(comments) => {
|
setComments
|
||||||
comments.sort((c1, c2) => c1.createdTime - c2.createdTime)
|
|
||||||
setComments(comments)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listenForCommentsOnGroup(
|
export function listenForCommentsOnGroup(
|
||||||
groupId: string,
|
groupId: string,
|
||||||
setComments: (comments: GroupComment[]) => void
|
setComments: (comments: GroupComment[]) => void
|
||||||
) {
|
) {
|
||||||
return listenForValues<GroupComment>(
|
return listenForValues<GroupComment>(
|
||||||
|
query(
|
||||||
getCommentsOnGroupCollection(groupId),
|
getCommentsOnGroupCollection(groupId),
|
||||||
(comments) => {
|
orderBy('createdTime', 'desc')
|
||||||
comments.sort((c1, c2) => c1.createdTime - c2.createdTime)
|
),
|
||||||
setComments(comments)
|
setComments
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -157,6 +157,10 @@ export function listenForUserContracts(
|
||||||
return listenForValues<Contract>(q, setContracts)
|
return listenForValues<Contract>(q, setContracts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getUserBetContracts(userId: string) {
|
||||||
|
return getValues<Contract>(getUserBetContractsQuery(userId))
|
||||||
|
}
|
||||||
|
|
||||||
export function getUserBetContractsQuery(userId: string) {
|
export function getUserBetContractsQuery(userId: string) {
|
||||||
return query(
|
return query(
|
||||||
contracts,
|
contracts,
|
||||||
|
|
|
@ -1,13 +1,17 @@
|
||||||
import {
|
import {
|
||||||
|
collection,
|
||||||
|
collectionGroup,
|
||||||
deleteDoc,
|
deleteDoc,
|
||||||
deleteField,
|
deleteField,
|
||||||
doc,
|
doc,
|
||||||
getDocs,
|
getDocs,
|
||||||
|
onSnapshot,
|
||||||
query,
|
query,
|
||||||
|
setDoc,
|
||||||
updateDoc,
|
updateDoc,
|
||||||
where,
|
where,
|
||||||
} from 'firebase/firestore'
|
} from 'firebase/firestore'
|
||||||
import { sortBy, uniq } from 'lodash'
|
import { uniq } from 'lodash'
|
||||||
import { Group, GROUP_CHAT_SLUG, GroupLink } from 'common/group'
|
import { Group, GROUP_CHAT_SLUG, GroupLink } from 'common/group'
|
||||||
import {
|
import {
|
||||||
coll,
|
coll,
|
||||||
|
@ -18,8 +22,15 @@ import {
|
||||||
} from './utils'
|
} from './utils'
|
||||||
import { Contract } from 'common/contract'
|
import { Contract } from 'common/contract'
|
||||||
import { updateContract } from 'web/lib/firebase/contracts'
|
import { updateContract } from 'web/lib/firebase/contracts'
|
||||||
|
import { db } from 'web/lib/firebase/init'
|
||||||
|
import { filterDefined } from 'common/util/array'
|
||||||
|
import { getUser } from 'web/lib/firebase/users'
|
||||||
|
|
||||||
export const groups = coll<Group>('groups')
|
export const groups = coll<Group>('groups')
|
||||||
|
export const groupMembers = (groupId: string) =>
|
||||||
|
collection(groups, groupId, 'groupMembers')
|
||||||
|
export const groupContracts = (groupId: string) =>
|
||||||
|
collection(groups, groupId, 'groupContracts')
|
||||||
|
|
||||||
export function groupPath(
|
export function groupPath(
|
||||||
groupSlug: string,
|
groupSlug: string,
|
||||||
|
@ -33,6 +44,9 @@ export function groupPath(
|
||||||
return `/group/${groupSlug}${subpath ? `/${subpath}` : ''}`
|
return `/group/${groupSlug}${subpath ? `/${subpath}` : ''}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type GroupContractDoc = { contractId: string; createdTime: number }
|
||||||
|
export type GroupMemberDoc = { userId: string; createdTime: number }
|
||||||
|
|
||||||
export function updateGroup(group: Group, updates: Partial<Group>) {
|
export function updateGroup(group: Group, updates: Partial<Group>) {
|
||||||
return updateDoc(doc(groups, group.id), updates)
|
return updateDoc(doc(groups, group.id), updates)
|
||||||
}
|
}
|
||||||
|
@ -57,6 +71,13 @@ export function listenForGroups(setGroups: (groups: Group[]) => void) {
|
||||||
return listenForValues(groups, setGroups)
|
return listenForValues(groups, setGroups)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listenForGroupContractDocs(
|
||||||
|
groupId: string,
|
||||||
|
setContractDocs: (docs: GroupContractDoc[]) => void
|
||||||
|
) {
|
||||||
|
return listenForValues(groupContracts(groupId), setContractDocs)
|
||||||
|
}
|
||||||
|
|
||||||
export function listenForOpenGroups(setGroups: (groups: Group[]) => void) {
|
export function listenForOpenGroups(setGroups: (groups: Group[]) => void) {
|
||||||
return listenForValues(
|
return listenForValues(
|
||||||
query(groups, where('anyoneCanJoin', '==', true)),
|
query(groups, where('anyoneCanJoin', '==', true)),
|
||||||
|
@ -68,6 +89,12 @@ export function getGroup(groupId: string) {
|
||||||
return getValue<Group>(doc(groups, groupId))
|
return getValue<Group>(doc(groups, groupId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getGroupContracts(groupId: string) {
|
||||||
|
return getValues<{ contractId: string; createdTime: number }>(
|
||||||
|
groupContracts(groupId)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export async function getGroupBySlug(slug: string) {
|
export async function getGroupBySlug(slug: string) {
|
||||||
const q = query(groups, where('slug', '==', slug))
|
const q = query(groups, where('slug', '==', slug))
|
||||||
const docs = (await getDocs(q)).docs
|
const docs = (await getDocs(q)).docs
|
||||||
|
@ -81,33 +108,32 @@ export function listenForGroup(
|
||||||
return listenForValue(doc(groups, groupId), setGroup)
|
return listenForValue(doc(groups, groupId), setGroup)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listenForMemberGroups(
|
export function listenForMemberGroupIds(
|
||||||
userId: string,
|
userId: string,
|
||||||
setGroups: (groups: Group[]) => void,
|
setGroupIds: (groupIds: string[]) => void
|
||||||
sort?: { by: 'mostRecentChatActivityTime' | 'mostRecentContractAddedTime' }
|
|
||||||
) {
|
) {
|
||||||
const q = query(groups, where('memberIds', 'array-contains', userId))
|
const q = query(
|
||||||
const sorter = (group: Group) => {
|
collectionGroup(db, 'groupMembers'),
|
||||||
if (sort?.by === 'mostRecentChatActivityTime') {
|
where('userId', '==', userId)
|
||||||
return group.mostRecentChatActivityTime ?? group.createdTime
|
)
|
||||||
}
|
return onSnapshot(q, { includeMetadataChanges: true }, (snapshot) => {
|
||||||
if (sort?.by === 'mostRecentContractAddedTime') {
|
if (snapshot.metadata.fromCache) return
|
||||||
return group.mostRecentContractAddedTime ?? group.createdTime
|
|
||||||
}
|
const values = snapshot.docs.map((doc) => doc.ref.parent.parent?.id)
|
||||||
return group.mostRecentActivityTime
|
|
||||||
}
|
setGroupIds(filterDefined(values))
|
||||||
return listenForValues<Group>(q, (groups) => {
|
|
||||||
const sorted = sortBy(groups, [(group) => -sorter(group)])
|
|
||||||
setGroups(sorted)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listenForGroupsWithContractId(
|
export function listenForMemberGroups(
|
||||||
contractId: string,
|
userId: string,
|
||||||
setGroups: (groups: Group[]) => void
|
setGroups: (groups: Group[]) => void
|
||||||
) {
|
) {
|
||||||
const q = query(groups, where('contractIds', 'array-contains', contractId))
|
return listenForMemberGroupIds(userId, (groupIds) => {
|
||||||
return listenForValues<Group>(q, setGroups)
|
return Promise.all(groupIds.map(getGroup)).then((groups) => {
|
||||||
|
setGroups(filterDefined(groups))
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addUserToGroupViaId(groupId: string, userId: string) {
|
export async function addUserToGroupViaId(groupId: string, userId: string) {
|
||||||
|
@ -121,19 +147,18 @@ export async function addUserToGroupViaId(groupId: string, userId: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function joinGroup(group: Group, userId: string): Promise<void> {
|
export async function joinGroup(group: Group, userId: string): Promise<void> {
|
||||||
const { memberIds } = group
|
// create a new member document in grouoMembers collection
|
||||||
if (memberIds.includes(userId)) return // already a member
|
const memberDoc = doc(groupMembers(group.id), userId)
|
||||||
|
return await setDoc(memberDoc, {
|
||||||
const newMemberIds = [...memberIds, userId]
|
userId,
|
||||||
return await updateGroup(group, { memberIds: uniq(newMemberIds) })
|
createdTime: Date.now(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function leaveGroup(group: Group, userId: string): Promise<void> {
|
export async function leaveGroup(group: Group, userId: string): Promise<void> {
|
||||||
const { memberIds } = group
|
// delete the member document in groupMembers collection
|
||||||
if (!memberIds.includes(userId)) return // not a member
|
const memberDoc = doc(groupMembers(group.id), userId)
|
||||||
|
return await deleteDoc(memberDoc)
|
||||||
const newMemberIds = memberIds.filter((id) => id !== userId)
|
|
||||||
return await updateGroup(group, { memberIds: uniq(newMemberIds) })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addContractToGroup(
|
export async function addContractToGroup(
|
||||||
|
@ -141,7 +166,6 @@ export async function addContractToGroup(
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
userId: string
|
userId: string
|
||||||
) {
|
) {
|
||||||
if (!canModifyGroupContracts(group, userId)) return
|
|
||||||
const newGroupLinks = [
|
const newGroupLinks = [
|
||||||
...(contract.groupLinks ?? []),
|
...(contract.groupLinks ?? []),
|
||||||
{
|
{
|
||||||
|
@ -158,25 +182,18 @@ export async function addContractToGroup(
|
||||||
groupLinks: newGroupLinks,
|
groupLinks: newGroupLinks,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!group.contractIds.includes(contract.id)) {
|
// create new contract document in groupContracts collection
|
||||||
return await updateGroup(group, {
|
const contractDoc = doc(groupContracts(group.id), contract.id)
|
||||||
contractIds: uniq([...group.contractIds, contract.id]),
|
await setDoc(contractDoc, {
|
||||||
|
contractId: contract.id,
|
||||||
|
createdTime: Date.now(),
|
||||||
})
|
})
|
||||||
.then(() => group)
|
|
||||||
.catch((err) => {
|
|
||||||
console.error('error adding contract to group', err)
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeContractFromGroup(
|
export async function removeContractFromGroup(
|
||||||
group: Group,
|
group: Group,
|
||||||
contract: Contract,
|
contract: Contract
|
||||||
userId: string
|
|
||||||
) {
|
) {
|
||||||
if (!canModifyGroupContracts(group, userId)) return
|
|
||||||
|
|
||||||
if (contract.groupLinks?.map((l) => l.groupId).includes(group.id)) {
|
if (contract.groupLinks?.map((l) => l.groupId).includes(group.id)) {
|
||||||
const newGroupLinks = contract.groupLinks?.filter(
|
const newGroupLinks = contract.groupLinks?.filter(
|
||||||
(link) => link.slug !== group.slug
|
(link) => link.slug !== group.slug
|
||||||
|
@ -188,23 +205,26 @@ export async function removeContractFromGroup(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (group.contractIds.includes(contract.id)) {
|
// delete the contract document in groupContracts collection
|
||||||
const newContractIds = group.contractIds.filter((id) => id !== contract.id)
|
const contractDoc = doc(groupContracts(group.id), contract.id)
|
||||||
return await updateGroup(group, {
|
await deleteDoc(contractDoc)
|
||||||
contractIds: uniq(newContractIds),
|
|
||||||
})
|
|
||||||
.then(() => group)
|
|
||||||
.catch((err) => {
|
|
||||||
console.error('error removing contract from group', err)
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canModifyGroupContracts(group: Group, userId: string) {
|
export function getGroupLinkToDisplay(contract: Contract) {
|
||||||
return (
|
const { groupLinks } = contract
|
||||||
group.creatorId === userId ||
|
const sortedGroupLinks = groupLinks?.sort(
|
||||||
group.memberIds.includes(userId) ||
|
(a, b) => b.createdTime - a.createdTime
|
||||||
group.anyoneCanJoin
|
|
||||||
)
|
)
|
||||||
|
const groupCreatorAdded = sortedGroupLinks?.find(
|
||||||
|
(g) => g.userId === contract.creatorId
|
||||||
|
)
|
||||||
|
const groupToDisplay = groupCreatorAdded
|
||||||
|
? groupCreatorAdded
|
||||||
|
: sortedGroupLinks?.[0] ?? null
|
||||||
|
return groupToDisplay
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listMembers(group: Group) {
|
||||||
|
const members = await getValues<GroupMemberDoc>(groupMembers(group.id))
|
||||||
|
return await Promise.all(members.map((m) => m.userId).map(getUser))
|
||||||
}
|
}
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user