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
This commit is contained in:
Ian Philips 2022-08-24 10:49:53 -06:00 committed by GitHub
parent 78780a9219
commit f50b4775a1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 719 additions and 235 deletions

View File

@ -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

View File

@ -70,3 +70,4 @@ export type notification_reason_types =
| 'challenge_accepted'
| 'betting_streak_incremented'
| 'loan_income'
| 'you_follow_contract'

View File

@ -42,6 +42,7 @@ export type User = {
shouldShowWelcome?: boolean
lastBetTime?: number
currentBettingStreak?: number
hasSeenContractFollowModal?: boolean
}
export type PrivateUser = {

2
dev.sh
View File

@ -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 \

View File

@ -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;

View File

@ -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",

View File

@ -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,9 +159,9 @@ 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 (
@ -277,47 +170,278 @@ export const createNotification = async (
recipients
) {
recipients.forEach((r) => notifyUserAddedToGroup(userToReasonTexts, r))
}
// The following functions need sourceContract to be defined.
if (!sourceContract) return userToReasonTexts
if (
sourceType === 'comment' ||
sourceType === 'answer' ||
(sourceType === 'contract' &&
(sourceUpdateType === 'updated' || sourceUpdateType === 'resolved'))
} else if (
sourceType === 'contract' &&
sourceUpdateType === 'created' &&
sourceContract
) {
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') {
} else if (
sourceType === 'contract' &&
sourceUpdateType === 'closed' &&
sourceContract
) {
await notifyContractCreator(userToReasonTexts, sourceContract, {
force: true,
})
} else if (sourceType === 'liquidity' && sourceUpdateType === 'created') {
} else if (
sourceType === 'liquidity' &&
sourceUpdateType === 'created' &&
sourceContract
) {
await notifyContractCreator(userToReasonTexts, sourceContract)
} else if (sourceType === 'bonus' && sourceUpdateType === 'created') {
} else if (
sourceType === 'bonus' &&
sourceUpdateType === 'created' &&
sourceContract
) {
// Note: the daily bonus won't have a contract attached to it
await notifyContractCreatorOfUniqueBettorsBonus(
userToReasonTexts,
sourceContract.creatorId
)
}
return userToReasonTexts
await createUsersNotifications(userToReasonTexts)
}
const userToReasonTexts = await getUsersToNotify()
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)
}

View File

@ -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()
}

View File

@ -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'

View File

@ -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
)
})

View File

@ -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([

View File

@ -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,

View File

@ -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,

View File

@ -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),
})
})

View File

@ -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
)
}
})

View File

@ -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) {

View File

@ -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)
}

View File

@ -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.')

View File

@ -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"}
/>
<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
label={"Activity on questions you've ever bet or commented on"}
highlight={notificationSettings === 'all'}
highlight={notificationSettings !== 'none'}
label={
<span>
That <span className={'font-bold'}>you follow </span>- you
auto-follow questions if:
</span>
}
onClick={() => setShowModal(true)}
/>
</div>
</div>
<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>
)
}

View File

@ -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,10 +45,19 @@ 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"
className="items-end"
contract={contract}
large
/>
@ -56,36 +66,37 @@ export const ContractOverview = (props: {
{isPseudoNumeric && (
<PseudoNumericResolutionOrExpectation
contract={contract}
className="hidden items-end xl:flex"
className="items-end"
/>
)}
{outcomeType === 'NUMERIC' && (
<NumericResolutionOrExpectation
contract={contract}
className="hidden items-end xl:flex"
className="items-end"
/>
)}
</Row>
</Row>
{isBinary ? (
<Row className="items-center justify-between gap-4 xl:hidden">
<BinaryResolutionOrChance contract={contract} />
<Row className={'items-start gap-2'}>
<FollowMarketButton contract={contract} user={user} />
{tradingAllowed(contract) && (
<BetButton contract={contract as CPMMBinaryContract} />
)}
</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} />
<Row className={'items-start gap-2'}>
<FollowMarketButton contract={contract} user={user} />
{tradingAllowed(contract) && <BetButton contract={contract} />}
</Row>
</Row>
) : (
(outcomeType === 'FREE_RESPONSE' ||
outcomeType === 'MULTIPLE_CHOICE') &&

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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
}

View File

@ -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',

View File

@ -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()