From 54f7b740dc5229346958384a60e8a5329374ac4e Mon Sep 17 00:00:00 2001 From: Ian Philips Date: Wed, 24 Aug 2022 10:49:53 -0600 Subject: [PATCH] Allow to follow/unfollow markets, backfill as well (#794) * Allow to follow/unfollow markets, backfill as well * remove yarn script edit * add decrement comment * Lint * Decrement follow count on unfollow * Follow/unfollow button logic * Unfollow/follow => heart * Add user to followers in place-bet and sell-shares * Add tracking * Show contract follow modal for first time following * Increment follower count as well * Remove add follow from bet trigger * restore on-create-bet * Add pubsub to dev.sh, show heart on FR, remove from answer trigger --- common/contract.ts | 1 + common/notification.ts | 1 + common/user.ts | 1 + dev.sh | 2 +- firestore.rules | 7 +- functions/package.json | 2 +- functions/src/create-notification.ts | 440 +++++++++++------- functions/src/follow-market.ts | 36 ++ functions/src/index.ts | 1 + functions/src/on-create-answer.ts | 7 +- .../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-follow.ts | 45 ++ functions/src/on-update-contract.ts | 10 +- functions/src/place-bet.ts | 3 + .../scripts/backfill-contract-followers.ts | 75 +++ functions/src/sell-shares.ts | 6 +- web/components/NotificationSettings.tsx | 82 ++-- web/components/contract/contract-overview.tsx | 69 +-- .../contract/follow-market-modal.tsx | 33 ++ web/components/follow-market-button.tsx | 76 +++ web/hooks/use-follows.ts | 11 + web/hooks/use-notifications.ts | 1 + web/lib/firebase/contracts.ts | 23 + 25 files changed, 719 insertions(+), 235 deletions(-) create mode 100644 functions/src/follow-market.ts create mode 100644 functions/src/on-update-contract-follow.ts create mode 100644 functions/src/scripts/backfill-contract-followers.ts create mode 100644 web/components/contract/follow-market-modal.tsx create mode 100644 web/components/follow-market-button.tsx 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/common/user.ts b/common/user.ts index 9927a3d3..b278300c 100644 --- a/common/user.ts +++ b/common/user.ts @@ -42,6 +42,7 @@ export type User = { shouldShowWelcome?: boolean lastBetTime?: number currentBettingStreak?: number + hasSeenContractFollowModal?: boolean } export type PrivateUser = { diff --git a/dev.sh b/dev.sh index ca3246ac..d392646e 100755 --- a/dev.sh +++ b/dev.sh @@ -24,7 +24,7 @@ then npx concurrently \ -n FIRESTORE,FUNCTIONS,NEXT,TS \ -c green,white,magenta,cyan \ - "yarn --cwd=functions firestore" \ + "yarn --cwd=functions localDbScript" \ "cross-env FIRESTORE_EMULATOR_HOST=localhost:8080 yarn --cwd=functions dev" \ "cross-env NEXT_PUBLIC_FUNCTIONS_URL=http://localhost:8088 \ NEXT_PUBLIC_FIREBASE_EMULATE=TRUE \ diff --git a/firestore.rules b/firestore.rules index b28ac6a5..0e5a759b 100644 --- a/firestore.rules +++ b/firestore.rules @@ -23,7 +23,7 @@ service cloud.firestore { allow read; allow update: if userId == request.auth.uid && request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome']); + .hasOnly(['bio', 'bannerUrl', 'website', 'twitterHandle', 'discordHandle', 'followedCategories', 'lastPingTime','shouldShowWelcome', 'hasSeenContractFollowModal']); // User referral rules allow update: if userId == request.auth.uid && request.resource.data.diff(resource.data).affectedKeys() @@ -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..c8f295fc 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", + "localDbScript": "firebase emulators:start --only functions,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..3fc05120 --- /dev/null +++ b/functions/src/follow-market.ts @@ -0,0 +1,36 @@ +import * as admin from 'firebase-admin' + +const firestore = admin.firestore() + +export const addUserToContractFollowers = async ( + contractId: string, + userId: string +) => { + const followerDoc = await firestore + .collection(`contracts/${contractId}/follows`) + .doc(userId) + .get() + if (followerDoc.exists) return + await firestore + .collection(`contracts/${contractId}/follows`) + .doc(userId) + .set({ + id: userId, + createdTime: Date.now(), + }) +} + +export const removeUserFromContractFollowers = async ( + contractId: string, + userId: string +) => { + const followerDoc = await firestore + .collection(`contracts/${contractId}/follows`) + .doc(userId) + .get() + if (!followerDoc.exists) return + await firestore + .collection(`contracts/${contractId}/follows`) + .doc(userId) + .delete() +} diff --git a/functions/src/index.ts b/functions/src/index.ts index 26a1ddf6..012ba241 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -30,6 +30,7 @@ export * from './score-contracts' export * from './weekly-markets-emails' export * from './reset-betting-streaks' export * from './reset-weekly-emails-flag' +export * from './on-update-contract-follow' // v2 export * from './health' diff --git a/functions/src/on-create-answer.ts b/functions/src/on-create-answer.ts index 6af5e699..611bf23b 100644 --- a/functions/src/on-create-answer.ts +++ b/functions/src/on-create-answer.ts @@ -1,6 +1,6 @@ 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' export const onCreateAnswer = functions.firestore @@ -20,14 +20,13 @@ export const onCreateAnswer = functions.firestore const answerCreator = await getUser(answer.userId) if (!answerCreator) throw new Error('Could not find answer creator') - - await createNotification( + await createCommentOrAnswerOrUpdatedContractNotification( answer.id, 'answer', 'created', answerCreator, eventId, answer.text, - { contract } + contract ) }) diff --git a/functions/src/on-create-comment-on-contract.ts b/functions/src/on-create-comment-on-contract.ts index 9f19dfcc..8651bde0 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.id, commentCreator.id) + 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..d9826f6c 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.id, contractCreator.id) await createNotification( contract.id, diff --git a/functions/src/on-create-liquidity-provision.ts b/functions/src/on-create-liquidity-provision.ts index 6ec092a5..56a01bbb 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.id, liquidityProvider.id) await createNotification( contract.id, diff --git a/functions/src/on-update-contract-follow.ts b/functions/src/on-update-contract-follow.ts new file mode 100644 index 00000000..f7d54fe8 --- /dev/null +++ b/functions/src/on-update-contract-follow.ts @@ -0,0 +1,45 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' +import { FieldValue } from 'firebase-admin/firestore' + +export const onDeleteContractFollow = functions.firestore + .document('contracts/{contractId}/follows/{userId}') + .onDelete(async (change, context) => { + const { contractId } = context.params as { + contractId: string + } + const firestore = admin.firestore() + const contract = await firestore + .collection(`contracts`) + .doc(contractId) + .get() + if (!contract.exists) throw new Error('Could not find contract') + + await firestore + .collection(`contracts`) + .doc(contractId) + .update({ + followerCount: FieldValue.increment(-1), + }) + }) + +export const onCreateContractFollow = functions.firestore + .document('contracts/{contractId}/follows/{userId}') + .onCreate(async (change, context) => { + const { contractId } = context.params as { + contractId: string + } + const firestore = admin.firestore() + const contract = await firestore + .collection(`contracts`) + .doc(contractId) + .get() + if (!contract.exists) throw new Error('Could not find contract') + + await firestore + .collection(`contracts`) + .doc(contractId) + .update({ + followerCount: FieldValue.increment(1), + }) + }) 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/place-bet.ts b/functions/src/place-bet.ts index 44a96210..237019a4 100644 --- a/functions/src/place-bet.ts +++ b/functions/src/place-bet.ts @@ -22,6 +22,7 @@ import { LimitBet } from '../../common/bet' import { floatingEqual } from '../../common/util/math' import { redeemShares } from './redeem-shares' import { log } from './utils' +import { addUserToContractFollowers } from 'functions/src/follow-market' const bodySchema = z.object({ contractId: z.string(), @@ -167,6 +168,8 @@ export const placebet = newEndpoint({}, async (req, auth) => { return { betId: betDoc.id, makers, newBet } }) + await addUserToContractFollowers(contractId, auth.uid) + log('Main transaction finished.') if (result.newBet.amount !== 0) { diff --git a/functions/src/scripts/backfill-contract-followers.ts b/functions/src/scripts/backfill-contract-followers.ts new file mode 100644 index 00000000..9b936654 --- /dev/null +++ b/functions/src/scripts/backfill-contract-followers.ts @@ -0,0 +1,75 @@ +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() }) + } + // Perhaps handled by the trigger? + // 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/functions/src/sell-shares.ts b/functions/src/sell-shares.ts index d9f99de3..0e669f39 100644 --- a/functions/src/sell-shares.ts +++ b/functions/src/sell-shares.ts @@ -13,6 +13,7 @@ import { floatingEqual, floatingLesserEqual } from '../../common/util/math' import { getUnfilledBetsQuery, updateMakers } from './place-bet' import { FieldValue } from 'firebase-admin/firestore' import { redeemShares } from './redeem-shares' +import { removeUserFromContractFollowers } from 'functions/src/follow-market' const bodySchema = z.object({ contractId: z.string(), @@ -123,9 +124,12 @@ export const sellshares = newEndpoint({}, async (req, auth) => { }) ) - return { newBet, makers } + return { newBet, makers, maxShares, soldShares } }) + if (result.maxShares === result.soldShares) { + await removeUserFromContractFollowers(contractId, auth.uid) + } const userIds = uniq(result.makers.map((maker) => maker.bet.userId)) await Promise.all(userIds.map((userId) => redeemShares(userId, contractId))) log('Share redemption transaction finished.') diff --git a/web/components/NotificationSettings.tsx b/web/components/NotificationSettings.tsx index 7a839a7a..6d8aa25f 100644 --- a/web/components/NotificationSettings.tsx +++ b/web/components/NotificationSettings.tsx @@ -9,6 +9,8 @@ import { Row } from 'web/components/layout/row' import clsx from 'clsx' import { CheckIcon, XIcon } from '@heroicons/react/outline' import { ChoicesToggleGroup } from 'web/components/choices-toggle-group' +import { Col } from 'web/components/layout/col' +import { FollowMarketModal } from 'web/components/contract/follow-market-modal' export function NotificationSettings() { const user = useUser() @@ -17,6 +19,7 @@ export function NotificationSettings() { const [emailNotificationSettings, setEmailNotificationSettings] = useState('all') const [privateUser, setPrivateUser] = useState(null) + const [showModal, setShowModal] = useState(false) useEffect(() => { if (user) listenForPrivateUser(user.id, setPrivateUser) @@ -121,12 +124,20 @@ export function NotificationSettings() { } function NotificationSettingLine(props: { - label: string + label: string | React.ReactNode highlight: boolean + onClick?: () => void }) { - const { label, highlight } = props + const { label, highlight, onClick } = props return ( - + {highlight ? : } {label} @@ -148,31 +159,45 @@ export function NotificationSettings() { toggleClassName={'w-24'} />
-
-
- You will receive notifications for: - - - - - -
-
+ + + You will receive notifications for these general events: + + + + You will receive new comment, answer, & resolution notifications on + questions: + + + That you follow - you + auto-follow questions if: + + } + onClick={() => setShowModal(true)} + /> + + • You create it + • You bet, comment on, or answer it + • You add liquidity to it + + • If you select 'Less' and you've commented on or answered a + question, you'll only receive notification on direct replies to + your comments or answers + + +
Email Notifications
+ ) } diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index bba30776..2aa2d6df 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -22,6 +22,7 @@ import { ContractDescription } from './contract-description' import { ContractDetails } from './contract-details' import { NumericGraph } from './numeric-graph' import { ShareRow } from './share-row' +import { FollowMarketButton } from 'web/components/follow-market-button' export const ContractOverview = (props: { contract: Contract @@ -44,47 +45,57 @@ export const ContractOverview = (props: {
+ {(outcomeType === 'FREE_RESPONSE' || + outcomeType === 'MULTIPLE_CHOICE') && + !resolution && ( +
+ +
+ )} + + - {isBinary && ( - - )} + {isBinary && ( + + )} - {isPseudoNumeric && ( - - )} + {isPseudoNumeric && ( + + )} - {outcomeType === 'NUMERIC' && ( - - )} + {outcomeType === 'NUMERIC' && ( + + )} +
{isBinary ? ( - - {tradingAllowed(contract) && ( - - )} + + + {tradingAllowed(contract) && ( + + )} + ) : isPseudoNumeric ? ( - {tradingAllowed(contract) && } - - ) : isPseudoNumeric ? ( - - - {tradingAllowed(contract) && } + + + {tradingAllowed(contract) && } + ) : ( (outcomeType === 'FREE_RESPONSE' || diff --git a/web/components/contract/follow-market-modal.tsx b/web/components/contract/follow-market-modal.tsx new file mode 100644 index 00000000..3dfb7ff4 --- /dev/null +++ b/web/components/contract/follow-market-modal.tsx @@ -0,0 +1,33 @@ +import { Col } from 'web/components/layout/col' +import { Modal } from 'web/components/layout/modal' +import React from 'react' + +export const FollowMarketModal = (props: { + open: boolean + setOpen: (b: boolean) => void + title?: string +}) => { + const { open, setOpen, title } = props + return ( + + + ❤️ + {title ? title : 'Following questions'} + + • What is following? + + You can receive notifications on questions you're interested in by + clicking the ❤️ button on a question. + + + • What types of notifications will I receive? + + + You'll receive in-app notifications for new comments, answers, and + updates to the question. + + + + + ) +} diff --git a/web/components/follow-market-button.tsx b/web/components/follow-market-button.tsx new file mode 100644 index 00000000..0a8ff4b4 --- /dev/null +++ b/web/components/follow-market-button.tsx @@ -0,0 +1,76 @@ +import { Button } from 'web/components/button' +import { + Contract, + followContract, + unFollowContract, +} from 'web/lib/firebase/contracts' +import toast from 'react-hot-toast' +import { CheckIcon, HeartIcon } from '@heroicons/react/outline' +import clsx from 'clsx' +import { User } from 'common/user' +import { useContractFollows } from 'web/hooks/use-follows' +import { firebaseLogin, updateUser } from 'web/lib/firebase/users' +import { track } from 'web/lib/service/analytics' +import { FollowMarketModal } from 'web/components/contract/follow-market-modal' +import { useState } from 'react' + +export const FollowMarketButton = (props: { + contract: Contract + user: User | undefined | null +}) => { + const { contract, user } = props + const followers = useContractFollows(contract.id) + const [open, setOpen] = useState(false) + + return ( + + ) +} 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/hooks/use-notifications.ts b/web/hooks/use-notifications.ts index ecc4ce2a..32500943 100644 --- a/web/hooks/use-notifications.ts +++ b/web/hooks/use-notifications.ts @@ -147,6 +147,7 @@ export function useUnseenPreferredNotifications( const lessPriorityReasons = [ 'on_contract_with_users_comment', 'on_contract_with_users_answer', + // Notifications not currently generated for users who've sold their shares 'on_contract_with_users_shares_out', // Not sure if users will want to see these w/ less: // 'on_contract_with_users_shares_in', diff --git a/web/lib/firebase/contracts.ts b/web/lib/firebase/contracts.ts index 9fe1e59c..fc205b6a 100644 --- a/web/lib/firebase/contracts.ts +++ b/web/lib/firebase/contracts.ts @@ -212,6 +212,29 @@ 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)) + ) +} + +export async function followContract(contractId: string, userId: string) { + const followDoc = doc(collection(contracts, contractId, 'follows'), userId) + return await setDoc(followDoc, { + id: userId, + createdTime: Date.now(), + }) +} + +export async function unFollowContract(contractId: string, userId: string) { + const followDoc = doc(collection(contracts, contractId, 'follows'), userId) + await deleteDoc(followDoc) +} + function chooseRandomSubset(contracts: Contract[], count: number) { const fiveMinutes = 5 * 60 * 1000 const seed = Math.round(Date.now() / fiveMinutes).toString()