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<T extends AnyContractType = AnyContractType> = { 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<Answer>( - 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<Comment>( - 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<Answer>( + 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<Comment>( + 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<Contract>( + firestore.collection('contracts').where('isResolved', '==', false) + ) + let count = 0 + for (const contract of contracts) { + const comments = await getValues<Comment>( + 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<notification_subscribe_types>('all') const [privateUser, setPrivateUser] = useState<PrivateUser | null>(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 ( - <Row className={clsx('my-1 text-gray-300', highlight && '!text-black')}> + <Row + className={clsx( + 'my-1 gap-1 text-gray-300', + highlight && '!text-black', + onClick ? 'cursor-pointer' : '' + )} + onClick={onClick} + > {highlight ? <CheckIcon height={20} /> : <XIcon height={20} />} {label} </Row> @@ -148,31 +159,45 @@ export function NotificationSettings() { toggleClassName={'w-24'} /> <div className={'mt-4 text-sm'}> - <div> - <div className={''}> - You will receive notifications for: - <NotificationSettingLine - label={"Resolution of questions you've interacted with"} - highlight={notificationSettings !== 'none'} - /> - <NotificationSettingLine - highlight={notificationSettings !== 'none'} - label={'Activity on your own questions, comments, & answers'} - /> - <NotificationSettingLine - highlight={notificationSettings !== 'none'} - label={"Activity on questions you're betting on"} - /> - <NotificationSettingLine - highlight={notificationSettings !== 'none'} - label={"Income & referral bonuses you've received"} - /> - <NotificationSettingLine - label={"Activity on questions you've ever bet or commented on"} - highlight={notificationSettings === 'all'} - /> - </div> - </div> + <Col className={''}> + <Row className={'my-1'}> + You will receive notifications for these general events: + </Row> + <NotificationSettingLine + highlight={notificationSettings !== 'none'} + label={"Income & referral bonuses you've received"} + /> + <Row className={'my-1'}> + You will receive new comment, answer, & resolution notifications on + questions: + </Row> + <NotificationSettingLine + highlight={notificationSettings !== 'none'} + label={ + <span> + That <span className={'font-bold'}>you follow </span>- you + auto-follow questions if: + </span> + } + onClick={() => setShowModal(true)} + /> + <Col + className={clsx( + 'mb-2 ml-8', + 'gap-1 text-gray-300', + notificationSettings !== 'none' && '!text-black' + )} + > + <Row>• You create it</Row> + <Row>• You bet, comment on, or answer it</Row> + <Row>• You add liquidity to it</Row> + <Row> + • 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 + </Row> + </Col> + </Col> </div> <div className={'mt-4'}>Email Notifications</div> <ChoicesToggleGroup @@ -205,6 +230,7 @@ export function NotificationSettings() { /> </div> </div> + <FollowMarketModal setOpen={setShowModal} open={showModal} /> </div> ) } 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: { <div className="text-2xl text-indigo-700 md:text-3xl"> <Linkify text={question} /> </div> + {(outcomeType === 'FREE_RESPONSE' || + outcomeType === 'MULTIPLE_CHOICE') && + !resolution && ( + <div className={'xl:hidden'}> + <FollowMarketButton contract={contract} user={user} /> + </div> + )} + <Row className={'hidden gap-3 xl:flex'}> + <FollowMarketButton contract={contract} user={user} /> - {isBinary && ( - <BinaryResolutionOrChance - className="hidden items-end xl:flex" - contract={contract} - large - /> - )} + {isBinary && ( + <BinaryResolutionOrChance + className="items-end" + contract={contract} + large + /> + )} - {isPseudoNumeric && ( - <PseudoNumericResolutionOrExpectation - contract={contract} - className="hidden items-end xl:flex" - /> - )} + {isPseudoNumeric && ( + <PseudoNumericResolutionOrExpectation + contract={contract} + className="items-end" + /> + )} - {outcomeType === 'NUMERIC' && ( - <NumericResolutionOrExpectation - contract={contract} - className="hidden items-end xl:flex" - /> - )} + {outcomeType === 'NUMERIC' && ( + <NumericResolutionOrExpectation + contract={contract} + className="items-end" + /> + )} + </Row> </Row> {isBinary ? ( <Row className="items-center justify-between gap-4 xl:hidden"> <BinaryResolutionOrChance contract={contract} /> - - {tradingAllowed(contract) && ( - <BetButton contract={contract as CPMMBinaryContract} /> - )} + <Row className={'items-start gap-2'}> + <FollowMarketButton contract={contract} user={user} /> + {tradingAllowed(contract) && ( + <BetButton contract={contract as CPMMBinaryContract} /> + )} + </Row> </Row> ) : isPseudoNumeric ? ( <Row className="items-center justify-between gap-4 xl:hidden"> <PseudoNumericResolutionOrExpectation contract={contract} /> - {tradingAllowed(contract) && <BetButton contract={contract} />} - </Row> - ) : isPseudoNumeric ? ( - <Row className="items-center justify-between gap-4 xl:hidden"> - <PseudoNumericResolutionOrExpectation contract={contract} /> - {tradingAllowed(contract) && <BetButton contract={contract} />} + <Row className={'items-start gap-2'}> + <FollowMarketButton contract={contract} user={user} /> + {tradingAllowed(contract) && <BetButton contract={contract} />} + </Row> </Row> ) : ( (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 ( + <Modal open={open} setOpen={setOpen}> + <Col className="items-center gap-4 rounded-md bg-white px-8 py-6"> + <span className={'text-8xl'}>❤️</span> + <span className="text-xl">{title ? title : 'Following questions'}</span> + <Col className={'gap-2'}> + <span className={'text-indigo-700'}>• What is following?</span> + <span className={'ml-2'}> + You can receive notifications on questions you're interested in by + clicking the ❤️ button on a question. + </span> + <span className={'text-indigo-700'}> + • What types of notifications will I receive? + </span> + <span className={'ml-2'}> + You'll receive in-app notifications for new comments, answers, and + updates to the question. + </span> + </Col> + </Col> + </Modal> + ) +} 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 ( + <Button + size={'lg'} + color={'gray-white'} + onClick={async () => { + if (!user) return firebaseLogin() + if (followers?.includes(user.id)) { + await unFollowContract(contract.id, user.id) + toast('Notifications from this market are now silenced.', { + icon: <CheckIcon className={'text-primary h-5 w-5'} />, + }) + track('Unfollow Market', { + slug: contract.slug, + }) + } else { + await followContract(contract.id, user.id) + toast('You are now following this market!', { + icon: <CheckIcon className={'text-primary h-5 w-5'} />, + }) + track('Follow Market', { + slug: contract.slug, + }) + } + if (!user.hasSeenContractFollowModal) { + await updateUser(user.id, { + hasSeenContractFollowModal: true, + }) + setOpen(true) + } + }} + > + {followers?.includes(user?.id ?? 'nope') ? ( + <HeartIcon + className={clsx('h-6 w-6 fill-red-600 stroke-red-600 xl:h-7 xl:w-7')} + aria-hidden="true" + /> + ) : ( + <HeartIcon + className={clsx('h-6 w-6 xl:h-7 xl:w-7')} + aria-hidden="true" + /> + )} + <FollowMarketModal + open={open} + setOpen={setOpen} + title={`You ${ + followers?.includes(user?.id ?? 'nope') ? 'followed' : 'unfollowed' + } a question!`} + /> + </Button> + ) +} 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<string[] | undefined>() @@ -29,3 +30,13 @@ export const useFollowers = (userId: string | undefined) => { return followerIds } + +export const useContractFollows = (contractId: string) => { + const [followIds, setFollowIds] = useState<string[] | undefined>() + + 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<Contract>(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()