From 89644502a51ef4ecb4d99c0398d8f7c33c33499e Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Tue, 23 Aug 2022 16:29:19 -0600 Subject: [PATCH] Allow to follow/unfollow markets, backfill as well --- common/contract.ts | 1 + common/notification.ts | 1 + firestore.rules | 5 + functions/package.json | 2 +- functions/src/create-notification.ts | 440 +++++++++++------- functions/src/follow-market.ts | 30 ++ functions/src/on-create-answer.ts | 9 +- functions/src/on-create-bet.ts | 6 +- .../src/on-create-comment-on-contract.ts | 18 +- functions/src/on-create-contract.ts | 2 + .../src/on-create-liquidity-provision.ts | 2 + functions/src/on-update-contract.ts | 10 +- .../scripts/backfill-contract-followers.ts | 74 +++ web/components/contract/share-row.tsx | 40 +- web/hooks/use-follows.ts | 11 + web/lib/firebase/contracts.ts | 11 + 16 files changed, 482 insertions(+), 180 deletions(-) create mode 100644 functions/src/follow-market.ts create mode 100644 functions/src/scripts/backfill-contract-followers.ts diff --git a/common/contract.ts b/common/contract.ts index 2a8f897a..343bc750 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -57,6 +57,7 @@ export type Contract = { uniqueBettorIds?: string[] uniqueBettorCount?: number popularityScore?: number + followerCount?: number } & T export type BinaryContract = Contract & Binary diff --git a/common/notification.ts b/common/notification.ts index 0a69f89d..f10bd3f6 100644 --- a/common/notification.ts +++ b/common/notification.ts @@ -70,3 +70,4 @@ export type notification_reason_types = | 'challenge_accepted' | 'betting_streak_incremented' | 'loan_income' + | 'you_follow_contract' diff --git a/firestore.rules b/firestore.rules index b28ac6a5..84113d01 100644 --- a/firestore.rules +++ b/firestore.rules @@ -44,6 +44,11 @@ service cloud.firestore { allow read; } + match /contracts/{contractId}/follows/{userId} { + allow read; + allow create, delete: if userId == request.auth.uid; + } + match /contracts/{contractId}/challenges/{challengeId}{ allow read; allow create: if request.auth.uid == request.resource.data.creatorId; diff --git a/functions/package.json b/functions/package.json index 63ef9b5d..d5b265de 100644 --- a/functions/package.json +++ b/functions/package.json @@ -13,7 +13,7 @@ "deploy": "firebase deploy --only functions", "logs": "firebase functions:log", "dev": "nodemon src/serve.ts", - "firestore": "firebase emulators:start --only firestore --import=./firestore_export", + "firestore": "firebase emulators:start --only firestore,pubsub --import=./firestore_export", "serve": "firebase use dev && yarn build && firebase emulators:start --only functions,firestore,pubsub --import=./firestore_export", "db:update-local-from-remote": "yarn db:backup-remote && gsutil rsync -r gs://$npm_package_config_firestore/firestore_export ./firestore_export", "db:backup-local": "firebase emulators:export --force ./firestore_export", diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index 3fb1f9c3..035126c5 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -7,7 +7,7 @@ import { } from '../../common/notification' import { User } from '../../common/user' import { Contract } from '../../common/contract' -import { getValues } from './utils' +import { getValues, log } from './utils' import { Comment } from '../../common/comment' import { uniq } from 'lodash' import { Bet, LimitBet } from '../../common/bet' @@ -33,19 +33,12 @@ export const createNotification = async ( sourceText: string, miscData?: { contract?: Contract - relatedSourceType?: notification_source_types recipients?: string[] slug?: string title?: string } ) => { - const { - contract: sourceContract, - relatedSourceType, - recipients, - slug, - title, - } = miscData ?? {} + const { contract: sourceContract, recipients, slug, title } = miscData ?? {} const shouldGetNotification = ( userId: string, @@ -90,24 +83,6 @@ export const createNotification = async ( ) } - const notifyLiquidityProviders = async ( - userToReasonTexts: user_to_reason_texts, - contract: Contract - ) => { - const liquidityProviders = await firestore - .collection(`contracts/${contract.id}/liquidity`) - .get() - const liquidityProvidersIds = uniq( - liquidityProviders.docs.map((doc) => doc.data().userId) - ) - liquidityProvidersIds.forEach((userId) => { - if (!shouldGetNotification(userId, userToReasonTexts)) return - userToReasonTexts[userId] = { - reason: 'on_contract_with_users_shares_in', - } - }) - } - const notifyUsersFollowers = async ( userToReasonTexts: user_to_reason_texts ) => { @@ -129,23 +104,6 @@ export const createNotification = async ( }) } - const notifyRepliedUser = ( - userToReasonTexts: user_to_reason_texts, - relatedUserId: string, - relatedSourceType: notification_source_types - ) => { - if (!shouldGetNotification(relatedUserId, userToReasonTexts)) return - if (relatedSourceType === 'comment') { - userToReasonTexts[relatedUserId] = { - reason: 'reply_to_users_comment', - } - } else if (relatedSourceType === 'answer') { - userToReasonTexts[relatedUserId] = { - reason: 'reply_to_users_answer', - } - } - } - const notifyFollowedUser = ( userToReasonTexts: user_to_reason_texts, followedUserId: string @@ -182,71 +140,6 @@ export const createNotification = async ( } } - const notifyOtherAnswerersOnContract = async ( - userToReasonTexts: user_to_reason_texts, - sourceContract: Contract - ) => { - const answers = await getValues( - firestore - .collection('contracts') - .doc(sourceContract.id) - .collection('answers') - ) - const recipientUserIds = uniq(answers.map((answer) => answer.userId)) - recipientUserIds.forEach((userId) => { - if (shouldGetNotification(userId, userToReasonTexts)) - userToReasonTexts[userId] = { - reason: 'on_contract_with_users_answer', - } - }) - } - - const notifyOtherCommentersOnContract = async ( - userToReasonTexts: user_to_reason_texts, - sourceContract: Contract - ) => { - const comments = await getValues( - firestore - .collection('contracts') - .doc(sourceContract.id) - .collection('comments') - ) - const recipientUserIds = uniq(comments.map((comment) => comment.userId)) - recipientUserIds.forEach((userId) => { - if (shouldGetNotification(userId, userToReasonTexts)) - userToReasonTexts[userId] = { - reason: 'on_contract_with_users_comment', - } - }) - } - - const notifyBettorsOnContract = async ( - userToReasonTexts: user_to_reason_texts, - sourceContract: Contract - ) => { - const betsSnap = await firestore - .collection(`contracts/${sourceContract.id}/bets`) - .get() - const bets = betsSnap.docs.map((doc) => doc.data() as Bet) - // filter bets for only users that have an amount invested still - const recipientUserIds = uniq(bets.map((bet) => bet.userId)).filter( - (userId) => { - return ( - getContractBetMetrics( - sourceContract, - bets.filter((bet) => bet.userId === userId) - ).invested > 0 - ) - } - ) - recipientUserIds.forEach((userId) => { - if (shouldGetNotification(userId, userToReasonTexts)) - userToReasonTexts[userId] = { - reason: 'on_contract_with_users_shares_in', - } - }) - } - const notifyUserAddedToGroup = ( userToReasonTexts: user_to_reason_texts, relatedUserId: string @@ -266,58 +159,289 @@ export const createNotification = async ( } } - const getUsersToNotify = async () => { - const userToReasonTexts: user_to_reason_texts = {} - // The following functions modify the userToReasonTexts object in place. - if (sourceType === 'follow' && recipients?.[0]) { - notifyFollowedUser(userToReasonTexts, recipients[0]) - } else if ( - sourceType === 'group' && - sourceUpdateType === 'created' && - recipients - ) { - recipients.forEach((r) => notifyUserAddedToGroup(userToReasonTexts, r)) - } + const userToReasonTexts: user_to_reason_texts = {} + // The following functions modify the userToReasonTexts object in place. - // The following functions need sourceContract to be defined. - if (!sourceContract) return userToReasonTexts - - if ( - sourceType === 'comment' || - sourceType === 'answer' || - (sourceType === 'contract' && - (sourceUpdateType === 'updated' || sourceUpdateType === 'resolved')) - ) { - if (sourceType === 'comment') { - if (recipients?.[0] && relatedSourceType) - notifyRepliedUser(userToReasonTexts, recipients[0], relatedSourceType) - if (sourceText) notifyTaggedUsers(userToReasonTexts, recipients ?? []) - } - await notifyContractCreator(userToReasonTexts, sourceContract) - await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract) - await notifyLiquidityProviders(userToReasonTexts, sourceContract) - await notifyBettorsOnContract(userToReasonTexts, sourceContract) - await notifyOtherCommentersOnContract(userToReasonTexts, sourceContract) - } else if (sourceType === 'contract' && sourceUpdateType === 'created') { - await notifyUsersFollowers(userToReasonTexts) - notifyTaggedUsers(userToReasonTexts, recipients ?? []) - } else if (sourceType === 'contract' && sourceUpdateType === 'closed') { - await notifyContractCreator(userToReasonTexts, sourceContract, { - force: true, - }) - } else if (sourceType === 'liquidity' && sourceUpdateType === 'created') { - await notifyContractCreator(userToReasonTexts, sourceContract) - } else if (sourceType === 'bonus' && sourceUpdateType === 'created') { - // Note: the daily bonus won't have a contract attached to it - await notifyContractCreatorOfUniqueBettorsBonus( - userToReasonTexts, - sourceContract.creatorId - ) - } - return userToReasonTexts + if (sourceType === 'follow' && recipients?.[0]) { + notifyFollowedUser(userToReasonTexts, recipients[0]) + } else if ( + sourceType === 'group' && + sourceUpdateType === 'created' && + recipients + ) { + recipients.forEach((r) => notifyUserAddedToGroup(userToReasonTexts, r)) + } else if ( + sourceType === 'contract' && + sourceUpdateType === 'created' && + sourceContract + ) { + await notifyUsersFollowers(userToReasonTexts) + notifyTaggedUsers(userToReasonTexts, recipients ?? []) + } else if ( + sourceType === 'contract' && + sourceUpdateType === 'closed' && + sourceContract + ) { + await notifyContractCreator(userToReasonTexts, sourceContract, { + force: true, + }) + } else if ( + sourceType === 'liquidity' && + sourceUpdateType === 'created' && + sourceContract + ) { + await notifyContractCreator(userToReasonTexts, sourceContract) + } else if ( + sourceType === 'bonus' && + sourceUpdateType === 'created' && + sourceContract + ) { + // Note: the daily bonus won't have a contract attached to it + await notifyContractCreatorOfUniqueBettorsBonus( + userToReasonTexts, + sourceContract.creatorId + ) } - const userToReasonTexts = await getUsersToNotify() + await createUsersNotifications(userToReasonTexts) +} + +export const createCommentOrAnswerOrUpdatedContractNotification = async ( + sourceId: string, + sourceType: notification_source_types, + sourceUpdateType: notification_source_update_types, + sourceUser: User, + idempotencyKey: string, + sourceText: string, + sourceContract: Contract, + miscData?: { + relatedSourceType?: notification_source_types + repliedUserId?: string + taggedUserIds?: string[] + } +) => { + const { relatedSourceType, repliedUserId, taggedUserIds } = miscData ?? {} + + const createUsersNotifications = async ( + userToReasonTexts: user_to_reason_texts + ) => { + await Promise.all( + Object.keys(userToReasonTexts).map(async (userId) => { + const notificationRef = firestore + .collection(`/users/${userId}/notifications`) + .doc(idempotencyKey) + const notification: Notification = { + id: idempotencyKey, + userId, + reason: userToReasonTexts[userId].reason, + createdTime: Date.now(), + isSeen: false, + sourceId, + sourceType, + sourceUpdateType, + sourceContractId: sourceContract.id, + sourceUserName: sourceUser.name, + sourceUserUsername: sourceUser.username, + sourceUserAvatarUrl: sourceUser.avatarUrl, + sourceText, + sourceContractCreatorUsername: sourceContract.creatorUsername, + sourceContractTitle: sourceContract.question, + sourceContractSlug: sourceContract.slug, + sourceSlug: sourceContract.slug, + sourceTitle: sourceContract.question, + } + await notificationRef.set(removeUndefinedProps(notification)) + }) + ) + } + + // get contract follower documents and check here if they're a follower + const contractFollowersSnap = await firestore + .collection(`contracts/${sourceContract.id}/follows`) + .get() + const contractFollowersIds = contractFollowersSnap.docs.map( + (doc) => doc.data().id + ) + log('contractFollowerIds', contractFollowersIds) + + const stillFollowingContract = (userId: string) => { + return contractFollowersIds.includes(userId) + } + + const shouldGetNotification = ( + userId: string, + userToReasonTexts: user_to_reason_texts + ) => { + return ( + sourceUser.id != userId && + !Object.keys(userToReasonTexts).includes(userId) + ) + } + + const notifyContractFollowers = async ( + userToReasonTexts: user_to_reason_texts + ) => { + for (const userId of contractFollowersIds) { + if (shouldGetNotification(userId, userToReasonTexts)) + userToReasonTexts[userId] = { + reason: 'you_follow_contract', + } + } + } + + const notifyContractCreator = async ( + userToReasonTexts: user_to_reason_texts + ) => { + if ( + shouldGetNotification(sourceContract.creatorId, userToReasonTexts) && + stillFollowingContract(sourceContract.creatorId) + ) + userToReasonTexts[sourceContract.creatorId] = { + reason: 'on_users_contract', + } + } + + const notifyOtherAnswerersOnContract = async ( + userToReasonTexts: user_to_reason_texts + ) => { + const answers = await getValues( + firestore + .collection('contracts') + .doc(sourceContract.id) + .collection('answers') + ) + const recipientUserIds = uniq(answers.map((answer) => answer.userId)) + recipientUserIds.forEach((userId) => { + if ( + shouldGetNotification(userId, userToReasonTexts) && + stillFollowingContract(userId) + ) + userToReasonTexts[userId] = { + reason: 'on_contract_with_users_answer', + } + }) + } + + const notifyOtherCommentersOnContract = async ( + userToReasonTexts: user_to_reason_texts + ) => { + const comments = await getValues( + firestore + .collection('contracts') + .doc(sourceContract.id) + .collection('comments') + ) + const recipientUserIds = uniq(comments.map((comment) => comment.userId)) + recipientUserIds.forEach((userId) => { + if ( + shouldGetNotification(userId, userToReasonTexts) && + stillFollowingContract(userId) + ) + userToReasonTexts[userId] = { + reason: 'on_contract_with_users_comment', + } + }) + } + + const notifyBettorsOnContract = async ( + userToReasonTexts: user_to_reason_texts + ) => { + const betsSnap = await firestore + .collection(`contracts/${sourceContract.id}/bets`) + .get() + const bets = betsSnap.docs.map((doc) => doc.data() as Bet) + // filter bets for only users that have an amount invested still + const recipientUserIds = uniq(bets.map((bet) => bet.userId)).filter( + (userId) => { + return ( + getContractBetMetrics( + sourceContract, + bets.filter((bet) => bet.userId === userId) + ).invested > 0 + ) + } + ) + recipientUserIds.forEach((userId) => { + if ( + shouldGetNotification(userId, userToReasonTexts) && + stillFollowingContract(userId) + ) + userToReasonTexts[userId] = { + reason: 'on_contract_with_users_shares_in', + } + }) + } + + const notifyRepliedUser = ( + userToReasonTexts: user_to_reason_texts, + relatedUserId: string, + relatedSourceType: notification_source_types + ) => { + if ( + shouldGetNotification(relatedUserId, userToReasonTexts) && + stillFollowingContract(relatedUserId) + ) { + if (relatedSourceType === 'comment') { + userToReasonTexts[relatedUserId] = { + reason: 'reply_to_users_comment', + } + } else if (relatedSourceType === 'answer') { + userToReasonTexts[relatedUserId] = { + reason: 'reply_to_users_answer', + } + } + } + } + + const notifyTaggedUsers = ( + userToReasonTexts: user_to_reason_texts, + userIds: (string | undefined)[] + ) => { + userIds.forEach((id) => { + console.log('tagged user: ', id) + // Allowing non-following users to get tagged + if (id && shouldGetNotification(id, userToReasonTexts)) + userToReasonTexts[id] = { + reason: 'tagged_user', + } + }) + } + + const notifyLiquidityProviders = async ( + userToReasonTexts: user_to_reason_texts + ) => { + const liquidityProviders = await firestore + .collection(`contracts/${sourceContract.id}/liquidity`) + .get() + const liquidityProvidersIds = uniq( + liquidityProviders.docs.map((doc) => doc.data().userId) + ) + liquidityProvidersIds.forEach((userId) => { + if ( + shouldGetNotification(userId, userToReasonTexts) && + stillFollowingContract(userId) + ) { + userToReasonTexts[userId] = { + reason: 'on_contract_with_users_shares_in', + } + } + }) + } + const userToReasonTexts: user_to_reason_texts = {} + + if (sourceType === 'comment') { + if (repliedUserId && relatedSourceType) + notifyRepliedUser(userToReasonTexts, repliedUserId, relatedSourceType) + if (sourceText) notifyTaggedUsers(userToReasonTexts, taggedUserIds ?? []) + } + await notifyContractCreator(userToReasonTexts) + await notifyOtherAnswerersOnContract(userToReasonTexts) + await notifyLiquidityProviders(userToReasonTexts) + await notifyBettorsOnContract(userToReasonTexts) + await notifyOtherCommentersOnContract(userToReasonTexts) + // if they weren't added previously, add them now + await notifyContractFollowers(userToReasonTexts) + await createUsersNotifications(userToReasonTexts) } diff --git a/functions/src/follow-market.ts b/functions/src/follow-market.ts new file mode 100644 index 00000000..1dc2941b --- /dev/null +++ b/functions/src/follow-market.ts @@ -0,0 +1,30 @@ +import { Contract } from '../../common/lib/contract' +import { User } from '../../common/lib/user' +import * as admin from 'firebase-admin' +import { FieldValue } from 'firebase-admin/firestore' + +const firestore = admin.firestore() + +export const addUserToContractFollowers = async ( + contract: Contract, + user: User +) => { + const followerDoc = await firestore + .collection(`contracts/${contract.id}/follows`) + .doc(user.id) + .get() + if (followerDoc.exists) return + await firestore + .collection(`contracts/${contract.id}/follows`) + .doc(user.id) + .set({ + id: user.id, + createdTime: Date.now(), + }) + await firestore + .collection(`contracts`) + .doc(contract.id) + .update({ + followerCount: FieldValue.increment(1), + }) +} diff --git a/functions/src/on-create-answer.ts b/functions/src/on-create-answer.ts index 6af5e699..d3e3e1eb 100644 --- a/functions/src/on-create-answer.ts +++ b/functions/src/on-create-answer.ts @@ -1,7 +1,8 @@ import * as functions from 'firebase-functions' import { getContract, getUser } from './utils' -import { createNotification } from './create-notification' +import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification' import { Answer } from '../../common/answer' +import { addUserToContractFollowers } from './follow-market' export const onCreateAnswer = functions.firestore .document('contracts/{contractId}/answers/{answerNumber}') @@ -20,14 +21,14 @@ export const onCreateAnswer = functions.firestore const answerCreator = await getUser(answer.userId) if (!answerCreator) throw new Error('Could not find answer creator') - - await createNotification( + await addUserToContractFollowers(contract, answerCreator) + await createCommentOrAnswerOrUpdatedContractNotification( answer.id, 'answer', 'created', answerCreator, eventId, answer.text, - { contract } + contract ) }) diff --git a/functions/src/on-create-bet.ts b/functions/src/on-create-bet.ts index ff6cf9d9..767add88 100644 --- a/functions/src/on-create-bet.ts +++ b/functions/src/on-create-bet.ts @@ -24,6 +24,7 @@ import { } from '../../common/antes' import { APIError } from '../../common/api' import { User } from '../../common/user' +import { addUserToContractFollowers } from './follow-market' const firestore = admin.firestore() const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime() @@ -54,14 +55,13 @@ export const onCreateBet = functions.firestore log(`Could not find contract ${contractId}`) return } - await updateUniqueBettorsAndGiveCreatorBonus(contract, eventId, bet.userId) - const bettor = await getUser(bet.userId) if (!bettor) return + await addUserToContractFollowers(contract, bettor) + await updateUniqueBettorsAndGiveCreatorBonus(contract, eventId, bet.userId) await notifyFills(bet, contract, eventId, bettor) await updateBettingStreak(bettor, bet, contract, eventId) - await firestore.collection('users').doc(bettor.id).update({ lastBetTime }) }) diff --git a/functions/src/on-create-comment-on-contract.ts b/functions/src/on-create-comment-on-contract.ts index 9f19dfcc..a44487cc 100644 --- a/functions/src/on-create-comment-on-contract.ts +++ b/functions/src/on-create-comment-on-contract.ts @@ -6,8 +6,9 @@ import { ContractComment } from '../../common/comment' import { sendNewCommentEmail } from './emails' import { Bet } from '../../common/bet' import { Answer } from '../../common/answer' -import { createNotification } from './create-notification' +import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification' import { parseMentions, richTextToString } from '../../common/util/parse' +import { addUserToContractFollowers } from './follow-market' const firestore = admin.firestore() @@ -35,6 +36,8 @@ export const onCreateCommentOnContract = functions const commentCreator = await getUser(comment.userId) if (!commentCreator) throw new Error('Could not find comment creator') + await addUserToContractFollowers(contract, commentCreator) + await firestore .collection('contracts') .doc(contract.id) @@ -77,18 +80,19 @@ export const onCreateCommentOnContract = functions ? comments.find((c) => c.id === comment.replyToCommentId)?.userId : answer?.userId - const recipients = uniq( - compact([...parseMentions(comment.content), repliedUserId]) - ) - - await createNotification( + await createCommentOrAnswerOrUpdatedContractNotification( comment.id, 'comment', 'created', commentCreator, eventId, richTextToString(comment.content), - { contract, relatedSourceType, recipients } + contract, + { + relatedSourceType, + repliedUserId, + taggedUserIds: compact(parseMentions(comment.content)), + } ) const recipientUserIds = uniq([ diff --git a/functions/src/on-create-contract.ts b/functions/src/on-create-contract.ts index 3785ecc9..e5a5bb51 100644 --- a/functions/src/on-create-contract.ts +++ b/functions/src/on-create-contract.ts @@ -5,6 +5,7 @@ import { createNotification } from './create-notification' import { Contract } from '../../common/contract' import { parseMentions, richTextToString } from '../../common/util/parse' import { JSONContent } from '@tiptap/core' +import { addUserToContractFollowers } from './follow-market' export const onCreateContract = functions .runWith({ secrets: ['MAILGUN_KEY'] }) @@ -18,6 +19,7 @@ export const onCreateContract = functions const desc = contract.description as JSONContent const mentioned = parseMentions(desc) + await addUserToContractFollowers(contract, contractCreator) await createNotification( contract.id, diff --git a/functions/src/on-create-liquidity-provision.ts b/functions/src/on-create-liquidity-provision.ts index 6ec092a5..0473fb68 100644 --- a/functions/src/on-create-liquidity-provision.ts +++ b/functions/src/on-create-liquidity-provision.ts @@ -2,6 +2,7 @@ import * as functions from 'firebase-functions' import { getContract, getUser } from './utils' import { createNotification } from './create-notification' import { LiquidityProvision } from 'common/liquidity-provision' +import { addUserToContractFollowers } from './follow-market' export const onCreateLiquidityProvision = functions.firestore .document('contracts/{contractId}/liquidity/{liquidityId}') @@ -18,6 +19,7 @@ export const onCreateLiquidityProvision = functions.firestore const liquidityProvider = await getUser(liquidity.userId) if (!liquidityProvider) throw new Error('Could not find liquidity provider') + await addUserToContractFollowers(contract, liquidityProvider) await createNotification( contract.id, diff --git a/functions/src/on-update-contract.ts b/functions/src/on-update-contract.ts index 28523eae..d7ecd56e 100644 --- a/functions/src/on-update-contract.ts +++ b/functions/src/on-update-contract.ts @@ -1,6 +1,6 @@ import * as functions from 'firebase-functions' import { getUser } from './utils' -import { createNotification } from './create-notification' +import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification' import { Contract } from '../../common/contract' export const onUpdateContract = functions.firestore @@ -29,14 +29,14 @@ export const onUpdateContract = functions.firestore resolutionText = `${contract.resolutionValue}` } - await createNotification( + await createCommentOrAnswerOrUpdatedContractNotification( contract.id, 'contract', 'resolved', contractUpdater, eventId, resolutionText, - { contract } + contract ) } else if ( previousValue.closeTime !== contract.closeTime || @@ -52,14 +52,14 @@ export const onUpdateContract = functions.firestore sourceText = contract.question } - await createNotification( + await createCommentOrAnswerOrUpdatedContractNotification( contract.id, 'contract', 'updated', contractUpdater, eventId, sourceText, - { contract } + contract ) } }) diff --git a/functions/src/scripts/backfill-contract-followers.ts b/functions/src/scripts/backfill-contract-followers.ts new file mode 100644 index 00000000..65ea9a2c --- /dev/null +++ b/functions/src/scripts/backfill-contract-followers.ts @@ -0,0 +1,74 @@ +import * as admin from 'firebase-admin' + +import { initAdmin } from './script-init' +initAdmin() + +import { getValues } from '../utils' +import { Contract } from 'common/lib/contract' +import { Comment } from 'common/lib/comment' +import { uniq } from 'lodash' +import { Bet } from 'common/lib/bet' +import { + DEV_HOUSE_LIQUIDITY_PROVIDER_ID, + HOUSE_LIQUIDITY_PROVIDER_ID, +} from 'common/lib/antes' + +const firestore = admin.firestore() + +async function backfillContractFollowers() { + console.log('Backfilling contract followers') + const contracts = await getValues( + firestore.collection('contracts').where('isResolved', '==', false) + ) + let count = 0 + for (const contract of contracts) { + const comments = await getValues( + firestore.collection('contracts').doc(contract.id).collection('comments') + ) + const commenterIds = uniq(comments.map((comment) => comment.userId)) + const betsSnap = await firestore + .collection(`contracts/${contract.id}/bets`) + .get() + const bets = betsSnap.docs.map((doc) => doc.data() as Bet) + // filter bets for only users that have an amount invested still + const bettorIds = uniq(bets.map((bet) => bet.userId)) + const liquidityProviders = await firestore + .collection(`contracts/${contract.id}/liquidity`) + .get() + const liquidityProvidersIds = uniq( + liquidityProviders.docs.map((doc) => doc.data().userId) + // exclude free market liquidity provider + ).filter( + (id) => + id !== HOUSE_LIQUIDITY_PROVIDER_ID || + id !== DEV_HOUSE_LIQUIDITY_PROVIDER_ID + ) + const followerIds = uniq([ + ...commenterIds, + ...bettorIds, + ...liquidityProvidersIds, + contract.creatorId, + ]) + for (const followerId of followerIds) { + await firestore + .collection(`contracts/${contract.id}/follows`) + .doc(followerId) + .set({ id: followerId, createdTime: Date.now() }) + } + const followerCount = followerIds.length + await firestore + .collection(`contracts`) + .doc(contract.id) + .update({ followerCount: followerCount }) + count += 1 + if (count % 100 === 0) { + console.log(`${count} contracts processed`) + } + } +} + +if (require.main === module) { + backfillContractFollowers() + .then(() => process.exit()) + .catch(console.log) +} diff --git a/web/components/contract/share-row.tsx b/web/components/contract/share-row.tsx index 9011ff1b..afec20e4 100644 --- a/web/components/contract/share-row.tsx +++ b/web/components/contract/share-row.tsx @@ -1,8 +1,8 @@ import clsx from 'clsx' -import { ShareIcon } from '@heroicons/react/outline' +import { EyeIcon, EyeOffIcon, ShareIcon } from '@heroicons/react/outline' import { Row } from '../layout/row' -import { Contract } from 'web/lib/firebase/contracts' +import { Contract, contracts } from 'web/lib/firebase/contracts' import { useState } from 'react' import { Button } from 'web/components/button' import { CreateChallengeModal } from '../challenges/create-challenge-modal' @@ -10,6 +10,9 @@ import { User } from 'common/user' import { CHALLENGES_ENABLED } from 'common/challenge' import { ShareModal } from './share-modal' import { withTracking } from 'web/lib/service/analytics' +import { collection, deleteDoc, doc } from 'firebase/firestore' +import { users } from 'web/lib/firebase/users' +import { useContractFollows } from 'web/hooks/use-follows' export function ShareRow(props: { contract: Contract @@ -23,6 +26,7 @@ export function ShareRow(props: { const [isOpen, setIsOpen] = useState(false) const [isShareOpen, setShareOpen] = useState(false) + const followers = useContractFollows(contract.id) return ( @@ -62,6 +66,38 @@ export function ShareRow(props: { /> )} + {user && ( + + )} ) } diff --git a/web/hooks/use-follows.ts b/web/hooks/use-follows.ts index 2a8caaea..2b418658 100644 --- a/web/hooks/use-follows.ts +++ b/web/hooks/use-follows.ts @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react' import { listenForFollowers, listenForFollows } from 'web/lib/firebase/users' +import { contracts, listenForContractFollows } from 'web/lib/firebase/contracts' export const useFollows = (userId: string | null | undefined) => { const [followIds, setFollowIds] = useState() @@ -29,3 +30,13 @@ export const useFollowers = (userId: string | undefined) => { return followerIds } + +export const useContractFollows = (contractId: string) => { + const [followIds, setFollowIds] = useState() + + useEffect(() => { + return listenForContractFollows(contractId, setFollowIds) + }, [contractId]) + + return followIds +} diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index 9fe1e59c..efec3afd 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -23,6 +23,7 @@ import { Bet } from 'common/bet' import { Comment } from 'common/comment' import { ENV_CONFIG } from 'common/envs/constants' import { getBinaryProb } from 'common/contract-details' +import { users } from 'web/lib/firebase/users' export const contracts = coll('contracts') @@ -212,6 +213,16 @@ export function listenForContract( return listenForValue(contractRef, setContract) } +export function listenForContractFollows( + contractId: string, + setFollowIds: (followIds: string[]) => void +) { + const follows = collection(contracts, contractId, 'follows') + return listenForValues<{ id: string }>(follows, (docs) => + setFollowIds(docs.map(({ id }) => id)) + ) +} + function chooseRandomSubset(contracts: Contract[], count: number) { const fiveMinutes = 5 * 60 * 1000 const seed = Math.round(Date.now() / fiveMinutes).toString()