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:
parent
e868f0a15a
commit
55c91dfcdd
|
@ -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 }) {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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']
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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' }
|
||||
})
|
|
@ -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'
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
110
functions/src/scripts/convert-categories.ts
Normal file
110
functions/src/scripts/convert-categories.ts
Normal 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)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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),
|
||||
}))
|
||||
|
|
|
@ -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'}>
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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]),
|
||||
})
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
Loading…
Reference in New Issue
Block a user