Categories to groups (#641)

* start on script

* Revert "Remove category filters"

This reverts commit d6e808e1a3.

* Convert categories to official default groups

* Add new users to default groups

* Rework group cards

* Cleanup

* Add unique bettors to contract and sort by them

* Most bettors to most popular

* Unused vars

* Track unique bettor ids on contracts

* Add followed users' bets to personal markets

* Add new users to welcome, bugs, and updates groups

* Add users to fewer default cats
This commit is contained in:
Ian Philips 2022-07-13 15:11:22 -06:00 committed by GitHub
parent e868f0a15a
commit 55c91dfcdd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 617 additions and 382 deletions

View File

@ -2,7 +2,7 @@ import { cloneDeep, range, sum, sumBy, sortBy, mapValues } from 'lodash'
import { Bet, NumericBet } from './bet'
import { DPMContract, DPMBinaryContract, NumericContract } from './contract'
import { DPM_FEES } from './fees'
import { normpdf } from '../common/util/math'
import { normpdf } from './util/math'
import { addObjects } from './util/object'
export function getDpmProbability(totalShares: { [outcome: string]: number }) {

View File

@ -1,5 +1,6 @@
import { difference } from 'lodash'
export const CATEGORIES_GROUP_SLUG_POSTFIX = '-default'
export const CATEGORIES = {
politics: 'Politics',
technology: 'Technology',
@ -24,9 +25,15 @@ export const TO_CATEGORY = Object.fromEntries(
export const CATEGORY_LIST = Object.keys(CATEGORIES)
export const EXCLUDED_CATEGORIES: category[] = ['fun', 'manifold', 'personal']
export const EXCLUDED_CATEGORIES: category[] = [
'fun',
'manifold',
'personal',
'covid',
'culture',
'gaming',
'crypto',
'world',
]
export const DEFAULT_CATEGORIES = difference(
CATEGORY_LIST,
EXCLUDED_CATEGORIES
)
export const DEFAULT_CATEGORIES = difference(CATEGORY_LIST, EXCLUDED_CATEGORIES)

View File

@ -44,10 +44,14 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
volume7Days: number
collectedFees: Fees
groupSlugs?: string[]
uniqueBettorIds?: string[]
uniqueBettorCount?: number
} & T
export type BinaryContract = Contract & Binary
export type PseudoNumericContract = Contract & PseudoNumeric
export type BinaryContract = Contract & Binary
export type PseudoNumericContract = Contract & PseudoNumeric
export type NumericContract = Contract & Numeric
export type FreeResponseContract = Contract & FreeResponse
export type DPMContract = Contract & DPM
@ -109,7 +113,12 @@ export type Numeric = {
export type outcomeType = AnyOutcomeType['outcomeType']
export type resolution = 'YES' | 'NO' | 'MKT' | 'CANCEL'
export const RESOLUTIONS = ['YES', 'NO', 'MKT', 'CANCEL'] as const
export const OUTCOME_TYPES = ['BINARY', 'FREE_RESPONSE', 'PSEUDO_NUMERIC', 'NUMERIC'] as const
export const OUTCOME_TYPES = [
'BINARY',
'FREE_RESPONSE',
'PSEUDO_NUMERIC',
'NUMERIC',
] as const
export const MAX_QUESTION_LENGTH = 480
export const MAX_DESCRIPTION_LENGTH = 10000

View File

@ -9,7 +9,10 @@ export type Group = {
memberIds: string[] // User ids
anyoneCanJoin: boolean
contractIds: string[]
chatDisabled?: boolean
}
export const MAX_GROUP_NAME_LENGTH = 75
export const MAX_ABOUT_LENGTH = 140
export const MAX_ID_LENGTH = 60
export const NEW_USER_GROUP_SLUGS = ['updates', 'bugs', 'welcome']

View File

@ -71,7 +71,7 @@ service cloud.firestore {
match /contracts/{contractId} {
allow read;
allow update: if request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['tags', 'lowercaseTags']);
.hasOnly(['tags', 'lowercaseTags', 'groupSlugs']);
allow update: if request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['description', 'closeTime'])
&& resource.data.creatorId == request.auth.uid;

View File

@ -6,7 +6,7 @@ import {
SUS_STARTING_BALANCE,
User,
} from '../../common/user'
import { getUser, getUserByUsername } from './utils'
import { getUser, getUserByUsername, getValues, isProd } from './utils'
import { randomString } from '../../common/util/random'
import {
cleanDisplayName,
@ -14,10 +14,19 @@ import {
} from '../../common/util/clean-username'
import { sendWelcomeEmail } from './emails'
import { isWhitelisted } from '../../common/envs/constants'
import { DEFAULT_CATEGORIES } from '../../common/categories'
import {
CATEGORIES_GROUP_SLUG_POSTFIX,
DEFAULT_CATEGORIES,
} from '../../common/categories'
import { track } from './analytics'
import { APIError, newEndpoint, validate } from './api'
import { Group, NEW_USER_GROUP_SLUGS } from '../../common/group'
import { uniq } from 'lodash'
import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID,
} from '../../common/antes'
const bodySchema = z.object({
deviceToken: z.string().optional(),
@ -85,7 +94,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
await firestore.collection('private-users').doc(auth.uid).create(privateUser)
await sendWelcomeEmail(user, privateUser)
await addUserToDefaultGroups(user)
await track(auth.uid, 'create user', { username }, { ip: req.ip })
return user
@ -110,3 +119,50 @@ const numberUsersWithIp = async (ipAddress: string) => {
return snap.docs.length
}
const addUserToDefaultGroups = async (user: User) => {
for (const category of Object.values(DEFAULT_CATEGORIES)) {
const slug = category.toLowerCase() + CATEGORIES_GROUP_SLUG_POSTFIX
const groups = await getValues<Group>(
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)),
})
const manifoldAccount = isProd()
? HOUSE_LIQUIDITY_PROVIDER_ID
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
if (slug === 'welcome') {
const welcomeCommentDoc = firestore
.collection(`groups/${group.id}/comments`)
.doc()
await welcomeCommentDoc.create({
id: welcomeCommentDoc.id,
groupId: group.id,
userId: manifoldAccount,
text: `Welcome, ${user.name} (@${user.username})!`,
createdTime: Date.now(),
userName: 'Manifold Markets',
userUsername: 'ManifoldMarkets',
userAvatarUrl: 'https://manifold.markets/logo-bg-white.png',
})
}
}
}

View File

@ -1,142 +0,0 @@
import { APIError, newEndpoint } from './api'
import { isProd, log } from './utils'
import * as admin from 'firebase-admin'
import { PrivateUser } from '../../common/lib/user'
import { uniq } from 'lodash'
import { Bet } from '../../common/lib/bet'
const firestore = admin.firestore()
import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID,
} from '../../common/antes'
import { runTxn, TxnData } from './transact'
import { createNotification } from './create-notification'
import { User } from '../../common/lib/user'
import { Contract } from '../../common/lib/contract'
import { UNIQUE_BETTOR_BONUS_AMOUNT } from '../../common/numeric-constants'
const BONUS_START_DATE = new Date('2022-07-01T00:00:00.000Z').getTime()
const QUERY_LIMIT_SECONDS = 60
export const getdailybonuses = newEndpoint({}, async (req, auth) => {
const { user, lastTimeCheckedBonuses } = await firestore.runTransaction(
async (trans) => {
const userSnap = await trans.get(
firestore.doc(`private-users/${auth.uid}`)
)
if (!userSnap.exists) throw new APIError(400, 'User not found.')
const user = userSnap.data() as PrivateUser
const lastTimeCheckedBonuses = user.lastTimeCheckedBonuses ?? 0
if (Date.now() - lastTimeCheckedBonuses < QUERY_LIMIT_SECONDS * 1000)
throw new APIError(
400,
`Limited to one query per user per ${QUERY_LIMIT_SECONDS} seconds.`
)
await trans.update(userSnap.ref, {
lastTimeCheckedBonuses: Date.now(),
})
return {
user,
lastTimeCheckedBonuses,
}
}
)
const fromUserId = isProd()
? HOUSE_LIQUIDITY_PROVIDER_ID
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
const fromSnap = await firestore.doc(`users/${fromUserId}`).get()
if (!fromSnap.exists) throw new APIError(400, 'From user not found.')
const fromUser = fromSnap.data() as User
// Get all users contracts made since implementation time
const userContractsSnap = await firestore
.collection(`contracts`)
.where('creatorId', '==', user.id)
.where('createdTime', '>=', BONUS_START_DATE)
.get()
const userContracts = userContractsSnap.docs.map(
(doc) => doc.data() as Contract
)
const nullReturn = { status: 'no bets', txn: null }
for (const contract of userContracts) {
const result = await firestore.runTransaction(async (trans) => {
const contractId = contract.id
// Get all bets made on user's contracts
const bets = (
await firestore
.collection(`contracts/${contractId}/bets`)
.where('userId', '!=', user.id)
.get()
).docs.map((bet) => bet.ref)
if (bets.length === 0) {
return nullReturn
}
const contractBetsSnap = await trans.getAll(...bets)
const contractBets = contractBetsSnap.map((doc) => doc.data() as Bet)
const uniqueBettorIdsBeforeLastResetTime = uniq(
contractBets
.filter((bet) => bet.createdTime < lastTimeCheckedBonuses)
.map((bet) => bet.userId)
)
// Filter users for ONLY those that have made bets since the last daily bonus received time
const uniqueBettorIdsWithBetsAfterLastResetTime = uniq(
contractBets
.filter((bet) => bet.createdTime > lastTimeCheckedBonuses)
.map((bet) => bet.userId)
)
// Filter for users only present in the above list
const newUniqueBettorIds =
uniqueBettorIdsWithBetsAfterLastResetTime.filter(
(userId) => !uniqueBettorIdsBeforeLastResetTime.includes(userId)
)
newUniqueBettorIds.length > 0 &&
log(
`Got ${newUniqueBettorIds.length} new unique bettors since last bonus`
)
if (newUniqueBettorIds.length === 0) {
return nullReturn
}
// Create combined txn for all unique bettors
const bonusTxnDetails = {
contractId: contractId,
uniqueBettors: newUniqueBettorIds.length,
}
const bonusTxn: TxnData = {
fromId: fromUser.id,
fromType: 'BANK',
toId: user.id,
toType: 'USER',
amount: UNIQUE_BETTOR_BONUS_AMOUNT * newUniqueBettorIds.length,
token: 'M$',
category: 'UNIQUE_BETTOR_BONUS',
description: JSON.stringify(bonusTxnDetails),
}
return await runTxn(trans, bonusTxn)
})
if (result.status != 'success' || !result.txn) {
result.status != nullReturn.status &&
log(`No bonus for user: ${user.id} - reason:`, result.status)
} else {
log(`Bonus txn for user: ${user.id} completed:`, result.txn?.id)
await createNotification(
result.txn.id,
'bonus',
'created',
fromUser,
result.txn.id,
result.txn.amount + '',
contract,
undefined,
// No need to set the user id, we'll use the contract creator id
undefined,
contract.slug,
contract.question
)
}
}
return { userId: user.id, message: 'success' }
})

View File

@ -38,6 +38,5 @@ export * from './add-liquidity'
export * from './withdraw-liquidity'
export * from './create-group'
export * from './resolve-market'
export * from './get-daily-bonuses'
export * from './unsubscribe'
export * from './stripe'

View File

@ -1,13 +1,26 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import { keyBy } from 'lodash'
import { keyBy, uniq } from 'lodash'
import { Bet, LimitBet } from '../../common/bet'
import { getContract, getUser, getValues } from './utils'
import { createBetFillNotification } from './create-notification'
import { getContract, getUser, getValues, isProd, log } from './utils'
import {
createBetFillNotification,
createNotification,
} from './create-notification'
import { filterDefined } from '../../common/util/array'
import { Contract } from '../../common/contract'
import { runTxn, TxnData } from './transact'
import { UNIQUE_BETTOR_BONUS_AMOUNT } from '../../common/numeric-constants'
import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID,
} from '../../common/antes'
import { APIError } from '../../common/api'
import { User } from '../../common/user'
const firestore = admin.firestore()
const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime()
export const onCreateBet = functions.firestore
.document('contracts/{contractId}/bets/{betId}')
@ -26,8 +39,109 @@ export const onCreateBet = functions.firestore
.update({ lastBetTime, lastUpdatedTime: Date.now() })
await notifyFills(bet, contractId, eventId)
await updateUniqueBettorsAndGiveCreatorBonus(
contractId,
eventId,
bet.userId
)
})
const updateUniqueBettorsAndGiveCreatorBonus = async (
contractId: string,
eventId: string,
bettorId: string
) => {
const userContractSnap = await firestore
.collection(`contracts`)
.doc(contractId)
.get()
const contract = userContractSnap.data() as Contract
if (!contract) {
log(`Could not find contract ${contractId}`)
return
}
let previousUniqueBettorIds = contract.uniqueBettorIds
if (!previousUniqueBettorIds) {
const contractBets = (
await firestore
.collection(`contracts/${contractId}/bets`)
.where('userId', '!=', contract.creatorId)
.get()
).docs.map((doc) => doc.data() as Bet)
if (contractBets.length === 0) {
log(`No bets for contract ${contractId}`)
return
}
previousUniqueBettorIds = uniq(
contractBets
.filter((bet) => bet.createdTime < BONUS_START_DATE)
.map((bet) => bet.userId)
)
}
const isNewUniqueBettor = !previousUniqueBettorIds.includes(bettorId)
const newUniqueBettorIds = uniq([...previousUniqueBettorIds, bettorId])
// Update contract unique bettors
if (!contract.uniqueBettorIds || isNewUniqueBettor) {
log(`Got ${previousUniqueBettorIds} unique bettors`)
isNewUniqueBettor && log(`And a new unique bettor ${bettorId}`)
await firestore.collection(`contracts`).doc(contractId).update({
uniqueBettorIds: newUniqueBettorIds,
uniqueBettorCount: newUniqueBettorIds.length,
})
}
if (!isNewUniqueBettor) return
// Create combined txn for all new unique bettors
const bonusTxnDetails = {
contractId: contractId,
uniqueBettorIds: newUniqueBettorIds,
}
const fromUserId = isProd()
? HOUSE_LIQUIDITY_PROVIDER_ID
: DEV_HOUSE_LIQUIDITY_PROVIDER_ID
const fromSnap = await firestore.doc(`users/${fromUserId}`).get()
if (!fromSnap.exists) throw new APIError(400, 'From user not found.')
const fromUser = fromSnap.data() as User
const result = await firestore.runTransaction(async (trans) => {
const bonusTxn: TxnData = {
fromId: fromUser.id,
fromType: 'BANK',
toId: contract.creatorId,
toType: 'USER',
amount: UNIQUE_BETTOR_BONUS_AMOUNT,
token: 'M$',
category: 'UNIQUE_BETTOR_BONUS',
description: JSON.stringify(bonusTxnDetails),
}
return await runTxn(trans, bonusTxn)
})
if (result.status != 'success' || !result.txn) {
log(`No bonus for user: ${contract.creatorId} - reason:`, result.status)
} else {
log(`Bonus txn for user: ${contract.creatorId} completed:`, result.txn?.id)
await createNotification(
result.txn.id,
'bonus',
'created',
fromUser,
eventId + '-bonus',
result.txn.amount + '',
contract,
undefined,
// No need to set the user id, we'll use the contract creator id
undefined,
contract.slug,
contract.question
)
}
}
const notifyFills = async (bet: Bet, contractId: string, eventId: string) => {
if (!bet.fills) return

View File

@ -0,0 +1,110 @@
import * as admin from 'firebase-admin'
import { initAdmin } from './script-init'
initAdmin()
import { getValues, isProd } from '../utils'
import {
CATEGORIES_GROUP_SLUG_POSTFIX,
DEFAULT_CATEGORIES,
} from 'common/categories'
import { Group } 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'
const adminFirestore = admin.firestore()
async function convertCategoriesToGroups() {
const groups = await getValues<Group>(adminFirestore.collection('groups'))
const contracts = await getValues<Contract>(
adminFirestore.collection('contracts')
)
for (const group of groups) {
const groupContracts = contracts.filter((contract) =>
group.contractIds.includes(contract.id)
)
for (const contract of groupContracts) {
await adminFirestore
.collection('contracts')
.doc(contract.id)
.update({
groupSlugs: uniq([...(contract.groupSlugs ?? []), group.slug]),
})
}
}
for (const category of Object.values(DEFAULT_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: 'Official 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) {
await adminFirestore
.collection('contracts')
.doc(market.id)
.update({
groupSlugs: uniq([...(market?.groupSlugs ?? []), newGroup.slug]),
})
}
}
}
if (require.main === module) {
convertCategoriesToGroups()
.then(() => process.exit())
.catch(console.log)
}

View File

@ -20,8 +20,12 @@ import { Row } from './layout/row'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Spacer } from './layout/spacer'
import { ENV, IS_PRIVATE_MANIFOLD } from 'common/envs/constants'
import { useUser } from 'web/hooks/use-user'
import { useFollows } from 'web/hooks/use-follows'
import { trackCallback } from 'web/lib/service/analytics'
import ContractSearchFirestore from 'web/pages/contract-search-firestore'
import { useMemberGroups } from 'web/hooks/use-group'
import { NEW_USER_GROUP_SLUGS } from 'common/group'
const searchClient = algoliasearch(
'GJQPAYENIF',
@ -33,6 +37,7 @@ const indexPrefix = ENV === 'DEV' ? 'dev-' : ''
const sortIndexes = [
{ label: 'Newest', value: indexPrefix + 'contracts-newest' },
{ label: 'Oldest', value: indexPrefix + 'contracts-oldest' },
{ label: 'Most popular', value: indexPrefix + 'contracts-most-popular' },
{ label: 'Most traded', value: indexPrefix + 'contracts-most-traded' },
{ label: '24h volume', value: indexPrefix + 'contracts-24-hour-vol' },
{ label: 'Last updated', value: indexPrefix + 'contracts-last-updated' },
@ -40,7 +45,7 @@ const sortIndexes = [
{ label: 'Resolve date', value: indexPrefix + 'contracts-resolve-date' },
]
type filter = 'open' | 'closed' | 'resolved' | 'all'
type filter = 'personal' | 'open' | 'closed' | 'resolved' | 'all'
export function ContractSearch(props: {
querySortOptions?: {
@ -69,13 +74,19 @@ export function ContractSearch(props: {
hideQuickBet,
} = props
const user = useUser()
const memberGroupSlugs = useMemberGroups(user?.id)
?.map((g) => g.slug)
.filter((s) => !NEW_USER_GROUP_SLUGS.includes(s))
const follows = useFollows(user?.id)
console.log(memberGroupSlugs, follows)
const { initialSort } = useInitialQueryAndSort(querySortOptions)
const sort = sortIndexes
.map(({ value }) => value)
.includes(`${indexPrefix}contracts-${initialSort ?? ''}`)
? initialSort
: querySortOptions?.defaultSort ?? '24-hour-vol'
: querySortOptions?.defaultSort ?? 'most-popular'
const [filter, setFilter] = useState<filter>(
querySortOptions?.defaultFilter ?? 'open'
@ -86,10 +97,21 @@ export function ContractSearch(props: {
filter === 'open' ? 'isResolved:false' : '',
filter === 'closed' ? 'isResolved:false' : '',
filter === 'resolved' ? 'isResolved:true' : '',
filter === 'personal'
? // Show contracts in groups that the user is a member of
(memberGroupSlugs?.map((slug) => `groupSlugs:${slug}`) ?? [])
// Show contracts created by users the user follows
.concat(follows?.map((followId) => `creatorId:${followId}`) ?? [])
// Show contracts bet on by users the user follows
.concat(
follows?.map((followId) => `uniqueBettorIds:${followId}`) ?? []
// Show contracts bet on by the user
)
.concat(user ? `uniqueBettorIds:${user.id}` : [])
: '',
additionalFilter?.creatorId
? `creatorId:${additionalFilter.creatorId}`
: '',
additionalFilter?.tag ? `lowercaseTags:${additionalFilter.tag}` : '',
].filter((f) => f)
// Hack to make Algolia work.
filters = ['', ...filters]
@ -100,7 +122,12 @@ export function ContractSearch(props: {
].filter((f) => f)
return { filters, numericFilters }
}, [filter, Object.values(additionalFilter ?? {}).join(',')])
}, [
filter,
Object.values(additionalFilter ?? {}).join(','),
(memberGroupSlugs ?? []).join(','),
(follows ?? []).join(','),
])
const indexName = `${indexPrefix}contracts-${sort}`
@ -125,6 +152,7 @@ export function ContractSearch(props: {
resetIcon: 'mt-2 hidden sm:flex',
}}
/>
{/*// TODO track WHICH filter users are using*/}
<select
className="!select !select-bordered"
value={filter}
@ -134,6 +162,7 @@ export function ContractSearch(props: {
<option value="open">Open</option>
<option value="closed">Closed</option>
<option value="resolved">Resolved</option>
<option value="personal">For you</option>
<option value="all">All</option>
</select>
{!hideOrderSelector && (
@ -155,13 +184,21 @@ export function ContractSearch(props: {
<Spacer h={3} />
<ContractSearchInner
querySortOptions={querySortOptions}
onContractClick={onContractClick}
overrideGridClassName={overrideGridClassName}
hideQuickBet={hideQuickBet}
excludeContractIds={additionalFilter?.excludeContractIds}
/>
{/*<Spacer h={4} />*/}
{filter === 'personal' &&
(follows ?? []).length === 0 &&
(memberGroupSlugs ?? []).length === 0 ? (
<>You're not following anyone, nor in any of your own groups yet.</>
) : (
<ContractSearchInner
querySortOptions={querySortOptions}
onContractClick={onContractClick}
overrideGridClassName={overrideGridClassName}
hideQuickBet={hideQuickBet}
excludeContractIds={additionalFilter?.excludeContractIds}
/>
)}
</InstantSearch>
)
}

View File

@ -13,9 +13,12 @@ export function Leaderboard(props: {
renderCell: (user: User) => any
}[]
className?: string
maxToShow?: number
}) {
// TODO: Ideally, highlight your own entry on the leaderboard
const { title, users, columns, className } = props
const { title, columns, className } = props
const maxToShow = props.maxToShow ?? props.users.length
const users = props.users.slice(0, maxToShow)
return (
<div className={clsx('w-full px-1', className)}>
<Title text={title} className="!mt-0" />

View File

@ -193,7 +193,9 @@ export default function Sidebar(props: { className?: string }) {
const mobileNavigationOptions = !user
? signedOutMobileNavigation
: signedInMobileNavigation
const memberItems = (useMemberGroups(user?.id) ?? []).map((group: Group) => ({
const memberItems = (
useMemberGroups(user?.id, { withChatEnabled: true }) ?? []
).map((group: Group) => ({
name: group.name,
href: groupPath(group.slug),
}))

View File

@ -6,22 +6,12 @@ import { usePrivateUser, useUser } from 'web/hooks/use-user'
import { useRouter } from 'next/router'
import { useUnseenPreferredNotificationGroups } from 'web/hooks/use-notifications'
import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications'
import { requestBonuses } from 'web/lib/firebase/api'
import { PrivateUser } from 'common/user'
export default function NotificationsIcon(props: { className?: string }) {
const user = useUser()
const privateUser = usePrivateUser(user?.id)
useEffect(() => {
if (
privateUser &&
privateUser.lastTimeCheckedBonuses &&
Date.now() - privateUser.lastTimeCheckedBonuses > 1000 * 70
)
requestBonuses({}).catch(() => console.log('no bonuses for you (yet)'))
}, [privateUser])
return (
<Row className={clsx('justify-center')}>
<div className={'relative'}>

View File

@ -2,12 +2,15 @@ import { useEffect, useState } from 'react'
import { Group } from 'common/group'
import { User } from 'common/user'
import {
getGroupBySlug,
getGroupsWithContractId,
listenForGroup,
listenForGroups,
listenForMemberGroups,
} from 'web/lib/firebase/groups'
import { getUser } from 'web/lib/firebase/users'
import { CATEGORIES, CATEGORIES_GROUP_SLUG_POSTFIX } from 'common/categories'
import { filterDefined } from 'common/util/array'
export const useGroup = (groupId: string | undefined) => {
const [group, setGroup] = useState<Group | null | undefined>()
@ -29,11 +32,21 @@ export const useGroups = () => {
return groups
}
export const useMemberGroups = (userId: string | null | undefined) => {
export const useMemberGroups = (
userId: string | null | undefined,
options?: { withChatEnabled: boolean }
) => {
const [memberGroups, setMemberGroups] = useState<Group[] | undefined>()
useEffect(() => {
if (userId) return listenForMemberGroups(userId, setMemberGroups)
}, [userId])
if (userId)
return listenForMemberGroups(userId, (groups) => {
if (options?.withChatEnabled)
return setMemberGroups(
filterDefined(groups.filter((group) => group.chatDisabled !== true))
)
return setMemberGroups(groups)
})
}, [options?.withChatEnabled, userId])
return memberGroups
}

View File

@ -10,6 +10,7 @@ export type Sort =
| 'newest'
| 'oldest'
| 'most-traded'
| 'most-popular'
| '24-hour-vol'
| 'close-date'
| 'resolve-date'
@ -35,7 +36,7 @@ export function useInitialQueryAndSort(options?: {
shouldLoadFromStorage?: boolean
}) {
const { defaultSort, shouldLoadFromStorage } = defaults(options, {
defaultSort: '24-hour-vol',
defaultSort: 'most-popular',
shouldLoadFromStorage: true,
})
const router = useRouter()

View File

@ -80,7 +80,3 @@ export function claimManalink(params: any) {
export function createGroup(params: any) {
return call(getFunctionUrl('creategroup'), 'POST', params)
}
export function requestBonuses(params: any) {
return call(getFunctionUrl('getdailybonuses'), 'POST', params)
}

View File

@ -8,7 +8,7 @@ import {
} from 'firebase/firestore'
import { sortBy, uniq } from 'lodash'
import { Group } from 'common/group'
import { getContractFromId } from './contracts'
import { getContractFromId, updateContract } from './contracts'
import {
coll,
getValue,
@ -17,6 +17,7 @@ import {
listenForValues,
} from './utils'
import { filterDefined } from 'common/util/array'
import { Contract } from 'common/contract'
export const groups = coll<Group>('groups')
@ -129,7 +130,22 @@ export async function leaveGroup(group: Group, userId: string): Promise<Group> {
return newGroup
}
export async function addContractToGroup(group: Group, contractId: string) {
export async function addContractToGroup(group: Group, contract: Contract) {
await updateContract(contract.id, {
groupSlugs: [...(contract.groupSlugs ?? []), group.slug],
})
return await updateGroup(group, {
contractIds: uniq([...group.contractIds, contract.id]),
})
.then(() => group)
.catch((err) => {
console.error('error adding contract to group', err)
return err
})
}
export async function setContractGroupSlugs(group: Group, contractId: string) {
await updateContract(contractId, { groupSlugs: [group.slug] })
return await updateGroup(group, {
contractIds: uniq([...group.contractIds, contractId]),
})

View File

@ -61,6 +61,10 @@ export default function ContractSearchFirestore(props: {
)
} else if (sort === 'most-traded') {
matches.sort((a, b) => b.volume - a.volume)
} else if (sort === 'most-popular') {
matches.sort(
(a, b) => (b.uniqueBettorCount ?? 0) - (a.uniqueBettorCount ?? 0)
)
} else if (sort === '24-hour-vol') {
// Use lodash for stable sort, so previous sort breaks all ties.
matches = sortBy(matches, ({ volume7Days }) => -1 * volume7Days)
@ -107,6 +111,7 @@ export default function ContractSearchFirestore(props: {
>
<option value="newest">Newest</option>
<option value="oldest">Oldest</option>
<option value="most-popular">Most popular</option>
<option value="most-traded">Most traded</option>
<option value="24-hour-vol">24h volume</option>
<option value="close-date">Closing soon</option>

View File

@ -19,7 +19,7 @@ import {
import { formatMoney } from 'common/util/format'
import { removeUndefinedProps } from 'common/util/object'
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
import { addContractToGroup, getGroup } from 'web/lib/firebase/groups'
import { setContractGroupSlugs, 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'
@ -217,7 +217,7 @@ export function NewContract(props: {
isFree: false,
})
if (result && selectedGroup) {
await addContractToGroup(selectedGroup, result.id)
await setContractGroupSlugs(selectedGroup, result.id)
}
await router.push(contractPath(result as Contract))

View File

@ -9,8 +9,8 @@ import {
getGroupBySlug,
getGroupContracts,
updateGroup,
addContractToGroup,
addUserToGroup,
addContractToGroup,
} from 'web/lib/firebase/groups'
import { Row } from 'web/components/layout/row'
import { UserLink } from 'web/components/user-page'
@ -54,6 +54,7 @@ import clsx from 'clsx'
import { FollowList } from 'web/components/follow-list'
import { SearchIcon } from '@heroicons/react/outline'
import { useTipTxns } from 'web/hooks/use-tip-txns'
import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button'
export const getStaticProps = fromPropz(getStaticPropz)
export async function getStaticPropz(props: { params: { slugs: string[] } }) {
@ -145,7 +146,7 @@ export default function GroupPage(props: {
const router = useRouter()
const { slugs } = router.query as { slugs: string[] }
const page = (slugs?.[1] ?? 'chat') as typeof groupSubpages[number]
const page = slugs?.[1] as typeof groupSubpages[number]
const group = useGroup(props.group?.id) ?? props.group
const [contracts, setContracts] = useState<Contract[] | undefined>(undefined)
@ -213,6 +214,75 @@ export default function GroupPage(props: {
/>
</Col>
)
const tabs = [
...(group.chatDisabled
? []
: [
{
title: 'Chat',
content: messages ? (
<GroupChat
messages={messages}
user={user}
group={group}
tips={tips}
/>
) : (
<LoadingIndicator />
),
href: groupPath(group.slug, 'chat'),
},
]),
{
title: 'Questions',
content: (
<div className={'mt-2 px-1'}>
{contracts ? (
contracts.length > 0 ? (
<>
<input
type="text"
onChange={(e) => debouncedQuery(e.target.value)}
placeholder="Search the group's questions"
className="input input-bordered mb-4 w-full"
/>
<ContractsGrid
contracts={query != '' ? filteredContracts : contracts}
hasMore={false}
loadMore={() => {}}
/>
</>
) : (
<div className="p-2 text-gray-500">
No questions yet. Why not{' '}
<SiteLink
href={`/create/?groupId=${group.id}`}
className={'font-bold text-gray-700'}
>
add one?
</SiteLink>
</div>
)
) : (
<LoadingIndicator />
)}
</div>
),
href: groupPath(group.slug, 'questions'),
},
{
title: 'Rankings',
content: leaderboard,
href: groupPath(group.slug, 'rankings'),
},
{
title: 'About',
content: aboutTab,
href: groupPath(group.slug, 'about'),
},
]
const tabIndex = tabs.map((t) => t.title).indexOf(page ?? 'chat')
return (
<Page rightSidebar={rightSidebar} className="!pb-0">
<SEO
@ -250,79 +320,9 @@ export default function GroupPage(props: {
<Tabs
currentPageForAnalytics={groupPath(group.slug)}
className={'mx-3 mb-0'}
defaultIndex={
page === 'rankings'
? 2
: page === 'about'
? 3
: page === 'questions'
? 1
: 0
}
tabs={[
{
title: 'Chat',
content: messages ? (
<GroupChat
messages={messages}
user={user}
group={group}
tips={tips}
/>
) : (
<LoadingIndicator />
),
href: groupPath(group.slug, 'chat'),
},
{
title: 'Questions',
content: (
<div className={'mt-2 px-1'}>
{contracts ? (
contracts.length > 0 ? (
<>
<input
type="text"
onChange={(e) => debouncedQuery(e.target.value)}
placeholder="Search the group's questions"
className="input input-bordered mb-4 w-full"
/>
<ContractsGrid
contracts={query != '' ? filteredContracts : contracts}
hasMore={false}
loadMore={() => {}}
/>
</>
) : (
<div className="p-2 text-gray-500">
No questions yet. Why not{' '}
<SiteLink
href={`/create/?groupId=${group.id}`}
className={'font-bold text-gray-700'}
>
add one?
</SiteLink>
</div>
)
) : (
<LoadingIndicator />
)}
</div>
),
href: groupPath(group.slug, 'questions'),
},
{
title: 'Rankings',
content: leaderboard,
href: groupPath(group.slug, 'rankings'),
},
{
title: 'About',
content: aboutTab,
href: groupPath(group.slug, 'about'),
},
]}
className={'mb-0 sm:mb-2'}
defaultIndex={tabIndex > 0 ? tabIndex : 0}
tabs={tabs}
/>
</Page>
)
@ -391,7 +391,16 @@ function GroupOverview(props: {
username={creator.username}
/>
</div>
{isCreator && <EditGroupButton className={'ml-1'} group={group} />}
{isCreator ? (
<EditGroupButton className={'ml-1'} group={group} />
) : (
user &&
group.memberIds.includes(user?.id) && (
<Row>
<JoinOrLeaveGroupButton group={group} />
</Row>
)
)}
</Row>
<div className={'block sm:hidden'}>
<Linkify text={group.about} />
@ -461,12 +470,19 @@ function GroupMemberSearch(props: { group: Group }) {
(m) =>
checkAgainstQuery(query, m.name) || checkAgainstQuery(query, m.username)
)
const matchLimit = 25
return (
<div>
<SearchBar setQuery={setQuery} />
<Col className={'gap-2'}>
{matches.length > 0 && (
<FollowList userIds={matches.map((m) => m.id)} />
<FollowList userIds={matches.slice(0, matchLimit).map((m) => m.id)} />
)}
{matches.length > 25 && (
<div className={'text-center'}>
And {matches.length - matchLimit} more...
</div>
)}
</Col>
</div>
@ -475,25 +491,21 @@ function GroupMemberSearch(props: { group: Group }) {
export function GroupMembersList(props: { group: Group }) {
const { group } = props
const members = useMembers(group)
const maxMambersToShow = 5
const members = useMembers(group).filter((m) => m.id !== group.creatorId)
const maxMembersToShow = 3
if (group.memberIds.length === 1) return <div />
return (
<div>
<div>
<div className="text-neutral flex flex-wrap gap-1">
<span className={'text-gray-500'}>Other members</span>
{members.slice(0, maxMambersToShow).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>
))}
{members.length > maxMambersToShow && (
<span> & {members.length - maxMambersToShow} more</span>
)}
<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>
</div>
))}
{members.length > maxMembersToShow && (
<span> & {members.length - maxMembersToShow} more</span>
)}
</div>
)
}
@ -503,8 +515,9 @@ function SortedLeaderboard(props: {
scoreFunction: (user: User) => number
title: string
header: string
maxToShow?: number
}) {
const { users, scoreFunction, title, header } = props
const { users, scoreFunction, title, header, maxToShow } = props
const sortedUsers = users.sort((a, b) => scoreFunction(b) - scoreFunction(a))
return (
<Leaderboard
@ -514,6 +527,7 @@ function SortedLeaderboard(props: {
columns={[
{ header, renderCell: (user) => formatMoney(scoreFunction(user)) },
]}
maxToShow={maxToShow}
/>
)
}
@ -528,7 +542,7 @@ function GroupLeaderboards(props: {
}) {
const { traderScores, creatorScores, members, topTraders, topCreators } =
props
const maxToShow = 50
// Consider hiding M$0
// If it's just one member (curator), show all bettors, otherwise just show members
return (
@ -541,12 +555,14 @@ function GroupLeaderboards(props: {
scoreFunction={(user) => traderScores[user.id] ?? 0}
title="🏅 Bettor rankings"
header="Profit"
maxToShow={maxToShow}
/>
<SortedLeaderboard
users={members}
scoreFunction={(user) => creatorScores[user.id] ?? 0}
title="🏅 Creator rankings"
header="Market volume"
maxToShow={maxToShow}
/>
</>
) : (
@ -561,6 +577,7 @@ function GroupLeaderboards(props: {
renderCell: (user) => formatMoney(traderScores[user.id] ?? 0),
},
]}
maxToShow={maxToShow}
/>
<Leaderboard
className="max-w-xl"
@ -573,6 +590,7 @@ function GroupLeaderboards(props: {
formatMoney(creatorScores[user.id] ?? 0),
},
]}
maxToShow={maxToShow}
/>
</>
)}
@ -586,7 +604,7 @@ function AddContractButton(props: { group: Group; user: User }) {
const [open, setOpen] = useState(false)
async function addContractToCurrentGroup(contract: Contract) {
await addContractToGroup(group, contract.id)
await addContractToGroup(group, contract)
setOpen(false)
}

View File

@ -7,7 +7,6 @@ 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 { UserLink } from 'web/components/user-page'
import { useGroups, useMemberGroupIds } from 'web/hooks/use-group'
import { useUser } from 'web/hooks/use-user'
import { groupPath, listAllGroups } from 'web/lib/firebase/groups'
@ -17,6 +16,8 @@ import { GroupMembersList } from 'web/pages/group/[...slugs]'
import { checkAgainstQuery } from 'web/hooks/use-sort-and-query-params'
import { SiteLink } from 'web/components/site-link'
import clsx from 'clsx'
import { Avatar } from 'web/components/avatar'
import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button'
export async function getStaticProps() {
const groups = await listAllGroups().catch((_) => [])
@ -92,79 +93,75 @@ export default function Groups(props: {
return (
<Page>
<Col className="items-center">
<Col className="w-full max-w-xl">
<Col className="px-4 sm:px-0">
<Row className="items-center justify-between">
<Title text="Explore groups" />
{user && (
<CreateGroupButton user={user} goToGroupOnSubmit={true} />
)}
</Row>
<Col className="w-full max-w-2xl px-4 sm:px-2">
<Row className="items-center justify-between">
<Title text="Explore groups" />
{user && <CreateGroupButton user={user} goToGroupOnSubmit={true} />}
</Row>
<div className="mb-6 text-gray-500">
Discuss and compete on questions with a group of friends.
</div>
<div className="mb-6 text-gray-500">
Discuss and compete on questions with a group of friends.
</div>
<Tabs
currentPageForAnalytics={'groups'}
tabs={[
...(user && memberGroupIds.length > 0
? [
{
title: 'My Groups',
content: (
<Col>
<input
type="text"
onChange={(e) => debouncedQuery(e.target.value)}
placeholder="Search your groups"
className="input input-bordered mb-4 w-full"
/>
<Col className="gap-4">
{matchesOrderedByRecentActivity
.filter((match) =>
memberGroupIds.includes(match.id)
)
.map((group) => (
<GroupCard
key={group.id}
group={group}
creator={creatorsDict[group.creatorId]}
/>
))}
</Col>
</Col>
),
},
]
: []),
{
title: 'All',
content: (
<Col>
<input
type="text"
onChange={(e) => debouncedQuery(e.target.value)}
placeholder="Search groups"
className="input input-bordered mb-4 w-full"
/>
<Col className="gap-4">
{matches.map((group) => (
<GroupCard
key={group.id}
group={group}
creator={creatorsDict[group.creatorId]}
<Tabs
currentPageForAnalytics={'groups'}
tabs={[
...(user && memberGroupIds.length > 0
? [
{
title: 'My Groups',
content: (
<Col>
<input
type="text"
onChange={(e) => debouncedQuery(e.target.value)}
placeholder="Search your groups"
className="input input-bordered mb-4 w-full"
/>
))}
</Col>
</Col>
),
},
]}
/>
</Col>
<div className="flex flex-wrap justify-center gap-4">
{matchesOrderedByRecentActivity
.filter((match) =>
memberGroupIds.includes(match.id)
)
.map((group) => (
<GroupCard
key={group.id}
group={group}
creator={creatorsDict[group.creatorId]}
/>
))}
</div>
</Col>
),
},
]
: []),
{
title: 'All',
content: (
<Col>
<input
type="text"
onChange={(e) => debouncedQuery(e.target.value)}
placeholder="Search groups"
className="input input-bordered mb-4 w-full"
/>
<div className="flex flex-wrap justify-center gap-4">
{matches.map((group) => (
<GroupCard
key={group.id}
group={group}
creator={creatorsDict[group.creatorId]}
/>
))}
</div>
</Col>
),
},
]}
/>
</Col>
</Col>
</Page>
@ -176,32 +173,33 @@ export function GroupCard(props: { group: Group; creator: User | undefined }) {
return (
<Col
key={group.id}
className="relative gap-1 rounded-xl bg-white p-8 shadow-md hover:bg-gray-100"
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)}>
<a className="absolute left-0 right-0 top-0 bottom-0" />
<a className="absolute left-0 right-0 top-0 bottom-0 z-0" />
</Link>
<div>
<Avatar
className={'absolute top-2 right-2'}
username={creator?.username}
avatarUrl={creator?.avatarUrl}
noLink={false}
size={12}
/>
</div>
<Row className="items-center justify-between gap-2">
<span className="text-xl">{group.name}</span>
</Row>
<div className="flex flex-col items-start justify-start gap-2 text-sm text-gray-500 ">
<Row>
{group.contractIds.length} questions
<div className={'mx-2'}></div>
<div className="mr-1">Created by</div>
<UserLink
className="text-neutral"
name={creator?.name ?? ''}
username={creator?.username ?? ''}
/>
</Row>
{group.memberIds.length > 1 && (
<Row>
<GroupMembersList group={group} />
</Row>
)}
</div>
<div className="text-sm text-gray-500">{group.about}</div>
<Row>{group.contractIds.length} questions</Row>
<Row className="text-sm text-gray-500">
<GroupMembersList group={group} />
</Row>
<Row>
<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'} />
</Col>
</Col>
)
}

View File

@ -32,7 +32,7 @@ const Home = () => {
<ContractSearch
querySortOptions={{
shouldLoadFromStorage: true,
defaultSort: getSavedSort() ?? '24-hour-vol',
defaultSort: getSavedSort() ?? 'most-popular',
}}
onContractClick={(c) => {
// Show contract without navigating to contract page.