From 55c91dfcdd4a5d497d1afea38b5f3db4eaa89ccc Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Wed, 13 Jul 2022 15:11:22 -0600 Subject: [PATCH] Categories to groups (#641) * start on script * Revert "Remove category filters" This reverts commit d6e808e1a39e20193c97f713b1710ed687f7a5a4. * 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 --- common/calculate-dpm.ts | 2 +- common/categories.ts | 17 +- common/contract.ts | 15 +- common/group.ts | 3 + firestore.rules | 2 +- functions/src/create-user.ts | 62 +++++- functions/src/get-daily-bonuses.ts | 142 ------------- functions/src/index.ts | 1 - functions/src/on-create-bet.ts | 120 ++++++++++- functions/src/scripts/convert-categories.ts | 110 ++++++++++ web/components/contract-search.tsx | 59 +++++- web/components/leaderboard.tsx | 5 +- web/components/nav/sidebar.tsx | 4 +- web/components/notifications-icon.tsx | 10 - web/hooks/use-group.ts | 19 +- web/hooks/use-sort-and-query-params.tsx | 3 +- web/lib/firebase/api.ts | 4 - web/lib/firebase/groups.ts | 20 +- web/pages/contract-search-firestore.tsx | 5 + web/pages/create.tsx | 4 +- web/pages/group/[...slugs]/index.tsx | 210 +++++++++++--------- web/pages/groups.tsx | 180 +++++++++-------- web/pages/home.tsx | 2 +- 23 files changed, 617 insertions(+), 382 deletions(-) delete mode 100644 functions/src/get-daily-bonuses.ts create mode 100644 functions/src/scripts/convert-categories.ts diff --git a/common/calculate-dpm.ts b/common/calculate-dpm.ts index 497f1155..d38a7b67 100644 --- a/common/calculate-dpm.ts +++ b/common/calculate-dpm.ts @@ -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 }) { diff --git a/common/categories.ts b/common/categories.ts index 2bd6d25a..232aa526 100644 --- a/common/categories.ts +++ b/common/categories.ts @@ -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) diff --git a/common/contract.ts b/common/contract.ts index 52ca91d6..5ddcf0b8 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -44,10 +44,14 @@ export type Contract = { 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 diff --git a/common/group.ts b/common/group.ts index f06fdd15..15348d5a 100644 --- a/common/group.ts +++ b/common/group.ts @@ -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'] diff --git a/firestore.rules b/firestore.rules index 28ff4485..918448d6 100644 --- a/firestore.rules +++ b/firestore.rules @@ -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; diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts index e70371ca..332c1872 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -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( + 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( + 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', + }) + } + } +} diff --git a/functions/src/get-daily-bonuses.ts b/functions/src/get-daily-bonuses.ts deleted file mode 100644 index 017c32fc..00000000 --- a/functions/src/get-daily-bonuses.ts +++ /dev/null @@ -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' } -}) diff --git a/functions/src/index.ts b/functions/src/index.ts index e5ae78ec..cf75802e 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -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' diff --git a/functions/src/on-create-bet.ts b/functions/src/on-create-bet.ts index 5789ed0b..adf22d56 100644 --- a/functions/src/on-create-bet.ts +++ b/functions/src/on-create-bet.ts @@ -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 diff --git a/functions/src/scripts/convert-categories.ts b/functions/src/scripts/convert-categories.ts new file mode 100644 index 00000000..8fe90807 --- /dev/null +++ b/functions/src/scripts/convert-categories.ts @@ -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(adminFirestore.collection('groups')) + const contracts = await getValues( + 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( + adminFirestore + .collection('contracts') + .where('lowercaseTags', 'array-contains', category.toLowerCase()) + ) + const slug = category.toLowerCase() + CATEGORIES_GROUP_SLUG_POSTFIX + const oldGroup = await getValues( + 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(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) +} diff --git a/web/components/contract-search.tsx b/web/components/contract-search.tsx index 220a95ab..834a4db1 100644 --- a/web/components/contract-search.tsx +++ b/web/components/contract-search.tsx @@ -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( 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*/} {!hideOrderSelector && ( @@ -155,13 +184,21 @@ export function ContractSearch(props: { - + {/**/} + + {filter === 'personal' && + (follows ?? []).length === 0 && + (memberGroupSlugs ?? []).length === 0 ? ( + <>You're not following anyone, nor in any of your own groups yet. + ) : ( + + )} ) } diff --git a/web/components/leaderboard.tsx b/web/components/leaderboard.tsx index fb104060..b8c725e0 100644 --- a/web/components/leaderboard.tsx +++ b/web/components/leaderboard.tsx @@ -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 (
diff --git a/web/components/nav/sidebar.tsx b/web/components/nav/sidebar.tsx index 430e98d2..784eb63a 100644 --- a/web/components/nav/sidebar.tsx +++ b/web/components/nav/sidebar.tsx @@ -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), })) diff --git a/web/components/notifications-icon.tsx b/web/components/notifications-icon.tsx index 478b4ad4..0dfc5054 100644 --- a/web/components/notifications-icon.tsx +++ b/web/components/notifications-icon.tsx @@ -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'}> diff --git a/web/hooks/use-group.ts b/web/hooks/use-group.ts index 41f84707..1fde9f4e 100644 --- a/web/hooks/use-group.ts +++ b/web/hooks/use-group.ts @@ -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 } diff --git a/web/hooks/use-sort-and-query-params.tsx b/web/hooks/use-sort-and-query-params.tsx index b7bfb288..a2590249 100644 --- a/web/hooks/use-sort-and-query-params.tsx +++ b/web/hooks/use-sort-and-query-params.tsx @@ -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() diff --git a/web/lib/firebase/api.ts b/web/lib/firebase/api.ts index a6bd4359..27d6caa3 100644 --- a/web/lib/firebase/api.ts +++ b/web/lib/firebase/api.ts @@ -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) -} diff --git a/web/lib/firebase/groups.ts b/web/lib/firebase/groups.ts index fbb11520..708096b3 100644 --- a/web/lib/firebase/groups.ts +++ b/web/lib/firebase/groups.ts @@ -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]), }) diff --git a/web/pages/contract-search-firestore.tsx b/web/pages/contract-search-firestore.tsx index 2fa4844e..3ac11993 100644 --- a/web/pages/contract-search-firestore.tsx +++ b/web/pages/contract-search-firestore.tsx @@ -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> diff --git a/web/pages/create.tsx b/web/pages/create.tsx index 705ef0eb..7040dff0 100644 --- a/web/pages/create.tsx +++ b/web/pages/create.tsx @@ -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)) diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 4266b808..fc76df48 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -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) } diff --git a/web/pages/groups.tsx b/web/pages/groups.tsx index ae64cc76..8f2fe424 100644 --- a/web/pages/groups.tsx +++ b/web/pages/groups.tsx @@ -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> ) } diff --git a/web/pages/home.tsx b/web/pages/home.tsx index 12bd46a2..98d5036e 100644 --- a/web/pages/home.tsx +++ b/web/pages/home.tsx @@ -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.