Merge branch 'main' into fix-tournament-page

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

View File

@ -6,14 +6,16 @@ export type Group = {
creatorId: string // User id
createdTime: number
mostRecentActivityTime: number
memberIds: string[] // User ids
anyoneCanJoin: boolean
contractIds: string[]
totalContracts: number
totalMembers: number
aboutPostId?: string
chatDisabled?: boolean
mostRecentChatActivityTime?: number
mostRecentContractAddedTime?: number
/** @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_ABOUT_LENGTH = 140

View File

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

View File

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

View File

@ -48,6 +48,9 @@ export default Node.create<IframeOptions>({
frameborder: {
default: 0,
},
height: {
default: 0,
},
allowfullscreen: {
default: this.options.allowFullscreen,
parseHTML: () => this.options.allowFullscreen,
@ -60,6 +63,11 @@ export default Node.create<IframeOptions>({
},
renderHTML({ HTMLAttributes }) {
this.options.HTMLAttributes.style =
this.options.HTMLAttributes.style +
' height: ' +
HTMLAttributes.height +
';'
return [
'div',
this.options.HTMLAttributes,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import clsx from 'clsx'
import { useEffect, useRef, useState } from 'react'
import React, { useEffect, useRef, useState } from 'react'
import { XIcon } from '@heroicons/react/solid'
import { Answer } from 'common/answer'
@ -132,7 +132,10 @@ export function AnswerBetPanel(props: {
</button>
)}
</Row>
<div className="my-3 text-left text-sm text-gray-500">Amount </div>
<Row className="my-3 justify-between text-left text-sm text-gray-500">
Amount
<span>(balance: {formatMoney(user?.balance ?? 0)})</span>
</Row>
<BuyAmountInput
inputClassName="w-full max-w-none"
amount={betAmount}

View File

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

View File

@ -1,5 +1,5 @@
import clsx from 'clsx'
import { useState } from 'react'
import React, { useState } from 'react'
import Textarea from 'react-expanding-textarea'
import { findBestMatch } from 'string-similarity'
@ -149,7 +149,12 @@ export function CreateAnswerPanel(props: { contract: FreeResponseContract }) {
{text && (
<>
<Col className="mt-1 gap-2">
<div className="text-sm text-gray-500">Bet amount</div>
<Row className="my-3 justify-between text-left text-sm text-gray-500">
Bet Amount
<span className={'sm:hidden'}>
(balance: {formatMoney(user?.balance ?? 0)})
</span>
</Row>{' '}
<BuyAmountInput
amount={betAmount}
onChange={setBetAmount}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -282,8 +282,8 @@ function ContractSearchControls(props: {
: DEFAULT_CATEGORY_GROUPS.map((g) => g.slug)
const memberPillGroups = sortBy(
memberGroups.filter((group) => group.contractIds.length > 0),
(group) => group.contractIds.length
memberGroups.filter((group) => group.totalContracts > 0),
(group) => group.totalContracts
).reverse()
const pillGroups: { name: string; slug: string }[] =

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,391 +0,0 @@
import { Row } from 'web/components/layout/row'
import { Col } from 'web/components/layout/col'
import { PrivateUser, User } from 'common/user'
import React, { useEffect, memo, useState, useMemo } from 'react'
import { Avatar } from 'web/components/avatar'
import { Group } from 'common/group'
import { Comment, GroupComment } from 'common/comment'
import { createCommentOnGroup } from 'web/lib/firebase/comments'
import { 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 { User } from 'common/user'
import { useEffect, useState } from 'react'
import { useState } from 'react'
import { useUser } from 'web/hooks/use-user'
import { withTracking } from 'web/lib/service/analytics'
import { Row } from 'web/components/layout/row'
import { useMemberGroups } from 'web/hooks/use-group'
import { useMemberGroups, useMemberIds } from 'web/hooks/use-group'
import { TextButton } from 'web/components/text-button'
import { Group } from 'common/group'
import { Modal } from 'web/components/layout/modal'
@ -17,9 +17,7 @@ import toast from 'react-hot-toast'
export function GroupsButton(props: { user: User }) {
const { user } = props
const [isOpen, setIsOpen] = useState(false)
const groups = useMemberGroups(user.id, undefined, {
by: 'mostRecentChatActivityTime',
})
const groups = useMemberGroups(user.id)
return (
<>
@ -74,51 +72,34 @@ function GroupsList(props: { groups: Group[] }) {
function GroupItem(props: { group: Group; className?: string }) {
const { group, className } = props
const user = useUser()
const memberIds = useMemberIds(group.id)
return (
<Row className={clsx('items-center justify-between gap-2 p-2', className)}>
<Row className="line-clamp-1 items-center gap-2">
<GroupLinkItem group={group} />
</Row>
<JoinOrLeaveGroupButton group={group} />
<JoinOrLeaveGroupButton
group={group}
user={user}
isMember={user ? memberIds?.includes(user.id) : false}
/>
</Row>
)
}
export function JoinOrLeaveGroupButton(props: {
group: Group
isMember: boolean
user: User | undefined | null
small?: boolean
className?: string
}) {
const { group, small, className } = props
const currentUser = useUser()
const [isMember, setIsMember] = useState<boolean>(false)
useEffect(() => {
if (currentUser && group.memberIds.includes(currentUser.id)) {
setIsMember(group.memberIds.includes(currentUser.id))
}
}, [currentUser, group])
const onJoinGroup = () => {
if (!currentUser) return
setIsMember(true)
joinGroup(group, currentUser.id).catch(() => {
setIsMember(false)
toast.error('Failed to join group')
})
}
const onLeaveGroup = () => {
if (!currentUser) return
setIsMember(false)
leaveGroup(group, currentUser.id).catch(() => {
setIsMember(true)
toast.error('Failed to leave group')
})
}
const { group, small, className, isMember, user } = props
const smallStyle =
'btn !btn-xs border-2 border-gray-500 bg-white normal-case text-gray-500 hover:border-gray-500 hover:bg-white hover:text-gray-500'
if (!currentUser || isMember === undefined) {
if (!user) {
if (!group.anyoneCanJoin)
return <div className={clsx(className, 'text-gray-500')}>Closed</div>
return (
@ -130,6 +111,16 @@ export function JoinOrLeaveGroupButton(props: {
</button>
)
}
const onJoinGroup = () => {
joinGroup(group, user.id).catch(() => {
toast.error('Failed to join group')
})
}
const onLeaveGroup = () => {
leaveGroup(group, user.id).catch(() => {
toast.error('Failed to leave group')
})
}
if (isMember) {
return (

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"
className={clsx(className ? className : 'relative z-40 flex-shrink-0')}
>
<div>
<Menu.Button className="w-full rounded-full">
<span className="sr-only">Open user menu</span>
{buttonContent}
</Menu.Button>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-100"

View File

@ -11,7 +11,7 @@ export function ProfileSummary(props: { user: User }) {
<Link href={`/${user.username}?tab=bets`}>
<a
onClick={trackCallback('sidebar: profile')}
className="group flex flex-row items-center gap-4 rounded-md py-3 text-gray-500 hover:bg-gray-100 hover:text-gray-700"
className="group mb-3 flex flex-row items-center gap-4 truncate rounded-md py-3 text-gray-500 hover:bg-gray-100 hover:text-gray-700"
>
<Avatar avatarUrl={user.avatarUrl} username={user.username} noLink />

View File

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

View File

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

View File

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

View File

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

View File

@ -9,11 +9,18 @@ import { DOMAIN } from 'common/envs/constants'
import { copyToClipboard } from 'web/lib/util/copy'
import { track } from 'web/lib/service/analytics'
export function embedCode(contract: Contract) {
export function embedContractCode(contract: Contract) {
const title = contract.question
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 }) {
@ -26,7 +33,7 @@ export function ShareEmbedButton(props: { contract: Contract }) {
as="div"
className="relative z-10 flex-shrink-0"
onMouseUp={() => {
copyToClipboard(embedCode(contract))
copyToClipboard(embedContractCode(contract))
toast.success('Embed code copied!', {
icon: codeIcon,
})

View File

@ -1,13 +1,7 @@
import { linkClass, SiteLink } from 'web/components/site-link'
import { SiteLink } from 'web/components/site-link'
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 maxLength = 11
const shortName =
@ -38,63 +32,3 @@ export function UserLink(props: {
</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

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

View File

@ -9,9 +9,10 @@ import {
listenForHotContracts,
listenForInactiveContracts,
listenForNewContracts,
getUserBetContracts,
getUserBetContractsQuery,
} from 'web/lib/firebase/contracts'
import { QueryClient } from 'react-query'
import { useQueryClient } from 'react-query'
export const useContracts = () => {
const [contracts, setContracts] = useState<Contract[] | undefined>()
@ -93,12 +94,12 @@ export const useUpdatedContracts = (contracts: Contract[] | undefined) => {
: undefined
}
const queryClient = new QueryClient()
export const prefetchUserBetContracts = (userId: string) =>
queryClient.prefetchQuery(['contracts', 'bets', userId], () =>
getUserBetContractsQuery(userId)
export const usePrefetchUserBetContracts = (userId: string) => {
const queryClient = useQueryClient()
return queryClient.prefetchQuery(['contracts', 'bets', userId], () =>
getUserBetContracts(userId)
)
}
export const useUserBetContracts = (userId: string) => {
const result = useFirestoreQueryData(

View File

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

View File

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

View File

@ -1,19 +1,22 @@
import { QueryClient } from 'react-query'
import { useQueryClient } from 'react-query'
import { useFirestoreQueryData } from '@react-query-firebase/firestore'
import { DAY_MS, HOUR_MS } from 'common/util/time'
import { getPortfolioHistoryQuery, Period } from 'web/lib/firebase/users'
const queryClient = new QueryClient()
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 prefetchPortfolioHistory = (userId: string, period: Period) => {
export const usePrefetchPortfolioHistory = (userId: string, period: Period) => {
const queryClient = useQueryClient()
const cutoff = getCutoff(period)
return queryClient.prefetchQuery(['portfolio-history', userId, cutoff], () =>
getPortfolioHistoryQuery(userId, cutoff)
getPortfolioHistory(userId, cutoff)
)
}

View File

@ -1,11 +1,12 @@
import { prefetchUserBetContracts } from './use-contracts'
import { prefetchPortfolioHistory } from './use-portfolio-history'
import { prefetchUserBets } from './use-user-bets'
import { usePrefetchUserBetContracts } from './use-contracts'
import { usePrefetchPortfolioHistory } from './use-portfolio-history'
import { usePrefetchUserBets } from './use-user-bets'
export function usePrefetch(userId: string | undefined) {
const maybeUserId = userId ?? ''
prefetchUserBets(maybeUserId)
prefetchUserBetContracts(maybeUserId)
prefetchPortfolioHistory(maybeUserId, 'weekly')
return Promise.all([
usePrefetchUserBets(maybeUserId),
usePrefetchUserBetContracts(maybeUserId),
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

@ -1,16 +1,17 @@
import { QueryClient } from 'react-query'
import { useQueryClient } from 'react-query'
import { useFirestoreQueryData } from '@react-query-firebase/firestore'
import { useEffect, useState } from 'react'
import {
Bet,
getUserBets,
getUserBetsQuery,
listenForUserContractBets,
} from 'web/lib/firebase/bets'
const queryClient = new QueryClient()
export const prefetchUserBets = (userId: string) =>
queryClient.prefetchQuery(['bets', userId], () => getUserBetsQuery(userId))
export const usePrefetchUserBets = (userId: string) => {
const queryClient = useQueryClient()
return queryClient.prefetchQuery(['bets', userId], () => getUserBets(userId))
}
export const useUserBets = (userId: string) => {
const result = useFirestoreQueryData(

View File

@ -1,6 +1,6 @@
import { useContext } from 'react'
import { useFirestoreDocumentData } from '@react-query-firebase/firestore'
import { QueryClient } from 'react-query'
import { useQueryClient } from 'react-query'
import { doc, DocumentData } from 'firebase/firestore'
import { getUser, User, users } from 'web/lib/firebase/users'
@ -28,12 +28,13 @@ export const useUserById = (userId = '_') => {
return result.isLoading ? undefined : result.data
}
const queryClient = new QueryClient()
export const usePrefetchUser = (userId: string) => {
return usePrefetchUsers([userId])[0]
}
export const prefetchUser = (userId: string) => {
export const usePrefetchUsers = (userIds: string[]) => {
const queryClient = useQueryClient()
return userIds.map((userId) =>
queryClient.prefetchQuery(['users', userId], () => getUser(userId))
}
export const prefetchUsers = (userIds: string[]) => {
userIds.forEach(prefetchUser)
)
}

View File

@ -70,20 +70,16 @@ export function listenForBets(
)
}
export async function getUserBets(
userId: string,
options: { includeRedemptions: boolean }
) {
const { includeRedemptions } = options
return getValues<Bet>(
query(collectionGroup(db, 'bets'), where('userId', '==', userId))
)
.then((bets) =>
bets.filter(
(bet) => (includeRedemptions || !bet.isRedemption) && !bet.isAnte
)
)
.catch((reason) => reason)
export async function getUserBets(userId: string) {
return getValues<Bet>(getUserBetsQuery(userId))
}
export function getUserBetsQuery(userId: string) {
return query(
collectionGroup(db, 'bets'),
where('userId', '==', userId),
orderBy('createdTime', 'desc')
) as Query<Bet>
}
export async function getBets(options: {
@ -124,22 +120,16 @@ export async function getBets(options: {
}
export async function getContractsOfUserBets(userId: string) {
const bets: Bet[] = await getUserBets(userId, { includeRedemptions: false })
const contractIds = uniq(bets.map((bet) => bet.contractId))
const bets = await getUserBets(userId)
const contractIds = uniq(
bets.filter((b) => !b.isAnte).map((bet) => bet.contractId)
)
const contracts = await Promise.all(
contractIds.map((contractId) => getContractFromId(contractId))
)
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(
userId: string,
contractId: string,

View File

@ -164,6 +164,10 @@ export function listenForUserContracts(
return listenForValues<Contract>(q, setContracts)
}
export function getUserBetContracts(userId: string) {
return getValues<Contract>(getUserBetContractsQuery(userId))
}
export function getUserBetContractsQuery(userId: string) {
return query(
contracts,

View File

@ -1,13 +1,17 @@
import {
collection,
collectionGroup,
deleteDoc,
deleteField,
doc,
getDocs,
onSnapshot,
query,
setDoc,
updateDoc,
where,
} from 'firebase/firestore'
import { sortBy, uniq } from 'lodash'
import { uniq } from 'lodash'
import { Group, GROUP_CHAT_SLUG, GroupLink } from 'common/group'
import {
coll,
@ -18,8 +22,15 @@ import {
} from './utils'
import { Contract } from 'common/contract'
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 groupMembers = (groupId: string) =>
collection(groups, groupId, 'groupMembers')
export const groupContracts = (groupId: string) =>
collection(groups, groupId, 'groupContracts')
export function groupPath(
groupSlug: string,
@ -33,6 +44,9 @@ export function groupPath(
return `/group/${groupSlug}${subpath ? `/${subpath}` : ''}`
}
export type GroupContractDoc = { contractId: string; createdTime: number }
export type GroupMemberDoc = { userId: string; createdTime: number }
export function updateGroup(group: Group, updates: Partial<Group>) {
return updateDoc(doc(groups, group.id), updates)
}
@ -57,6 +71,13 @@ export function listenForGroups(setGroups: (groups: Group[]) => void) {
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) {
return listenForValues(
query(groups, where('anyoneCanJoin', '==', true)),
@ -68,6 +89,12 @@ export function getGroup(groupId: string) {
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) {
const q = query(groups, where('slug', '==', slug))
const docs = (await getDocs(q)).docs
@ -81,33 +108,32 @@ export function listenForGroup(
return listenForValue(doc(groups, groupId), setGroup)
}
export function listenForMemberGroups(
export function listenForMemberGroupIds(
userId: string,
setGroups: (groups: Group[]) => void,
sort?: { by: 'mostRecentChatActivityTime' | 'mostRecentContractAddedTime' }
setGroupIds: (groupIds: string[]) => void
) {
const q = query(groups, where('memberIds', 'array-contains', userId))
const sorter = (group: Group) => {
if (sort?.by === 'mostRecentChatActivityTime') {
return group.mostRecentChatActivityTime ?? group.createdTime
}
if (sort?.by === 'mostRecentContractAddedTime') {
return group.mostRecentContractAddedTime ?? group.createdTime
}
return group.mostRecentActivityTime
}
return listenForValues<Group>(q, (groups) => {
const sorted = sortBy(groups, [(group) => -sorter(group)])
setGroups(sorted)
const q = query(
collectionGroup(db, 'groupMembers'),
where('userId', '==', userId)
)
return onSnapshot(q, { includeMetadataChanges: true }, (snapshot) => {
if (snapshot.metadata.fromCache) return
const values = snapshot.docs.map((doc) => doc.ref.parent.parent?.id)
setGroupIds(filterDefined(values))
})
}
export async function listenForGroupsWithContractId(
contractId: string,
export function listenForMemberGroups(
userId: string,
setGroups: (groups: Group[]) => void
) {
const q = query(groups, where('contractIds', 'array-contains', contractId))
return listenForValues<Group>(q, setGroups)
return listenForMemberGroupIds(userId, (groupIds) => {
return Promise.all(groupIds.map(getGroup)).then((groups) => {
setGroups(filterDefined(groups))
})
})
}
export async function 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> {
const { memberIds } = group
if (memberIds.includes(userId)) return // already a member
const newMemberIds = [...memberIds, userId]
return await updateGroup(group, { memberIds: uniq(newMemberIds) })
// create a new member document in grouoMembers collection
const memberDoc = doc(groupMembers(group.id), userId)
return await setDoc(memberDoc, {
userId,
createdTime: Date.now(),
})
}
export async function leaveGroup(group: Group, userId: string): Promise<void> {
const { memberIds } = group
if (!memberIds.includes(userId)) return // not a member
const newMemberIds = memberIds.filter((id) => id !== userId)
return await updateGroup(group, { memberIds: uniq(newMemberIds) })
// delete the member document in groupMembers collection
const memberDoc = doc(groupMembers(group.id), userId)
return await deleteDoc(memberDoc)
}
export async function addContractToGroup(
@ -141,7 +166,6 @@ export async function addContractToGroup(
contract: Contract,
userId: string
) {
if (!canModifyGroupContracts(group, userId)) return
const newGroupLinks = [
...(contract.groupLinks ?? []),
{
@ -158,25 +182,18 @@ export async function addContractToGroup(
groupLinks: newGroupLinks,
})
if (!group.contractIds.includes(contract.id)) {
return await updateGroup(group, {
contractIds: uniq([...group.contractIds, contract.id]),
// create new contract document in groupContracts collection
const contractDoc = doc(groupContracts(group.id), contract.id)
await setDoc(contractDoc, {
contractId: contract.id,
createdTime: Date.now(),
})
.then(() => group)
.catch((err) => {
console.error('error adding contract to group', err)
return err
})
}
}
export async function removeContractFromGroup(
group: Group,
contract: Contract,
userId: string
contract: Contract
) {
if (!canModifyGroupContracts(group, userId)) return
if (contract.groupLinks?.map((l) => l.groupId).includes(group.id)) {
const newGroupLinks = contract.groupLinks?.filter(
(link) => link.slug !== group.slug
@ -188,23 +205,26 @@ export async function removeContractFromGroup(
})
}
if (group.contractIds.includes(contract.id)) {
const newContractIds = group.contractIds.filter((id) => id !== contract.id)
return await updateGroup(group, {
contractIds: uniq(newContractIds),
})
.then(() => group)
.catch((err) => {
console.error('error removing contract from group', err)
return err
})
}
// delete the contract document in groupContracts collection
const contractDoc = doc(groupContracts(group.id), contract.id)
await deleteDoc(contractDoc)
}
export function canModifyGroupContracts(group: Group, userId: string) {
return (
group.creatorId === userId ||
group.memberIds.includes(userId) ||
group.anyoneCanJoin
export function getGroupLinkToDisplay(contract: Contract) {
const { groupLinks } = contract
const sortedGroupLinks = groupLinks?.sort(
(a, b) => b.createdTime - a.createdTime
)
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))
}

View File

@ -10,9 +10,28 @@ import { connectFunctionsEmulator, getFunctions } from 'firebase/functions'
// Initialize Firebase
export const app = getApps().length ? getApp() : initializeApp(FIREBASE_CONFIG)
export const db = initializeFirestore(app, {
experimentalForceLongPolling: true,
})
function iOS() {
if (typeof navigator === 'undefined') {
// We're on the server, proceed normally
return false
}
return (
[
'iPad Simulator',
'iPhone Simulator',
'iPod Simulator',
'iPad',
'iPhone',
'iPod',
].includes(navigator.platform) ||
// iPad on iOS 13 detection
(navigator.userAgent.includes('Mac') && 'ontouchend' in document)
)
}
// Long polling is necessary for ios, see: https://github.com/firebase/firebase-js-sdk/issues/6118
const opts = iOS() ? { experimentalForceLongPolling: true } : {}
export const db = initializeFirestore(app, opts)
export const functions = getFunctions()
export const storage = getStorage()

View File

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

View File

@ -254,6 +254,10 @@ export async function unfollow(userId: string, unfollowedUserId: string) {
await deleteDoc(followDoc)
}
export function getPortfolioHistory(userId: string, since: number) {
return getValues<PortfolioMetrics>(getPortfolioHistoryQuery(userId, since))
}
export function getPortfolioHistoryQuery(userId: string, since: number) {
return query(
collectionGroup(db, 'portfolioHistory'),

View File

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

View File

@ -36,6 +36,8 @@ import { useSaveReferral } from 'web/hooks/use-save-referral'
import { User } from 'common/user'
import { ContractComment } from 'common/comment'
import { getOpenGraphProps } from 'common/contract-details'
import { ContractDescription } from 'web/components/contract/contract-description'
import { ExtraContractActionsRow } from 'web/components/contract/extra-contract-actions-row'
import {
ContractLeaderboard,
ContractTopTrades,
@ -232,6 +234,8 @@ export function ContractPageContent(
)}
<ContractOverview contract={contract} bets={nonChallengeBets} />
<ExtraContractActionsRow contract={contract} />
<ContractDescription className="mb-6 px-2" contract={contract} />
{outcomeType === 'NUMERIC' && (
<AlertBox

View File

@ -18,8 +18,9 @@ export default async function handler(
return
}
const bets = await getUserBets(user.id, { includeRedemptions: false })
const bets = await getUserBets(user.id)
const visibleBets = bets.filter((b) => !b.isRedemption && !b.isAnte)
res.setHeader('Cache-Control', 'max-age=0')
return res.status(200).json(bets)
return res.status(200).json(visibleBets)
}

View File

@ -41,7 +41,7 @@ export default function CreatePost() {
return (
<Page>
<div className="mx-auto w-full max-w-2xl">
<div className="mx-auto w-full max-w-3xl">
<div className="rounded-lg px-6 py-4 sm:py-0">
<Title className="!mt-0" text="Create a post" />
<form>

View File

@ -20,7 +20,7 @@ import {
import { formatMoney } from 'common/util/format'
import { removeUndefinedProps } from 'common/util/object'
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
import { canModifyGroupContracts, getGroup } from 'web/lib/firebase/groups'
import { getGroup } from 'web/lib/firebase/groups'
import { Group } from 'common/group'
import { useTracking } from 'web/hooks/use-tracking'
import { useWarnUnsavedChanges } from 'web/hooks/use-warn-unsaved-changes'
@ -139,7 +139,7 @@ export function NewContract(props: {
useEffect(() => {
if (groupId)
getGroup(groupId).then((group) => {
if (group && canModifyGroupContracts(group, creator.id)) {
if (group) {
setSelectedGroup(group)
setShowGroupSelector(false)
}
@ -314,14 +314,14 @@ export function NewContract(props: {
<div className="form-control mb-2 items-start">
<label className="label gap-2">
<span className="mb-1">Range</span>
<InfoTooltip text="The minimum and maximum numbers across the numeric range." />
<InfoTooltip text="The lower and higher bounds of the numeric range. Choose bounds the value could reasonably be expected to hit." />
</label>
<Row className="gap-2">
<input
type="number"
className="input input-bordered w-32"
placeholder="MIN"
placeholder="LOW"
onClick={(e) => e.stopPropagation()}
onChange={(e) => setMinString(e.target.value)}
min={Number.MIN_SAFE_INTEGER}
@ -332,7 +332,7 @@ export function NewContract(props: {
<input
type="number"
className="input input-bordered w-32"
placeholder="MAX"
placeholder="HIGH"
onClick={(e) => e.stopPropagation()}
onChange={(e) => setMaxString(e.target.value)}
min={Number.MIN_SAFE_INTEGER}

View File

@ -103,7 +103,7 @@ export function ContractEmbed(props: { contract: Contract; bets: Bet[] }) {
<Spacer h={3} />
<Row className="items-center justify-between gap-4 px-2">
<ContractDetails contract={contract} user={null} disabled />
<ContractDetails contract={contract} disabled />
{(isBinary || isPseudoNumeric) &&
tradingAllowed(contract) &&

View File

@ -0,0 +1,37 @@
import React from 'react'
import { Contract, getContractFromSlug } from 'web/lib/firebase/contracts'
import { ContractsGrid } from 'web/components/contract/contracts-grid'
export async function getStaticProps(props: { params: { slugs: string[] } }) {
const { slugs } = props.params
const contracts = (await Promise.all(
slugs.map((slug) =>
getContractFromSlug(slug) != null ? getContractFromSlug(slug) : []
)
)) as Contract[]
return {
props: {
contracts,
},
revalidate: 60, // regenerate after a minute
}
}
export async function getStaticPaths() {
return { paths: [], fallback: 'blocking' }
}
export default function ContractGridPage(props: { contracts: Contract[] }) {
const { contracts } = props
return (
<>
<ContractsGrid
contracts={contracts}
breakpointColumns={{ default: 2, 650: 1 }}
/>
</>
)
}

View File

@ -14,13 +14,14 @@ import {
getGroupBySlug,
groupPath,
joinGroup,
listMembers,
updateGroup,
} from 'web/lib/firebase/groups'
import { Row } from 'web/components/layout/row'
import { firebaseLogin, getUser, User } from 'web/lib/firebase/users'
import { Col } from 'web/components/layout/col'
import { useUser } from 'web/hooks/use-user'
import { listMembers, useGroup, useMembers } from 'web/hooks/use-group'
import { useGroup, useGroupContractIds, useMembers } from 'web/hooks/use-group'
import { scoreCreators, scoreTraders } from 'common/scoring'
import { Leaderboard } from 'web/components/leaderboard'
import { formatMoney } from 'common/util/format'
@ -62,7 +63,11 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) {
const contracts =
(group && (await listContractsByGroupSlug(group.slug))) ?? []
const now = Date.now()
const suggestedFilter =
contracts.filter((c) => (c.closeTime ?? 0) > now).length < 5
? 'all'
: 'open'
const aboutPost =
group && group.aboutPostId != null && (await getPost(group.aboutPostId))
const bets = await Promise.all(
@ -80,9 +85,12 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) {
[]
const creator = await creatorPromise
// Only count unresolved markets
const contractsCount = contracts.filter((c) => !c.isResolved).length
return {
props: {
contractsCount,
group,
members,
creator,
@ -92,6 +100,7 @@ export async function getStaticPropz(props: { params: { slugs: string[] } }) {
topCreators,
messages,
aboutPost,
suggestedFilter,
},
revalidate: 60, // regenerate after a minute
@ -122,6 +131,7 @@ const groupSubpages = [
] as const
export default function GroupPage(props: {
contractsCount: number
group: Group | null
members: User[]
creator: User
@ -131,8 +141,10 @@ export default function GroupPage(props: {
topCreators: User[]
messages: GroupComment[]
aboutPost: Post
suggestedFilter: 'open' | 'all'
}) {
props = usePropz(props, getStaticPropz) ?? {
contractsCount: 0,
group: null,
members: [],
creator: null,
@ -141,14 +153,16 @@ export default function GroupPage(props: {
creatorScores: {},
topCreators: [],
messages: [],
suggestedFilter: 'open',
}
const {
contractsCount,
creator,
members,
traderScores,
topTraders,
creatorScores,
topCreators,
suggestedFilter,
} = props
const router = useRouter()
@ -160,6 +174,7 @@ export default function GroupPage(props: {
const user = useUser()
const isAdmin = useAdmin()
const members = useMembers(group?.id) ?? props.members
useSaveReferral(user, {
defaultReferrerUsername: creator.username,
@ -169,9 +184,8 @@ export default function GroupPage(props: {
if (group === null || !groupSubpages.includes(page) || slugs[2]) {
return <Custom404 />
}
const { memberIds } = group
const isCreator = user && group && user.id === group.creatorId
const isMember = user && memberIds.includes(user.id)
const isMember = user && members.map((m) => m.id).includes(user.id)
const leaderboard = (
<Col>
@ -210,13 +224,14 @@ export default function GroupPage(props: {
<ContractSearch
user={user}
defaultSort={'newest'}
defaultFilter={'open'}
defaultFilter={suggestedFilter}
additionalFilter={{ groupSlug: group.slug }}
/>
)
const tabs = [
{
badge: `${contractsCount}`,
title: 'Markets',
content: questionsTab,
href: groupPath(group.slug, 'markets'),
@ -316,6 +331,7 @@ function GroupOverview(props: {
const shareUrl = `https://${ENV_CONFIG.domain}${groupPath(
group.slug
)}${postFix}`
const isMember = user ? members.map((m) => m.id).includes(user.id) : false
return (
<>
@ -332,10 +348,13 @@ function GroupOverview(props: {
{isCreator ? (
<EditGroupButton className={'ml-1'} group={group} />
) : (
user &&
group.memberIds.includes(user?.id) && (
user && (
<Row>
<JoinOrLeaveGroupButton group={group} />
<JoinOrLeaveGroupButton
group={group}
user={user}
isMember={isMember}
/>
</Row>
)
)}
@ -410,7 +429,7 @@ function GroupMemberSearch(props: { members: User[]; group: Group }) {
let { members } = props
// Use static members on load, but also listen to member changes:
const listenToMembers = useMembers(group)
const listenToMembers = useMembers(group.id)
if (listenToMembers) {
members = listenToMembers
}
@ -532,6 +551,7 @@ function AddContractButton(props: { group: Group; user: User }) {
const [open, setOpen] = useState(false)
const [contracts, setContracts] = useState<Contract[]>([])
const [loading, setLoading] = useState(false)
const groupContractIds = useGroupContractIds(group.id)
async function addContractToCurrentGroup(contract: Contract) {
if (contracts.map((c) => c.id).includes(contract.id)) {
@ -619,7 +639,9 @@ function AddContractButton(props: { group: Group; user: User }) {
hideOrderSelector={true}
onContractClick={addContractToCurrentGroup}
cardHideOptions={{ hideGroupLink: true, hideQuickBet: true }}
additionalFilter={{ excludeContractIds: group.contractIds }}
additionalFilter={{
excludeContractIds: groupContractIds,
}}
highlightOptions={{
contractIds: contracts.map((c) => c.id),
highlightClassName: '!bg-indigo-100 border-indigo-100 border-2',
@ -638,7 +660,7 @@ function JoinGroupButton(props: {
}) {
const { group, user } = props
function addUserToGroup() {
if (user && !group.memberIds.includes(user.id)) {
if (user) {
toast.promise(joinGroup(group, user.id), {
loading: 'Joining group...',
success: 'Joined group!',

View File

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

View File

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

View File

@ -43,12 +43,13 @@ import { SiteLink } from 'web/components/site-link'
import { NotificationSettings } from 'web/components/NotificationSettings'
import { SEO } from 'web/components/SEO'
import { usePrivateUser, useUser } from 'web/hooks/use-user'
import {
MultiUserTipLink,
MultiUserLinkInfo,
UserLink,
} from 'web/components/user-link'
import { UserLink } from 'web/components/user-link'
import { LoadingIndicator } from 'web/components/loading-indicator'
import {
MultiUserLinkInfo,
MultiUserTransactionLink,
} from 'web/components/multi-user-transaction-link'
import { Col } from 'web/components/layout/col'
export const NOTIFICATIONS_PER_PAGE = 30
const HIGHLIGHT_CLASS = 'bg-indigo-50'
@ -212,7 +213,7 @@ function IncomeNotificationGroupItem(props: {
function combineNotificationsByAddingNumericSourceTexts(
notifications: Notification[]
) {
const newNotifications = []
const newNotifications: Notification[] = []
const groupedNotificationsBySourceType = groupBy(
notifications,
(n) => n.sourceType
@ -228,10 +229,7 @@ function IncomeNotificationGroupItem(props: {
for (const sourceTitle in groupedNotificationsBySourceTitle) {
const notificationsForSourceTitle =
groupedNotificationsBySourceTitle[sourceTitle]
if (notificationsForSourceTitle.length === 1) {
newNotifications.push(notificationsForSourceTitle[0])
continue
}
let sum = 0
notificationsForSourceTitle.forEach(
(notification) =>
@ -251,7 +249,7 @@ function IncomeNotificationGroupItem(props: {
username: notification.sourceUserUsername,
name: notification.sourceUserName,
avatarUrl: notification.sourceUserAvatarUrl,
amountTipped: thisSum,
amount: thisSum,
} as MultiUserLinkInfo
}),
(n) => n.username
@ -260,10 +258,8 @@ function IncomeNotificationGroupItem(props: {
const newNotification = {
...notificationsForSourceTitle[0],
sourceText: sum.toString(),
sourceUserUsername:
uniqueUsers.length > 1
? JSON.stringify(uniqueUsers)
: notificationsForSourceTitle[0].sourceType,
sourceUserUsername: notificationsForSourceTitle[0].sourceUserUsername,
data: JSON.stringify(uniqueUsers),
}
newNotifications.push(newNotification)
}
@ -372,12 +368,15 @@ function IncomeNotificationItem(props: {
justSummary?: boolean
}) {
const { notification, justSummary } = props
const { sourceType, sourceUserName, sourceUserUsername, sourceText } =
notification
const { sourceType, sourceUserUsername, sourceText, data } = notification
const [highlighted] = useState(!notification.isSeen)
const { width } = useWindowSize()
const isMobile = (width && width < 768) || false
const user = useUser()
const isTip = sourceType === 'tip' || sourceType === 'tip_and_like'
const isUniqueBettorBonus = sourceType === 'bonus'
const userLinks: MultiUserLinkInfo[] =
isTip || isUniqueBettorBonus ? JSON.parse(data ?? '{}') : []
useEffect(() => {
setNotificationsAsSeen([notification])
@ -505,29 +504,26 @@ function IncomeNotificationItem(props: {
href={getIncomeSourceUrl() ?? ''}
className={'absolute left-0 right-0 top-0 bottom-0 z-0'}
/>
<Row className={'items-center text-gray-500 sm:justify-start'}>
<div className={'line-clamp-2 flex max-w-xl shrink '}>
<div className={'inline'}>
<span className={'mr-1'}>{incomeNotificationLabel()}</span>
</div>
<span>
{(sourceType === 'tip' || sourceType === 'tip_and_like') &&
(sourceUserUsername?.includes(',') ? (
<MultiUserTipLink
userInfos={JSON.parse(sourceUserUsername)}
<Col className={'justify-start text-gray-500'}>
{(isTip || isUniqueBettorBonus) && (
<MultiUserTransactionLink
userInfos={userLinks}
modalLabel={isTip ? 'Who tipped you' : 'Unique bettors'}
/>
) : (
<UserLink
name={sourceUserName || ''}
username={sourceUserUsername || ''}
className={'mr-1 flex-shrink-0'}
short={true}
/>
))}
{reasonAndLink(false)}
)}
<Row className={'line-clamp-2 flex max-w-xl'}>
<span>{incomeNotificationLabel()}</span>
<span className={'mx-1'}>
{isTip &&
(userLinks.length > 1
? 'Multiple users'
: userLinks.length > 0
? userLinks[0].name
: '')}
</span>
</div>
<span>{reasonAndLink(false)}</span>
</Row>
</Col>
<div className={'border-b border-gray-300 pt-4'} />
</div>
</div>