From 3b3717d307a223f6c5c6a973d8530482591ffaf3 Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Wed, 22 Jun 2022 11:35:50 -0500 Subject: [PATCH] Groups (#510) * Folds=>groups * Show groups on user profile * Allow group creation from /create * Refactoring to groups * Convert folds to groups * Add new add to group notification * Fix user profile tab bug * Add groups nav and tab for my groups * Remove bad profile pages * remove comments * Add group list dropdown to sidebar * remove unused * group cards ui * Messages=>Comments, v2, groupDetails * Discussion time * Cleaning up some code * Remove follow count * Fix pool scoring for cpmm * Fix imports * Simplify rules, add GroupUser collection * Fix group cards * Refactor * Refactor * Small fixes * Remove string * Add api error detail handling * Clear name field * Componentize * Spacing * Undo userpage memo * Member groups are already in my tab * Remove active contracts reference for now * Remove unused * Refactoring * Allow adding old questions to a group * Rename * Wording * Throw standard v2 APIError * Hide input for non-members, add about under title * Multiple names to & # more * Move comments firestore rules to appropriate subpaths * Group membership, pool=>volume * Cleanup, useEvent * Raise state to parent * Eliminate unused * Cleaning up * Clean code * Revert tags input deletion * Cleaning code * Stylling * Limit members to display * Array cleanup * Add categories back in * Private=>closed * Unused vars --- common/comment.ts | 3 +- common/contract.ts | 3 + common/envs/dev.ts | 1 + common/envs/prod.ts | 2 + common/envs/theoremone.ts | 1 + common/fold.ts | 23 - common/group.ts | 21 + common/new-contract.ts | 5 +- common/notification.ts | 7 +- common/scoring.ts | 7 +- firestore.rules | 27 +- functions/src/api.ts | 1 + functions/src/backup-db.ts | 2 +- functions/src/create-contract.ts | 30 +- functions/src/create-fold.ts | 95 --- functions/src/create-group.ts | 87 +++ functions/src/create-notification.ts | 23 +- functions/src/index.ts | 6 +- functions/src/on-create-group.ts | 30 + functions/src/on-fold-delete.ts | 10 - functions/src/on-fold-follow.ts | 17 - functions/src/on-update-group.ts | 20 + functions/src/scripts/lowercase-fold-tags.ts | 34 -- web/components/confirmation-button.tsx | 53 +- web/components/contract/contract-card.tsx | 18 +- web/components/contract/contract-details.tsx | 20 +- .../contract/contract-info-dialog.tsx | 3 +- web/components/contract/contracts-list.tsx | 22 +- web/components/create-question-button.tsx | 34 ++ web/components/feed/copy-link-date-time.tsx | 19 +- .../feed/feed-answer-comment-group.tsx | 9 +- web/components/feed/feed-comments.tsx | 201 ++++--- web/components/feed/find-active-contracts.ts | 6 +- web/components/filter-select-users.tsx | 118 ++++ web/components/folds/create-fold-button.tsx | 141 ----- web/components/folds/fast-fold-following.tsx | 110 ---- web/components/folds/fold-tag.tsx | 17 - web/components/folds/follow-fold-button.tsx | 33 -- web/components/groups/create-group-button.tsx | 140 +++++ web/components/groups/discussion.tsx | 185 ++++++ .../edit-group-button.tsx} | 95 ++- web/components/groups/group-selector.tsx | 139 +++++ web/components/layout/col.tsx | 10 +- web/components/layout/row.tsx | 11 +- web/components/nav/menu.tsx | 21 +- web/components/nav/sidebar.tsx | 120 ++-- web/components/tags-input.tsx | 24 - web/components/user-page.tsx | 21 +- web/hooks/use-comments.ts | 4 +- web/hooks/use-contracts.ts | 16 - web/hooks/use-fold.ts | 80 --- web/hooks/use-group.ts | 78 +++ web/hooks/use-sort-and-query-params.tsx | 5 + web/lib/firebase/api-call.ts | 10 +- web/lib/firebase/comments.ts | 52 +- web/lib/firebase/contracts.ts | 12 + web/lib/firebase/fn-call.ts | 6 - web/lib/firebase/folds.ts | 220 ------- web/lib/firebase/groups.ts | 84 +++ web/pages/[username]/bets.tsx | 5 - web/pages/[username]/comments.tsx | 5 - web/pages/[username]/index.tsx | 14 +- web/pages/create.tsx | 67 ++- web/pages/fold/[...slugs]/index.tsx | 330 ----------- web/pages/folds.tsx | 155 ----- web/pages/group/[...slugs]/index.tsx | 557 ++++++++++++++++++ web/pages/groups.tsx | 204 +++++++ web/pages/notifications.tsx | 15 +- 68 files changed, 2319 insertions(+), 1625 deletions(-) delete mode 100644 common/fold.ts create mode 100644 common/group.ts delete mode 100644 functions/src/create-fold.ts create mode 100644 functions/src/create-group.ts create mode 100644 functions/src/on-create-group.ts delete mode 100644 functions/src/on-fold-delete.ts delete mode 100644 functions/src/on-fold-follow.ts create mode 100644 functions/src/on-update-group.ts delete mode 100644 functions/src/scripts/lowercase-fold-tags.ts create mode 100644 web/components/create-question-button.tsx create mode 100644 web/components/filter-select-users.tsx delete mode 100644 web/components/folds/create-fold-button.tsx delete mode 100644 web/components/folds/fast-fold-following.tsx delete mode 100644 web/components/folds/fold-tag.tsx delete mode 100644 web/components/folds/follow-fold-button.tsx create mode 100644 web/components/groups/create-group-button.tsx create mode 100644 web/components/groups/discussion.tsx rename web/components/{folds/edit-fold-button.tsx => groups/edit-group-button.tsx} (55%) create mode 100644 web/components/groups/group-selector.tsx delete mode 100644 web/hooks/use-fold.ts create mode 100644 web/hooks/use-group.ts delete mode 100644 web/lib/firebase/folds.ts create mode 100644 web/lib/firebase/groups.ts delete mode 100644 web/pages/[username]/bets.tsx delete mode 100644 web/pages/[username]/comments.tsx delete mode 100644 web/pages/fold/[...slugs]/index.tsx delete mode 100644 web/pages/folds.tsx create mode 100644 web/pages/group/[...slugs]/index.tsx create mode 100644 web/pages/groups.tsx diff --git a/common/comment.ts b/common/comment.ts index 1f420b64..0d0c4daf 100644 --- a/common/comment.ts +++ b/common/comment.ts @@ -2,7 +2,8 @@ // They're uniquely identified by the pair contractId/betId. export type Comment = { id: string - contractId: string + contractId?: string + groupId?: string betId?: string answerOutcome?: string replyToCommentId?: string diff --git a/common/contract.ts b/common/contract.ts index 8427c84b..d8f2f032 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -1,5 +1,6 @@ import { Answer } from './answer' import { Fees } from './fees' +import { GroupDetails } from 'common/group' export type AnyMechanism = DPM | CPMM export type AnyOutcomeType = Binary | FreeResponse | Numeric @@ -24,6 +25,8 @@ export type Contract = { lowercaseTags: string[] visibility: 'public' | 'unlisted' + groupDetails?: GroupDetails[] // Starting with one group per contract + createdTime: number // Milliseconds since epoch lastUpdatedTime?: number // Updated on new bet or comment lastBetTime?: number diff --git a/common/envs/dev.ts b/common/envs/dev.ts index e643800b..e20c6e9e 100644 --- a/common/envs/dev.ts +++ b/common/envs/dev.ts @@ -17,6 +17,7 @@ export const DEV_CONFIG: EnvConfig = { sellshares: 'https://sellshares-w3txbmd3ba-uc.a.run.app', sellbet: 'https://sellbet-w3txbmd3ba-uc.a.run.app', createmarket: 'https://createmarket-w3txbmd3ba-uc.a.run.app', + creategroup: 'https://creategroup-w3txbmd3ba-uc.a.run.app', }, amplitudeApiKey: 'fd8cbfd964b9a205b8678a39faae71b3', } diff --git a/common/envs/prod.ts b/common/envs/prod.ts index bce8cac4..dbc686aa 100644 --- a/common/envs/prod.ts +++ b/common/envs/prod.ts @@ -3,6 +3,7 @@ export type V2CloudFunction = | 'sellbet' | 'sellshares' | 'createmarket' + | 'creategroup' export type EnvConfig = { domain: string @@ -52,6 +53,7 @@ export const PROD_CONFIG: EnvConfig = { sellshares: 'https://sellshares-nggbo3neva-uc.a.run.app', sellbet: 'https://sellbet-nggbo3neva-uc.a.run.app', createmarket: 'https://createmarket-nggbo3neva-uc.a.run.app', + creategroup: 'https://creategroup-nggbo3neva-uc.a.run.app', }, adminEmails: [ 'akrolsmir@gmail.com', // Austin diff --git a/common/envs/theoremone.ts b/common/envs/theoremone.ts index 54ced3f4..afc2b8ba 100644 --- a/common/envs/theoremone.ts +++ b/common/envs/theoremone.ts @@ -18,6 +18,7 @@ export const THEOREMONE_CONFIG: EnvConfig = { sellshares: 'https://sellshares-nggbo3neva-uc.a.run.app', sellbet: 'https://sellbet-nggbo3neva-uc.a.run.app', createmarket: 'https://createmarket-nggbo3neva-uc.a.run.app', + creategroup: 'https://creategroup-nggbo3neva-uc.a.run.app', }, adminEmails: [...PROD_CONFIG.adminEmails, 'david.glidden@theoremone.co'], whitelistEmail: '@theoremone.co', diff --git a/common/fold.ts b/common/fold.ts deleted file mode 100644 index e732e34d..00000000 --- a/common/fold.ts +++ /dev/null @@ -1,23 +0,0 @@ -export type Fold = { - id: string - slug: string - name: string - about: string - curatorId: string // User id - createdTime: number - - tags: string[] - lowercaseTags: string[] - - contractIds: string[] - excludedContractIds: string[] - - // Invariant: exactly one of the following is defined. - // Default: creatorIds: undefined, excludedCreatorIds: [] - creatorIds?: string[] - excludedCreatorIds?: string[] - - followCount: number - - disallowMarketCreation?: boolean -} diff --git a/common/group.ts b/common/group.ts new file mode 100644 index 00000000..c0b497bc --- /dev/null +++ b/common/group.ts @@ -0,0 +1,21 @@ +export type Group = { + id: string + slug: string + name: string + about: string + creatorId: string // User id + createdTime: number + mostRecentActivityTime: number + memberIds: string[] // User ids + anyoneCanJoin: boolean + contractIds: string[] +} +export const MAX_GROUP_NAME_LENGTH = 75 +export const MAX_ABOUT_LENGTH = 140 +export const MAX_ID_LENGTH = 60 + +export type GroupDetails = { + groupId: string + groupSlug: string + groupName: string +} diff --git a/common/new-contract.ts b/common/new-contract.ts index 0b7d294a..99190165 100644 --- a/common/new-contract.ts +++ b/common/new-contract.ts @@ -11,6 +11,7 @@ import { import { User } from './user' import { parseTags } from './util/parse' import { removeUndefinedProps } from './util/object' +import { GroupDetails } from 'common/group' export function getNewContract( id: string, @@ -27,7 +28,8 @@ export function getNewContract( // used for numeric markets bucketCount: number, min: number, - max: number + max: number, + groupDetails?: GroupDetails ) { const tags = parseTags( `${question} ${description} ${extraTags.map((tag) => `#${tag}`).join(' ')}` @@ -69,6 +71,7 @@ export function getNewContract( liquidityFee: 0, platformFee: 0, }, + groupDetails: groupDetails ? [groupDetails] : undefined, }) return contract as Contract diff --git a/common/notification.ts b/common/notification.ts index 845a859e..919cf917 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -15,10 +15,13 @@ export type Notification = { sourceUserUsername?: string sourceUserAvatarUrl?: string sourceText?: string + sourceContractTitle?: string sourceContractCreatorUsername?: string sourceContractSlug?: string - sourceContractTags?: string[] + + sourceSlug?: string + sourceTitle?: string } export type notification_source_types = | 'contract' @@ -29,6 +32,7 @@ export type notification_source_types = | 'follow' | 'tip' | 'admin_message' + | 'group' export type notification_source_update_types = | 'created' @@ -48,3 +52,4 @@ export type notification_reason_types = | 'reply_to_users_comment' | 'on_new_follow' | 'you_follow_user' + | 'added_you_to_group' diff --git a/common/scoring.ts b/common/scoring.ts index e910aa2f..d4e40267 100644 --- a/common/scoring.ts +++ b/common/scoring.ts @@ -7,7 +7,12 @@ import { getPayouts } from './payouts' export function scoreCreators(contracts: Contract[]) { const creatorScore = mapValues( groupBy(contracts, ({ creatorId }) => creatorId), - (contracts) => sumBy(contracts, ({ pool }) => pool.YES + pool.NO) + (contracts) => + sumBy( + contracts.map((contract) => { + return contract.volume + }) + ) ) return creatorScore diff --git a/firestore.rules b/firestore.rules index 3516de02..652851a4 100644 --- a/firestore.rules +++ b/firestore.rules @@ -59,6 +59,9 @@ service cloud.firestore { .hasOnly(['description', 'closeTime']) && resource.data.creatorId == request.auth.uid; allow update: if isAdmin(); + match /comments/{commentId} { + allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data); + } } match /{somePath=**}/bets/{betId} { @@ -80,20 +83,12 @@ service cloud.firestore { match /{somePath=**}/comments/{commentId} { allow read; - allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data); } match /{somePath=**}/answers/{answerId} { allow read; } - match /folds/{foldId} { - allow read; - allow update: if request.auth.uid == resource.data.curatorId - && request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['name', 'about', 'tags', 'lowercaseTags']); - allow delete: if request.auth.uid == resource.data.curatorId; - } match /{somePath=**}/followers/{userId} { allow read; @@ -111,5 +106,21 @@ service cloud.firestore { && request.resource.data.diff(resource.data).affectedKeys() .hasOnly(['isSeen', 'viewTime']); } + + match /groups/{groupId} { + allow read; + allow update: if request.auth.uid in resource.data.memberIds + && request.resource.data.diff(resource.data).affectedKeys() + .hasOnly(['name', 'about', 'contractIds', 'memberIds', 'anyoneCanJoin' ]); + allow delete: if request.auth.uid == resource.data.creatorId; + + function isMember() { + return request.auth.uid in get(/databases/$(database)/documents/groups/$(groupId)).data.memberIds; + } + + match /comments/{commentId} { + allow create: if request.auth != null && commentMatchesUser(request.auth.uid, request.resource.data) && isMember(); + } + } } } diff --git a/functions/src/api.ts b/functions/src/api.ts index ff315562..abbf952b 100644 --- a/functions/src/api.ts +++ b/functions/src/api.ts @@ -96,6 +96,7 @@ export const validate = (schema: T, val: unknown) => { const result = schema.safeParse(val) if (!result.success) { const issues = result.error.issues.map((i) => { + // TODO: export this type for the front-end to parse return { field: i.path.join('.') || null, error: i.message, diff --git a/functions/src/backup-db.ts b/functions/src/backup-db.ts index 163760c8..5174f595 100644 --- a/functions/src/backup-db.ts +++ b/functions/src/backup-db.ts @@ -41,7 +41,7 @@ export const backupDb = functions.pubsub // NOTE: Subcollections are not backed up by default collectionIds: [ 'contracts', - 'folds', + 'groups', 'private-users', 'stripe-transactions', 'users', diff --git a/functions/src/create-contract.ts b/functions/src/create-contract.ts index 645b5544..d62de2ec 100644 --- a/functions/src/create-contract.ts +++ b/functions/src/create-contract.ts @@ -28,6 +28,7 @@ import { getNoneAnswer } from '../../common/answer' import { getNewContract } from '../../common/new-contract' import { NUMERIC_BUCKET_COUNT } from '../../common/numeric-constants' import { User } from '../../common/user' +import { Group, MAX_ID_LENGTH } from '../../common/group' const bodySchema = z.object({ question: z.string().min(1).max(MAX_QUESTION_LENGTH), @@ -38,6 +39,7 @@ const bodySchema = z.object({ 'Close time must be in the future.' ), outcomeType: z.enum(OUTCOME_TYPES), + groupId: z.string().min(1).max(MAX_ID_LENGTH).optional(), }) const binarySchema = z.object({ @@ -50,10 +52,8 @@ const numericSchema = z.object({ }) export const createmarket = newEndpoint(['POST'], async (req, auth) => { - const { question, description, tags, closeTime, outcomeType } = validate( - bodySchema, - req.body - ) + const { question, description, tags, closeTime, outcomeType, groupId } = + validate(bodySchema, req.body) let min, max, initialProb if (outcomeType === 'NUMERIC') { @@ -77,6 +77,19 @@ export const createmarket = newEndpoint(['POST'], async (req, auth) => { } const user = userDoc.data() as User + let group = null + if (groupId) { + const groupDoc = await firestore.collection('groups').doc(groupId).get() + if (!groupDoc.exists) { + throw new APIError(400, 'No group exists with the given group ID.') + } + + group = groupDoc.data() as Group + if (!group.memberIds.includes(user.id)) { + throw new APIError(400, 'User is not a member of the group.') + } + } + const userContractsCreatedTodaySnapshot = await firestore .collection(`contracts`) .where('creatorId', '==', auth.uid) @@ -115,7 +128,14 @@ export const createmarket = newEndpoint(['POST'], async (req, auth) => { tags ?? [], NUMERIC_BUCKET_COUNT, min ?? 0, - max ?? 0 + max ?? 0, + group + ? { + groupId: group.id, + groupName: group.name, + groupSlug: group.slug, + } + : undefined ) if (!isFree && ante) await chargeUser(user.id, ante, true) diff --git a/functions/src/create-fold.ts b/functions/src/create-fold.ts deleted file mode 100644 index d6a33188..00000000 --- a/functions/src/create-fold.ts +++ /dev/null @@ -1,95 +0,0 @@ -import * as functions from 'firebase-functions' -import * as admin from 'firebase-admin' - -import { getUser } from './utils' -import { Contract } from '../../common/contract' -import { slugify } from '../../common/util/slugify' -import { randomString } from '../../common/util/random' -import { Fold } from '../../common/fold' - -export const createFold = functions.runWith({ minInstances: 1 }).https.onCall( - async ( - data: { - name: string - about: string - tags: string[] - }, - context - ) => { - const userId = context?.auth?.uid - if (!userId) return { status: 'error', message: 'Not authorized' } - - const creator = await getUser(userId) - if (!creator) return { status: 'error', message: 'User not found' } - - let { name, about } = data - - if (!name || typeof name !== 'string') - return { status: 'error', message: 'Name must be a non-empty string' } - name = name.trim().slice(0, 140) - - if (typeof about !== 'string') - return { status: 'error', message: 'About must be a string' } - about = about.trim().slice(0, 140) - - const { tags } = data - - if (!Array.isArray(tags)) - return { status: 'error', message: 'Tags must be an array of strings' } - - console.log( - 'creating fold for', - creator.username, - 'named', - name, - 'about', - about, - 'tags', - tags - ) - - const slug = await getSlug(name) - - const foldRef = firestore.collection('folds').doc() - - const fold: Fold = { - id: foldRef.id, - curatorId: userId, - slug, - name, - about, - tags, - lowercaseTags: tags.map((tag) => tag.toLowerCase()), - createdTime: Date.now(), - contractIds: [], - excludedContractIds: [], - excludedCreatorIds: [], - followCount: 0, - } - - await foldRef.create(fold) - - await foldRef.collection('followers').doc(userId).set({ userId }) - - return { status: 'success', fold } - } -) - -const getSlug = async (name: string) => { - const proposedSlug = slugify(name) - - const preexistingFold = await getFoldFromSlug(proposedSlug) - - return preexistingFold ? proposedSlug + '-' + randomString() : proposedSlug -} - -const firestore = admin.firestore() - -export async function getFoldFromSlug(slug: string) { - const snap = await firestore - .collection('folds') - .where('slug', '==', slug) - .get() - - return snap.empty ? undefined : (snap.docs[0].data() as Contract) -} diff --git a/functions/src/create-group.ts b/functions/src/create-group.ts new file mode 100644 index 00000000..e7ee0cf5 --- /dev/null +++ b/functions/src/create-group.ts @@ -0,0 +1,87 @@ +import * as admin from 'firebase-admin' + +import { getUser } from './utils' +import { Contract } from '../../common/contract' +import { slugify } from '../../common/util/slugify' +import { randomString } from '../../common/util/random' +import { + Group, + MAX_ABOUT_LENGTH, + MAX_GROUP_NAME_LENGTH, + MAX_ID_LENGTH, +} from '../../common/group' +import { APIError, newEndpoint, validate } from '../../functions/src/api' +import { z } from 'zod' + +const bodySchema = z.object({ + name: z.string().min(1).max(MAX_GROUP_NAME_LENGTH), + memberIds: z.array(z.string().min(1).max(MAX_ID_LENGTH)), + anyoneCanJoin: z.boolean(), + about: z.string().min(1).max(MAX_ABOUT_LENGTH).optional(), +}) + +export const creategroup = newEndpoint(['POST'], async (req, auth) => { + const { name, about, memberIds, anyoneCanJoin } = validate( + bodySchema, + req.body + ) + + const creator = await getUser(auth.uid) + if (!creator) + throw new APIError(400, 'No user exists with the authenticated user ID.') + + // Add creator id to member ids for convenience + if (!memberIds.includes(creator.id)) memberIds.push(creator.id) + + console.log( + 'creating group for', + creator.username, + 'named', + name, + 'about', + about, + 'other member ids', + memberIds + ) + + const slug = await getSlug(name) + + const groupRef = firestore.collection('groups').doc() + + const group: Group = { + id: groupRef.id, + creatorId: creator.id, + slug, + name, + about: about ?? '', + createdTime: Date.now(), + mostRecentActivityTime: Date.now(), + // TODO: allow users to add contract ids on group creation + contractIds: [], + anyoneCanJoin, + memberIds, + } + + await groupRef.create(group) + + return { status: 'success', group: group } +}) + +const getSlug = async (name: string) => { + const proposedSlug = slugify(name) + + const preexistingGroup = await getGroupFromSlug(proposedSlug) + + return preexistingGroup ? proposedSlug + '-' + randomString() : proposedSlug +} + +const firestore = admin.firestore() + +export async function getGroupFromSlug(slug: string) { + const snap = await firestore + .collection('groups') + .where('slug', '==', slug) + .get() + + return snap.empty ? undefined : (snap.docs[0].data() as Contract) +} diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 83ba9f24..daf7e9d7 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -29,7 +29,9 @@ export const createNotification = async ( sourceText: string, sourceContract?: Contract, relatedSourceType?: notification_source_types, - relatedUserId?: string + relatedUserId?: string, + sourceSlug?: string, + sourceTitle?: string ) => { const shouldGetNotification = ( userId: string, @@ -63,10 +65,12 @@ export const createNotification = async ( sourceUserUsername: sourceUser.username, sourceUserAvatarUrl: sourceUser.avatarUrl, sourceText, - sourceContractTitle: sourceContract?.question, sourceContractCreatorUsername: sourceContract?.creatorUsername, + // TODO: move away from sourceContractTitle to sourceTitle + sourceContractTitle: sourceContract?.question, sourceContractSlug: sourceContract?.slug, - sourceContractTags: sourceContract?.tags, + sourceSlug: sourceSlug ? sourceSlug : sourceContract?.slug, + sourceTitle: sourceTitle ? sourceTitle : sourceContract?.question, } await notificationRef.set(removeUndefinedProps(notification)) }) @@ -238,6 +242,16 @@ export const createNotification = async ( }) } + const notifyUserAddedToGroup = async ( + userToReasonTexts: user_to_reason_texts, + relatedUserId: string + ) => { + if (shouldGetNotification(relatedUserId, userToReasonTexts)) + userToReasonTexts[relatedUserId] = { + reason: 'added_you_to_group', + } + } + const getUsersToNotify = async () => { const userToReasonTexts: user_to_reason_texts = {} // The following functions modify the userToReasonTexts object in place. @@ -273,6 +287,9 @@ export const createNotification = async ( } } else if (sourceType === 'follow' && relatedUserId) { await notifyFollowedUser(userToReasonTexts, relatedUserId) + } else if (sourceType === 'group' && relatedUserId) { + if (sourceUpdateType === 'created') + await notifyUserAddedToGroup(userToReasonTexts, relatedUserId) } return userToReasonTexts } diff --git a/functions/src/index.ts b/functions/src/index.ts index 501f6b76..7bcd2199 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -8,12 +8,9 @@ export * from './transact' export * from './resolve-market' export * from './stripe' export * from './create-user' -export * from './create-fold' export * from './create-answer' export * from './on-create-bet' export * from './on-create-comment' -export * from './on-fold-follow' -export * from './on-fold-delete' export * from './on-view' export * from './unsubscribe' export * from './update-metrics' @@ -27,6 +24,8 @@ export * from './on-create-contract' export * from './on-follow-user' export * from './on-unfollow-user' export * from './on-create-liquidity-provision' +export * from './on-update-group' +export * from './on-create-group' // v2 export * from './health' @@ -35,3 +34,4 @@ export * from './sell-bet' export * from './sell-shares' export * from './create-contract' export * from './withdraw-liquidity' +export * from './create-group' diff --git a/functions/src/on-create-group.ts b/functions/src/on-create-group.ts new file mode 100644 index 00000000..1d041c04 --- /dev/null +++ b/functions/src/on-create-group.ts @@ -0,0 +1,30 @@ +import * as functions from 'firebase-functions' +import { getUser } from './utils' +import { createNotification } from './create-notification' +import { Group } from '../../common/group' + +export const onCreateGroup = functions.firestore + .document('groups/{groupId}') + .onCreate(async (change, context) => { + const group = change.data() as Group + const { eventId } = context + + const groupCreator = await getUser(group.creatorId) + if (!groupCreator) throw new Error('Could not find group creator') + // create notifications for all members of the group + for (const memberId of group.memberIds) { + await createNotification( + group.id, + 'group', + 'created', + groupCreator, + eventId, + group.about, + undefined, + undefined, + memberId, + group.slug, + group.name + ) + } + }) diff --git a/functions/src/on-fold-delete.ts b/functions/src/on-fold-delete.ts deleted file mode 100644 index 10afca43..00000000 --- a/functions/src/on-fold-delete.ts +++ /dev/null @@ -1,10 +0,0 @@ -import * as functions from 'firebase-functions' - -export const onFoldDelete = functions.firestore - .document('folds/{foldId}') - .onDelete(async (change, _context) => { - const snapshot = await change.ref.collection('followers').get() - - // Delete followers sub-collection. - await Promise.all(snapshot.docs.map((doc) => doc.ref.delete())) - }) diff --git a/functions/src/on-fold-follow.ts b/functions/src/on-fold-follow.ts deleted file mode 100644 index 4d72fcfb..00000000 --- a/functions/src/on-fold-follow.ts +++ /dev/null @@ -1,17 +0,0 @@ -import * as functions from 'firebase-functions' -import * as admin from 'firebase-admin' - -const firestore = admin.firestore() - -export const onFoldFollow = functions.firestore - .document('folds/{foldId}/followers/{userId}') - .onWrite(async (change, context) => { - const { foldId } = context.params - - const snapshot = await firestore - .collection(`folds/${foldId}/followers`) - .get() - const followCount = snapshot.size - - await firestore.doc(`folds/${foldId}`).update({ followCount }) - }) diff --git a/functions/src/on-update-group.ts b/functions/src/on-update-group.ts new file mode 100644 index 00000000..bc6f6ab4 --- /dev/null +++ b/functions/src/on-update-group.ts @@ -0,0 +1,20 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' +import { Group } from '../../common/group' +const firestore = admin.firestore() + +export const onUpdateGroup = functions.firestore + .document('groups/{groupId}') + .onUpdate(async (change) => { + const prevGroup = change.before.data() as Group + const group = change.after.data() as Group + + // ignore the update we just made + if (prevGroup.mostRecentActivityTime !== group.mostRecentActivityTime) + return + + await firestore + .collection('groups') + .doc(group.id) + .update({ mostRecentActivityTime: Date.now() }) + }) diff --git a/functions/src/scripts/lowercase-fold-tags.ts b/functions/src/scripts/lowercase-fold-tags.ts deleted file mode 100644 index e2912e31..00000000 --- a/functions/src/scripts/lowercase-fold-tags.ts +++ /dev/null @@ -1,34 +0,0 @@ -import * as admin from 'firebase-admin' -import { uniq } from 'lodash' - -import { initAdmin } from './script-init' -initAdmin() - -import { getValues } from '../utils' -import { Fold } from '../../../common/fold' - -async function lowercaseFoldTags() { - const firestore = admin.firestore() - console.log('Updating fold tags') - - const folds = await getValues(firestore.collection('folds')) - - console.log('Loaded', folds.length, 'folds') - - for (const fold of folds) { - const foldRef = firestore.doc(`folds/${fold.id}`) - - const { tags } = fold - const lowercaseTags = uniq(tags.map((tag) => tag.toLowerCase())) - - console.log('Adding lowercase tags', fold.slug, lowercaseTags) - - await foldRef.update({ - lowercaseTags, - } as Partial) - } -} - -if (require.main === module) { - lowercaseFoldTags().then(() => process.exit()) -} diff --git a/web/components/confirmation-button.tsx b/web/components/confirmation-button.tsx index 57a7bafe..bc014902 100644 --- a/web/components/confirmation-button.tsx +++ b/web/components/confirmation-button.tsx @@ -18,40 +18,63 @@ export function ConfirmationButton(props: { label?: string className?: string } - onSubmit: () => void children: ReactNode + onSubmit?: () => void + onOpenChanged?: (isOpen: boolean) => void + onSubmitWithSuccess?: () => Promise }) { - const { openModalBtn, cancelBtn, submitBtn, onSubmit, children } = props + const { + openModalBtn, + cancelBtn, + submitBtn, + onSubmit, + children, + onOpenChanged, + onSubmitWithSuccess, + } = props const [open, setOpen] = useState(false) + function updateOpen(newOpen: boolean) { + onOpenChanged?.(newOpen) + setOpen(newOpen) + } + return ( <> - + {children} - - + - + ) } diff --git a/web/components/contract/contract-card.tsx b/web/components/contract/contract-card.tsx index a79351be..bac24586 100644 --- a/web/components/contract/contract-card.tsx +++ b/web/components/contract/contract-card.tsx @@ -31,8 +31,10 @@ export function ContractCard(props: { showCloseTime?: boolean className?: string onClick?: () => void + hideQuickBet?: boolean }) { - const { showHotVolume, showCloseTime, className, onClick } = props + const { showHotVolume, showCloseTime, className, onClick, hideQuickBet } = + props const contract = useContractWithPreload(props.contract) ?? props.contract const { question, outcomeType } = contract const { resolution } = contract @@ -42,12 +44,14 @@ export function ContractCard(props: { const marketClosed = (contract.closeTime || Infinity) < Date.now() || !!resolution - const showQuickBet = !( - !user || - marketClosed || - (outcomeType === 'FREE_RESPONSE' && getTopAnswer(contract) === undefined) || - outcomeType === 'NUMERIC' - ) + const showQuickBet = + user && + !marketClosed && + !( + outcomeType === 'FREE_RESPONSE' && getTopAnswer(contract) === undefined + ) && + outcomeType !== 'NUMERIC' && + !hideQuickBet return (
diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index ee289702..3e953fd6 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -3,6 +3,7 @@ import { DatabaseIcon, PencilIcon, TrendingUpIcon, + UserGroupIcon, } from '@heroicons/react/outline' import { Row } from '../layout/row' import { formatMoney } from 'common/util/format' @@ -24,6 +25,8 @@ import NewContractBadge from '../new-contract-badge' import { CATEGORY_LIST } from 'common/categories' import { TagsList } from '../tags-list' import { UserFollowButton } from '../follow-button' +import { groupPath } from 'web/lib/firebase/groups' +import { SiteLink } from 'web/components/site-link' import { DAY_MS } from 'common/util/time' export function MiscDetails(props: { @@ -107,7 +110,8 @@ export function ContractDetails(props: { disabled?: boolean }) { const { contract, bets, isCreator, disabled } = props - const { closeTime, creatorName, creatorUsername, creatorId } = contract + const { closeTime, creatorName, creatorUsername, creatorId, groupDetails } = + contract const { volumeLabel, resolvedDate } = contractMetrics(contract) return ( @@ -130,18 +134,21 @@ export function ContractDetails(props: { )} {!disabled && } + {groupDetails && ( + + + + {groupDetails[0].groupName} + + + )} {(!!closeTime || !!resolvedDate) && ( - {/* - {createdDate} - */} - {resolvedDate && contract.resolutionTime ? ( <> - {/* {' - '} */} - {/* {' - '}{' '} */} Tags
- {contract.mechanism === 'cpmm-1' && !contract.resolution && ( )} diff --git a/web/components/contract/contracts-list.tsx b/web/components/contract/contracts-list.tsx index 51765535..bc8dbe76 100644 --- a/web/components/contract/contracts-list.tsx +++ b/web/components/contract/contracts-list.tsx @@ -6,6 +6,7 @@ import { ContractCard } from './contract-card' import { ContractSearch } from '../contract-search' import { useIsVisible } from 'web/hooks/use-is-visible' import { useEffect, useState } from 'react' +import clsx from 'clsx' export function ContractsGrid(props: { contracts: Contract[] @@ -13,8 +14,18 @@ export function ContractsGrid(props: { hasMore: boolean showCloseTime?: boolean onContractClick?: (contract: Contract) => void + overrideGridClassName?: string + hideQuickBet?: boolean }) { - const { contracts, showCloseTime, hasMore, loadMore, onContractClick } = props + const { + contracts, + showCloseTime, + hasMore, + loadMore, + onContractClick, + overrideGridClassName, + hideQuickBet, + } = props const [elem, setElem] = useState(null) const isBottomVisible = useIsVisible(elem) @@ -38,7 +49,13 @@ export function ContractsGrid(props: { return ( -