Merge branch 'main' into twitch-linking

This commit is contained in:
mantikoros 2022-09-03 11:30:08 -05:00
commit 89032431fb
123 changed files with 3319 additions and 3220 deletions

131
common/calculate-metrics.ts Normal file
View 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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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) ![](https://i.imgur.com/nVGY4pL.png)
- Laser-cut Foldy by [@wasabipesto](https://manifold.markets/wasabipesto) ![](https://i.imgur.com/g9S6v3P.jpg)

View File

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

View File

@ -160,34 +160,49 @@ service cloud.firestore {
.hasOnly(['isSeen', 'viewTime']); .hasOnly(['isSeen', 'viewTime']);
} }
match /groups/{groupId} { match /{somePath=**}/groupMembers/{memberId} {
allow read;
}
match /{somePath=**}/groupContracts/{contractId} {
allow read;
}
match /groups/{groupId} {
allow read; allow read;
allow update: if request.auth.uid == resource.data.creatorId 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;
} }
} }
} }

View File

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

View File

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

View File

@ -151,15 +151,6 @@ export const createNotification = async (
} }
} }
const notifyContractCreatorOfUniqueBettorsBonus = async (
userToReasonTexts: user_to_reason_texts,
userId: string
) => {
userToReasonTexts[userId] = {
reason: 'unique_bettors_on_your_contract',
}
}
const userToReasonTexts: user_to_reason_texts = {} const userToReasonTexts: user_to_reason_texts = {}
// The following functions modify the userToReasonTexts object in place. // The following functions modify the userToReasonTexts object in place.
@ -192,16 +183,6 @@ export const createNotification = async (
sourceContract sourceContract
) { ) {
await notifyContractCreator(userToReasonTexts, sourceContract) await notifyContractCreator(userToReasonTexts, sourceContract)
} else if (
sourceType === 'bonus' &&
sourceUpdateType === 'created' &&
sourceContract
) {
// Note: the daily bonus won't have a contract attached to it
await notifyContractCreatorOfUniqueBettorsBonus(
userToReasonTexts,
sourceContract.creatorId
)
} }
await createUsersNotifications(userToReasonTexts) await createUsersNotifications(userToReasonTexts)
@ -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))
}

View File

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

View File

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

View File

@ -4,7 +4,6 @@ import { Bet } from '../../common/bet'
import { getProbability } from '../../common/calculate' import { getProbability } from '../../common/calculate'
import { Comment } from '../../common/comment' import { Comment } from '../../common/comment'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { DPM_CREATOR_FEE } from '../../common/fees'
import { PrivateUser, User } from '../../common/user' import { PrivateUser, User } from '../../common/user'
import { import {
formatLargeNumber, formatLargeNumber,
@ -187,7 +186,7 @@ export const sendPersonalFollowupEmail = async (
const emailBody = `Hi ${firstName}, const emailBody = `Hi ${firstName},
Thanks for signing up! I'm one of the cofounders of Manifold Markets, and was wondering how you've found your exprience on the platform so far? Thanks for signing up! I'm one of the cofounders of Manifold Markets, and was wondering how you've found your experience on the platform so far?
If you haven't already, I encourage you to try creating your own prediction market (https://manifold.markets/create) and joining our Discord chat (https://discord.com/invite/eHQBNBqXuh). If you haven't already, I encourage you to try creating your own prediction market (https://manifold.markets/create) and joining our Discord chat (https://discord.com/invite/eHQBNBqXuh).
@ -322,7 +321,7 @@ export const sendMarketCloseEmail = async (
const { username, name, id: userId } = user const { username, name, id: userId } = user
const firstName = name.split(' ')[0] const firstName = name.split(' ')[0]
const { question, slug, volume, mechanism, collectedFees } = contract const { question, slug, volume } = contract
const url = `https://${DOMAIN}/${username}/${slug}` const url = `https://${DOMAIN}/${username}/${slug}`
const emailType = 'market-resolve' const emailType = 'market-resolve'
@ -339,10 +338,6 @@ export const sendMarketCloseEmail = async (
userId, userId,
name: firstName, name: firstName,
volume: formatMoney(volume), volume: formatMoney(volume),
creatorFee:
mechanism === 'dpm-2'
? `${DPM_CREATOR_FEE * 100}% of the profits`
: formatMoney(collectedFees.creatorFee),
} }
) )
} }

View File

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

View File

@ -21,9 +21,7 @@ export * from './on-follow-user'
export * from './on-unfollow-user' export * from './on-unfollow-user'
export * from './on-create-liquidity-provision' export * from './on-create-liquidity-provision'
export * from './on-update-group' export * from './on-update-group'
export * from './on-create-group'
export * from './on-update-user' export * from './on-update-user'
export * from './on-create-comment-on-group'
export * from './on-create-txn' export * from './on-create-txn'
export * from './on-delete-group' export * from './on-delete-group'
export * from './score-contracts' export * from './score-contracts'
@ -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
} }

View File

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

View File

@ -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(
contract.creatorId, uniq([
...comments.map((comment) => comment.userId), contract.creatorId,
]).filter((id) => id !== comment.userId) ...comments.map((comment) => comment.userId),
]).filter((id) => id !== comment.userId),
contractId
)
await Promise.all( await Promise.all(
recipientUserIds.map((userId) => recipientUserIds.map((userId) =>

View File

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

View File

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

View File

@ -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,
})
}

View File

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

View File

@ -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,
})
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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()
}

View File

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

View File

@ -1,43 +1,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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import clsx from 'clsx' import clsx from 'clsx'
import { useState } from 'react' import React, { useState } from 'react'
import Textarea from 'react-expanding-textarea' import Textarea from 'react-expanding-textarea'
import { findBestMatch } from 'string-similarity' import { findBestMatch } from 'string-similarity'
@ -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}

View 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,
}
}

View File

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

View File

@ -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,27 +68,32 @@ export function BetPanel(props: {
className className
)} )}
> >
<QuickOrLimitBet {user ? (
isLimitOrder={isLimitOrder} <>
setIsLimitOrder={setIsLimitOrder} <QuickOrLimitBet
hideToggle={!user} isLimitOrder={isLimitOrder}
/> setIsLimitOrder={setIsLimitOrder}
<BuyPanel hideToggle={!user}
hidden={isLimitOrder} />
contract={contract} <BuyPanel
user={user} hidden={isLimitOrder}
unfilledBets={unfilledBets} contract={contract}
/> user={user}
<LimitOrderPanel unfilledBets={unfilledBets}
hidden={!isLimitOrder} />
contract={contract} <LimitOrderPanel
user={user} hidden={!isLimitOrder}
unfilledBets={unfilledBets} contract={contract}
/> user={user}
unfilledBets={unfilledBets}
<BetSignUpPrompt /> />
</>
{!user && <PlayMoneyDisclaimer />} ) : (
<>
<BetSignUpPrompt />
<PlayMoneyDisclaimer />
</>
)}
</Col> </Col>
{user && unfilledBets.length > 0 && ( {user && unfilledBets.length > 0 && (
@ -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">
Max amount<span className="ml-1 text-red-500">*</span> <span>
</div> Max amount<span className="ml-1 text-red-500">*</span>
</span>
<span className={'xl:hidden'}>
(balance: {formatMoney(user?.balance ?? 0)})
</span>
</Row>
<BuyAmountInput <BuyAmountInput
inputClassName="w-full max-w-none" inputClassName="w-full max-w-none"
amount={betAmount} amount={betAmount}

View File

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

View File

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

View File

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

View File

@ -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,89 +171,59 @@ 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">
<div>You'll bet:</div> <Col>
<Row <div>You'll bet:</div>
className={ <Row
'form-control w-full max-w-xs items-center justify-between gap-4 pr-3' className={
} 'form-control w-full max-w-xs items-center justify-between gap-4 pr-3'
>
<AmountInput
amount={challengeInfo.amount || undefined}
onChange={(newAmount) =>
setChallengeInfo((m: challengeInfo) => {
return {
...m,
amount: newAmount ?? 0,
acceptorAmount: editingAcceptorAmount
? m.acceptorAmount
: newAmount ?? 0,
}
})
}
error={undefined}
label={'M$'}
inputClassName="w-24"
/>
<span className={''}>on</span>
{challengeInfo.outcome === 'YES' ? <YesLabel /> : <NoLabel />}
</Row>
<Row className={'mt-3 max-w-xs justify-end'}>
<Button
color={'gray-white'}
onClick={() =>
setChallengeInfo((m: challengeInfo) => {
return {
...m,
outcome: m.outcome === 'YES' ? 'NO' : 'YES',
}
})
} }
> >
<SwitchVerticalIcon className={'h-6 w-6'} />
</Button>
</Row>
<Row className={'items-center'}>If they bet:</Row>
<Row className={'max-w-xs items-center justify-between gap-4 pr-3'}>
<div className={'w-32 sm:mr-1'}>
<AmountInput <AmountInput
amount={challengeInfo.acceptorAmount || undefined} amount={challengeInfo.amount || undefined}
onChange={(newAmount) => { onChange={(newAmount) =>
setEditingAcceptorAmount(true)
setChallengeInfo((m: challengeInfo) => { setChallengeInfo((m: challengeInfo) => {
return { return {
...m, ...m,
amount: newAmount ?? 0,
acceptorAmount: newAmount ?? 0, acceptorAmount: newAmount ?? 0,
} }
}) })
}} }
error={undefined} error={undefined}
label={'M$'} label={'M$'}
inputClassName="w-24" inputClassName="w-24"
/> />
<span className={''}>on</span>
{challengeInfo.outcome === 'YES' ? <YesLabel /> : <NoLabel />}
</Row>
<Row className={'max-w-xs justify-end'}>
<Button
color={'gray-white'}
onClick={() =>
setChallengeInfo((m: challengeInfo) => {
return {
...m,
outcome: m.outcome === 'YES' ? 'NO' : 'YES',
}
})
}
>
<SwitchVerticalIcon className={'h-6 w-6'} />
</Button>
</Row>
</Col>
<Row className={'items-center'}>If they bet:</Row>
<Row className={'max-w-xs items-center justify-between gap-4 pr-3'}>
<div className={'mt-1 w-32 sm:mr-1'}>
<span className={'ml-2 font-bold'}>
{formatMoney(challengeInfo.acceptorAmount)}
</span>
</div> </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">

View File

@ -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,14 +206,18 @@ export function ContractSearch(props: {
onSearchParametersChanged={onSearchParametersChanged} onSearchParametersChanged={onSearchParametersChanged}
noControls={noControls} noControls={noControls}
/> />
<ContractsGrid {renderContracts ? (
contracts={renderedContracts} renderContracts(renderedContracts, performQuery)
loadMore={noControls ? undefined : performQuery} ) : (
showTime={state.showTime ?? undefined} <ContractsGrid
onContractClick={onContractClick} contracts={renderedContracts}
highlightOptions={highlightOptions} loadMore={noControls ? undefined : performQuery}
cardHideOptions={cardHideOptions} showTime={state.showTime ?? undefined}
/> onContractClick={onContractClick}
highlightOptions={highlightOptions}
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

View File

@ -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') &&

View File

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

View File

@ -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
<Row> const groupToDisplay = getGroupLinkToDisplay(contract)
<UserGroupIcon className="mx-1 inline h-5 w-5 shrink-0" /> const groupInfo = groupToDisplay ? (
<span className="truncate"> <Link prefetch={false} href={groupPath(groupToDisplay.slug)}>
{groupToDisplay ? groupToDisplay.name : 'No group'} <a
</span> className={clsx(
</Row> 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>
<UserGroupIcon className="mx-1 inline h-5 w-5 shrink-0" />
<span className="truncate">No Group</span>
</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 {groupInfo}
size={'xs'} {user && groupToDisplay && (
className={'max-w-[200px] pr-2'}
color={'gray-white'}
onClick={() =>
groupToDisplay
? Router.push(groupPath(groupToDisplay.slug))
: setOpen(!open)
}
>
{groupInfo}
</Button>
{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>
))}
</> </>
) )
} }

View File

@ -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} />
)} )}

View File

@ -1,5 +1,4 @@
import React from 'react' import React from 'react'
import clsx from 'clsx'
import { tradingAllowed } from 'web/lib/firebase/contracts' import { tradingAllowed } from 'web/lib/firebase/contracts'
import { Col } from '../layout/col' import { Col } from '../layout/col'
@ -16,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>
)
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -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"> Create a market
{overrideText ?? 'Create a market'} </Button>
</Button> </Link>
</Link>
</div>
) )
} }

View 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>
)
}

View File

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

View File

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

View File

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

View File

@ -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,26 +25,23 @@ 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} /> ) : bettor ? (
) : bettor ? ( <Avatar avatarUrl={bettor.avatarUrl} username={bettor.username} />
<Avatar avatarUrl={bettor.avatarUrl} username={bettor.username} /> ) : (
) : ( <div className="relative px-1">
<div className="relative px-1"> <EmptyAvatar />
<EmptyAvatar />
</div>
)}
<div className={'min-w-0 flex-1 py-1.5'}>
<LiquidityStatusText
liquidity={liquidity}
isSelf={isSelf}
bettor={bettor}
/>
</div> </div>
</Row> )}
</> <LiquidityStatusText
liquidity={liquidity}
isSelf={isSelf}
bettor={bettor}
className={'flex-1'}
/>
</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} />
) : ( ) : (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,
}) })

View File

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

View File

@ -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>
</>
)
}

View File

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

View File

@ -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)
}, {}) }, {})
observer.observe(elem) ).current
return () => observer.disconnect()
}, [elem, onVisibilityUpdated]) useEffect(() => {
if (elem) {
observer.observe(elem)
return () => observer.unobserve(elem)
}
}, [elem, observer])
return <div ref={setElem} className={className}></div> return <div ref={setElem} className={className}></div>
} }

View File

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

View File

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

View File

@ -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(() => {

View File

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

View File

@ -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'),
])
} }

View File

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

View File

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

View File

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

View File

@ -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) => {
queryClient.prefetchQuery(['users', userId], () => getUser(userId))
} }
export const prefetchUsers = (userIds: string[]) => { export const usePrefetchUsers = (userIds: string[]) => {
userIds.forEach(prefetchUser) const queryClient = useQueryClient()
return userIds.map((userId) =>
queryClient.prefetchQuery(['users', userId], () => getUser(userId))
)
} }

View File

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

View File

@ -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[]) {

View File

@ -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>(
getCommentsOnGroupCollection(groupId), query(
(comments) => { getCommentsOnGroupCollection(groupId),
comments.sort((c1, c2) => c1.createdTime - c2.createdTime) orderBy('createdTime', 'desc')
setComments(comments) ),
} setComments
) )
} }

View File

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

View File

@ -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,
.then(() => group) createdTime: Date.now(),
.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