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:
parent
78780a9219
commit
f50b4775a1
|
@ -57,6 +57,7 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
|
||||||
uniqueBettorIds?: string[]
|
uniqueBettorIds?: string[]
|
||||||
uniqueBettorCount?: number
|
uniqueBettorCount?: number
|
||||||
popularityScore?: number
|
popularityScore?: number
|
||||||
|
followerCount?: number
|
||||||
} & T
|
} & T
|
||||||
|
|
||||||
export type BinaryContract = Contract & Binary
|
export type BinaryContract = Contract & Binary
|
||||||
|
|
|
@ -70,3 +70,4 @@ export type notification_reason_types =
|
||||||
| 'challenge_accepted'
|
| 'challenge_accepted'
|
||||||
| 'betting_streak_incremented'
|
| 'betting_streak_incremented'
|
||||||
| 'loan_income'
|
| 'loan_income'
|
||||||
|
| 'you_follow_contract'
|
||||||
|
|
|
@ -42,6 +42,7 @@ export type User = {
|
||||||
shouldShowWelcome?: boolean
|
shouldShowWelcome?: boolean
|
||||||
lastBetTime?: number
|
lastBetTime?: number
|
||||||
currentBettingStreak?: number
|
currentBettingStreak?: number
|
||||||
|
hasSeenContractFollowModal?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PrivateUser = {
|
export type PrivateUser = {
|
||||||
|
|
2
dev.sh
2
dev.sh
|
@ -24,7 +24,7 @@ then
|
||||||
npx concurrently \
|
npx concurrently \
|
||||||
-n FIRESTORE,FUNCTIONS,NEXT,TS \
|
-n FIRESTORE,FUNCTIONS,NEXT,TS \
|
||||||
-c green,white,magenta,cyan \
|
-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 FIRESTORE_EMULATOR_HOST=localhost:8080 yarn --cwd=functions dev" \
|
||||||
"cross-env NEXT_PUBLIC_FUNCTIONS_URL=http://localhost:8088 \
|
"cross-env NEXT_PUBLIC_FUNCTIONS_URL=http://localhost:8088 \
|
||||||
NEXT_PUBLIC_FIREBASE_EMULATE=TRUE \
|
NEXT_PUBLIC_FIREBASE_EMULATE=TRUE \
|
||||||
|
|
|
@ -23,7 +23,7 @@ service cloud.firestore {
|
||||||
allow read;
|
allow read;
|
||||||
allow update: if userId == request.auth.uid
|
allow update: if userId == request.auth.uid
|
||||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
&& 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
|
// User referral rules
|
||||||
allow update: if userId == request.auth.uid
|
allow update: if userId == request.auth.uid
|
||||||
&& request.resource.data.diff(resource.data).affectedKeys()
|
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||||
|
@ -44,6 +44,11 @@ service cloud.firestore {
|
||||||
allow read;
|
allow read;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
match /contracts/{contractId}/follows/{userId} {
|
||||||
|
allow read;
|
||||||
|
allow create, delete: if userId == request.auth.uid;
|
||||||
|
}
|
||||||
|
|
||||||
match /contracts/{contractId}/challenges/{challengeId}{
|
match /contracts/{contractId}/challenges/{challengeId}{
|
||||||
allow read;
|
allow read;
|
||||||
allow create: if request.auth.uid == request.resource.data.creatorId;
|
allow create: if request.auth.uid == request.resource.data.creatorId;
|
||||||
|
|
|
@ -13,7 +13,7 @@
|
||||||
"deploy": "firebase deploy --only functions",
|
"deploy": "firebase deploy --only functions",
|
||||||
"logs": "firebase functions:log",
|
"logs": "firebase functions:log",
|
||||||
"dev": "nodemon src/serve.ts",
|
"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",
|
"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: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",
|
"db:backup-local": "firebase emulators:export --force ./firestore_export",
|
||||||
|
|
|
@ -7,7 +7,7 @@ import {
|
||||||
} from '../../common/notification'
|
} from '../../common/notification'
|
||||||
import { User } from '../../common/user'
|
import { User } from '../../common/user'
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
import { getValues } from './utils'
|
import { getValues, log } from './utils'
|
||||||
import { Comment } from '../../common/comment'
|
import { Comment } from '../../common/comment'
|
||||||
import { uniq } from 'lodash'
|
import { uniq } from 'lodash'
|
||||||
import { Bet, LimitBet } from '../../common/bet'
|
import { Bet, LimitBet } from '../../common/bet'
|
||||||
|
@ -33,19 +33,12 @@ export const createNotification = async (
|
||||||
sourceText: string,
|
sourceText: string,
|
||||||
miscData?: {
|
miscData?: {
|
||||||
contract?: Contract
|
contract?: Contract
|
||||||
relatedSourceType?: notification_source_types
|
|
||||||
recipients?: string[]
|
recipients?: string[]
|
||||||
slug?: string
|
slug?: string
|
||||||
title?: string
|
title?: string
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
const {
|
const { contract: sourceContract, recipients, slug, title } = miscData ?? {}
|
||||||
contract: sourceContract,
|
|
||||||
relatedSourceType,
|
|
||||||
recipients,
|
|
||||||
slug,
|
|
||||||
title,
|
|
||||||
} = miscData ?? {}
|
|
||||||
|
|
||||||
const shouldGetNotification = (
|
const shouldGetNotification = (
|
||||||
userId: string,
|
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 (
|
const notifyUsersFollowers = async (
|
||||||
userToReasonTexts: user_to_reason_texts
|
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 = (
|
const notifyFollowedUser = (
|
||||||
userToReasonTexts: user_to_reason_texts,
|
userToReasonTexts: user_to_reason_texts,
|
||||||
followedUserId: string
|
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 = (
|
const notifyUserAddedToGroup = (
|
||||||
userToReasonTexts: user_to_reason_texts,
|
userToReasonTexts: user_to_reason_texts,
|
||||||
relatedUserId: string
|
relatedUserId: string
|
||||||
|
@ -266,58 +159,289 @@ export const createNotification = async (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getUsersToNotify = async () => {
|
const userToReasonTexts: user_to_reason_texts = {}
|
||||||
const userToReasonTexts: user_to_reason_texts = {}
|
// The following functions modify the userToReasonTexts object in place.
|
||||||
// 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))
|
|
||||||
}
|
|
||||||
|
|
||||||
// The following functions need sourceContract to be defined.
|
if (sourceType === 'follow' && recipients?.[0]) {
|
||||||
if (!sourceContract) return userToReasonTexts
|
notifyFollowedUser(userToReasonTexts, recipients[0])
|
||||||
|
} else if (
|
||||||
if (
|
sourceType === 'group' &&
|
||||||
sourceType === 'comment' ||
|
sourceUpdateType === 'created' &&
|
||||||
sourceType === 'answer' ||
|
recipients
|
||||||
(sourceType === 'contract' &&
|
) {
|
||||||
(sourceUpdateType === 'updated' || sourceUpdateType === 'resolved'))
|
recipients.forEach((r) => notifyUserAddedToGroup(userToReasonTexts, r))
|
||||||
) {
|
} else if (
|
||||||
if (sourceType === 'comment') {
|
sourceType === 'contract' &&
|
||||||
if (recipients?.[0] && relatedSourceType)
|
sourceUpdateType === 'created' &&
|
||||||
notifyRepliedUser(userToReasonTexts, recipients[0], relatedSourceType)
|
sourceContract
|
||||||
if (sourceText) notifyTaggedUsers(userToReasonTexts, recipients ?? [])
|
) {
|
||||||
}
|
await notifyUsersFollowers(userToReasonTexts)
|
||||||
await notifyContractCreator(userToReasonTexts, sourceContract)
|
notifyTaggedUsers(userToReasonTexts, recipients ?? [])
|
||||||
await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract)
|
} else if (
|
||||||
await notifyLiquidityProviders(userToReasonTexts, sourceContract)
|
sourceType === 'contract' &&
|
||||||
await notifyBettorsOnContract(userToReasonTexts, sourceContract)
|
sourceUpdateType === 'closed' &&
|
||||||
await notifyOtherCommentersOnContract(userToReasonTexts, sourceContract)
|
sourceContract
|
||||||
} else if (sourceType === 'contract' && sourceUpdateType === 'created') {
|
) {
|
||||||
await notifyUsersFollowers(userToReasonTexts)
|
await notifyContractCreator(userToReasonTexts, sourceContract, {
|
||||||
notifyTaggedUsers(userToReasonTexts, recipients ?? [])
|
force: true,
|
||||||
} else if (sourceType === 'contract' && sourceUpdateType === 'closed') {
|
})
|
||||||
await notifyContractCreator(userToReasonTexts, sourceContract, {
|
} else if (
|
||||||
force: true,
|
sourceType === 'liquidity' &&
|
||||||
})
|
sourceUpdateType === 'created' &&
|
||||||
} else if (sourceType === 'liquidity' && sourceUpdateType === 'created') {
|
sourceContract
|
||||||
await notifyContractCreator(userToReasonTexts, sourceContract)
|
) {
|
||||||
} else if (sourceType === 'bonus' && sourceUpdateType === 'created') {
|
await notifyContractCreator(userToReasonTexts, sourceContract)
|
||||||
// Note: the daily bonus won't have a contract attached to it
|
} else if (
|
||||||
await notifyContractCreatorOfUniqueBettorsBonus(
|
sourceType === 'bonus' &&
|
||||||
userToReasonTexts,
|
sourceUpdateType === 'created' &&
|
||||||
sourceContract.creatorId
|
sourceContract
|
||||||
)
|
) {
|
||||||
}
|
// Note: the daily bonus won't have a contract attached to it
|
||||||
return userToReasonTexts
|
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)
|
await createUsersNotifications(userToReasonTexts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
36
functions/src/follow-market.ts
Normal file
36
functions/src/follow-market.ts
Normal 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()
|
||||||
|
}
|
|
@ -30,6 +30,7 @@ export * from './score-contracts'
|
||||||
export * from './weekly-markets-emails'
|
export * from './weekly-markets-emails'
|
||||||
export * from './reset-betting-streaks'
|
export * from './reset-betting-streaks'
|
||||||
export * from './reset-weekly-emails-flag'
|
export * from './reset-weekly-emails-flag'
|
||||||
|
export * from './on-update-contract-follow'
|
||||||
|
|
||||||
// v2
|
// v2
|
||||||
export * from './health'
|
export * from './health'
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import * as functions from 'firebase-functions'
|
import * as functions from 'firebase-functions'
|
||||||
import { getContract, getUser } from './utils'
|
import { getContract, getUser } from './utils'
|
||||||
import { createNotification } from './create-notification'
|
import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
|
||||||
import { Answer } from '../../common/answer'
|
import { Answer } from '../../common/answer'
|
||||||
|
|
||||||
export const onCreateAnswer = functions.firestore
|
export const onCreateAnswer = functions.firestore
|
||||||
|
@ -20,14 +20,13 @@ export const onCreateAnswer = functions.firestore
|
||||||
|
|
||||||
const answerCreator = await getUser(answer.userId)
|
const answerCreator = await getUser(answer.userId)
|
||||||
if (!answerCreator) throw new Error('Could not find answer creator')
|
if (!answerCreator) throw new Error('Could not find answer creator')
|
||||||
|
await createCommentOrAnswerOrUpdatedContractNotification(
|
||||||
await createNotification(
|
|
||||||
answer.id,
|
answer.id,
|
||||||
'answer',
|
'answer',
|
||||||
'created',
|
'created',
|
||||||
answerCreator,
|
answerCreator,
|
||||||
eventId,
|
eventId,
|
||||||
answer.text,
|
answer.text,
|
||||||
{ contract }
|
contract
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
@ -6,8 +6,9 @@ import { ContractComment } from '../../common/comment'
|
||||||
import { sendNewCommentEmail } from './emails'
|
import { sendNewCommentEmail } from './emails'
|
||||||
import { Bet } from '../../common/bet'
|
import { Bet } from '../../common/bet'
|
||||||
import { Answer } from '../../common/answer'
|
import { Answer } from '../../common/answer'
|
||||||
import { createNotification } from './create-notification'
|
import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
|
||||||
import { parseMentions, richTextToString } from '../../common/util/parse'
|
import { parseMentions, richTextToString } from '../../common/util/parse'
|
||||||
|
import { addUserToContractFollowers } from './follow-market'
|
||||||
|
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
|
@ -35,6 +36,8 @@ export const onCreateCommentOnContract = functions
|
||||||
const commentCreator = await getUser(comment.userId)
|
const commentCreator = await getUser(comment.userId)
|
||||||
if (!commentCreator) throw new Error('Could not find comment creator')
|
if (!commentCreator) throw new Error('Could not find comment creator')
|
||||||
|
|
||||||
|
await addUserToContractFollowers(contract.id, commentCreator.id)
|
||||||
|
|
||||||
await firestore
|
await firestore
|
||||||
.collection('contracts')
|
.collection('contracts')
|
||||||
.doc(contract.id)
|
.doc(contract.id)
|
||||||
|
@ -77,18 +80,19 @@ export const onCreateCommentOnContract = functions
|
||||||
? comments.find((c) => c.id === comment.replyToCommentId)?.userId
|
? comments.find((c) => c.id === comment.replyToCommentId)?.userId
|
||||||
: answer?.userId
|
: answer?.userId
|
||||||
|
|
||||||
const recipients = uniq(
|
await createCommentOrAnswerOrUpdatedContractNotification(
|
||||||
compact([...parseMentions(comment.content), repliedUserId])
|
|
||||||
)
|
|
||||||
|
|
||||||
await createNotification(
|
|
||||||
comment.id,
|
comment.id,
|
||||||
'comment',
|
'comment',
|
||||||
'created',
|
'created',
|
||||||
commentCreator,
|
commentCreator,
|
||||||
eventId,
|
eventId,
|
||||||
richTextToString(comment.content),
|
richTextToString(comment.content),
|
||||||
{ contract, relatedSourceType, recipients }
|
contract,
|
||||||
|
{
|
||||||
|
relatedSourceType,
|
||||||
|
repliedUserId,
|
||||||
|
taggedUserIds: compact(parseMentions(comment.content)),
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const recipientUserIds = uniq([
|
const recipientUserIds = uniq([
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { createNotification } from './create-notification'
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
import { parseMentions, richTextToString } from '../../common/util/parse'
|
import { parseMentions, richTextToString } from '../../common/util/parse'
|
||||||
import { JSONContent } from '@tiptap/core'
|
import { JSONContent } from '@tiptap/core'
|
||||||
|
import { addUserToContractFollowers } from './follow-market'
|
||||||
|
|
||||||
export const onCreateContract = functions
|
export const onCreateContract = functions
|
||||||
.runWith({ secrets: ['MAILGUN_KEY'] })
|
.runWith({ secrets: ['MAILGUN_KEY'] })
|
||||||
|
@ -18,6 +19,7 @@ export const onCreateContract = functions
|
||||||
|
|
||||||
const desc = contract.description as JSONContent
|
const desc = contract.description as JSONContent
|
||||||
const mentioned = parseMentions(desc)
|
const mentioned = parseMentions(desc)
|
||||||
|
await addUserToContractFollowers(contract.id, contractCreator.id)
|
||||||
|
|
||||||
await createNotification(
|
await createNotification(
|
||||||
contract.id,
|
contract.id,
|
||||||
|
|
|
@ -2,6 +2,7 @@ import * as functions from 'firebase-functions'
|
||||||
import { getContract, getUser } from './utils'
|
import { getContract, getUser } from './utils'
|
||||||
import { createNotification } from './create-notification'
|
import { createNotification } from './create-notification'
|
||||||
import { LiquidityProvision } from 'common/liquidity-provision'
|
import { LiquidityProvision } from 'common/liquidity-provision'
|
||||||
|
import { addUserToContractFollowers } from './follow-market'
|
||||||
|
|
||||||
export const onCreateLiquidityProvision = functions.firestore
|
export const onCreateLiquidityProvision = functions.firestore
|
||||||
.document('contracts/{contractId}/liquidity/{liquidityId}')
|
.document('contracts/{contractId}/liquidity/{liquidityId}')
|
||||||
|
@ -18,6 +19,7 @@ export const onCreateLiquidityProvision = functions.firestore
|
||||||
|
|
||||||
const liquidityProvider = await getUser(liquidity.userId)
|
const liquidityProvider = await getUser(liquidity.userId)
|
||||||
if (!liquidityProvider) throw new Error('Could not find liquidity provider')
|
if (!liquidityProvider) throw new Error('Could not find liquidity provider')
|
||||||
|
await addUserToContractFollowers(contract.id, liquidityProvider.id)
|
||||||
|
|
||||||
await createNotification(
|
await createNotification(
|
||||||
contract.id,
|
contract.id,
|
||||||
|
|
45
functions/src/on-update-contract-follow.ts
Normal file
45
functions/src/on-update-contract-follow.ts
Normal 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),
|
||||||
|
})
|
||||||
|
})
|
|
@ -1,6 +1,6 @@
|
||||||
import * as functions from 'firebase-functions'
|
import * as functions from 'firebase-functions'
|
||||||
import { getUser } from './utils'
|
import { getUser } from './utils'
|
||||||
import { createNotification } from './create-notification'
|
import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
|
|
||||||
export const onUpdateContract = functions.firestore
|
export const onUpdateContract = functions.firestore
|
||||||
|
@ -29,14 +29,14 @@ export const onUpdateContract = functions.firestore
|
||||||
resolutionText = `${contract.resolutionValue}`
|
resolutionText = `${contract.resolutionValue}`
|
||||||
}
|
}
|
||||||
|
|
||||||
await createNotification(
|
await createCommentOrAnswerOrUpdatedContractNotification(
|
||||||
contract.id,
|
contract.id,
|
||||||
'contract',
|
'contract',
|
||||||
'resolved',
|
'resolved',
|
||||||
contractUpdater,
|
contractUpdater,
|
||||||
eventId,
|
eventId,
|
||||||
resolutionText,
|
resolutionText,
|
||||||
{ contract }
|
contract
|
||||||
)
|
)
|
||||||
} else if (
|
} else if (
|
||||||
previousValue.closeTime !== contract.closeTime ||
|
previousValue.closeTime !== contract.closeTime ||
|
||||||
|
@ -52,14 +52,14 @@ export const onUpdateContract = functions.firestore
|
||||||
sourceText = contract.question
|
sourceText = contract.question
|
||||||
}
|
}
|
||||||
|
|
||||||
await createNotification(
|
await createCommentOrAnswerOrUpdatedContractNotification(
|
||||||
contract.id,
|
contract.id,
|
||||||
'contract',
|
'contract',
|
||||||
'updated',
|
'updated',
|
||||||
contractUpdater,
|
contractUpdater,
|
||||||
eventId,
|
eventId,
|
||||||
sourceText,
|
sourceText,
|
||||||
{ contract }
|
contract
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -22,6 +22,7 @@ import { LimitBet } from '../../common/bet'
|
||||||
import { floatingEqual } from '../../common/util/math'
|
import { floatingEqual } from '../../common/util/math'
|
||||||
import { redeemShares } from './redeem-shares'
|
import { redeemShares } from './redeem-shares'
|
||||||
import { log } from './utils'
|
import { log } from './utils'
|
||||||
|
import { addUserToContractFollowers } from 'functions/src/follow-market'
|
||||||
|
|
||||||
const bodySchema = z.object({
|
const bodySchema = z.object({
|
||||||
contractId: z.string(),
|
contractId: z.string(),
|
||||||
|
@ -167,6 +168,8 @@ export const placebet = newEndpoint({}, async (req, auth) => {
|
||||||
return { betId: betDoc.id, makers, newBet }
|
return { betId: betDoc.id, makers, newBet }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await addUserToContractFollowers(contractId, auth.uid)
|
||||||
|
|
||||||
log('Main transaction finished.')
|
log('Main transaction finished.')
|
||||||
|
|
||||||
if (result.newBet.amount !== 0) {
|
if (result.newBet.amount !== 0) {
|
||||||
|
|
75
functions/src/scripts/backfill-contract-followers.ts
Normal file
75
functions/src/scripts/backfill-contract-followers.ts
Normal 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)
|
||||||
|
}
|
|
@ -13,6 +13,7 @@ import { floatingEqual, floatingLesserEqual } from '../../common/util/math'
|
||||||
import { getUnfilledBetsQuery, updateMakers } from './place-bet'
|
import { getUnfilledBetsQuery, updateMakers } from './place-bet'
|
||||||
import { FieldValue } from 'firebase-admin/firestore'
|
import { FieldValue } from 'firebase-admin/firestore'
|
||||||
import { redeemShares } from './redeem-shares'
|
import { redeemShares } from './redeem-shares'
|
||||||
|
import { removeUserFromContractFollowers } from 'functions/src/follow-market'
|
||||||
|
|
||||||
const bodySchema = z.object({
|
const bodySchema = z.object({
|
||||||
contractId: z.string(),
|
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))
|
const userIds = uniq(result.makers.map((maker) => maker.bet.userId))
|
||||||
await Promise.all(userIds.map((userId) => redeemShares(userId, contractId)))
|
await Promise.all(userIds.map((userId) => redeemShares(userId, contractId)))
|
||||||
log('Share redemption transaction finished.')
|
log('Share redemption transaction finished.')
|
||||||
|
|
|
@ -9,6 +9,8 @@ import { Row } from 'web/components/layout/row'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { CheckIcon, XIcon } from '@heroicons/react/outline'
|
import { CheckIcon, XIcon } from '@heroicons/react/outline'
|
||||||
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
|
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() {
|
export function NotificationSettings() {
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
|
@ -17,6 +19,7 @@ export function NotificationSettings() {
|
||||||
const [emailNotificationSettings, setEmailNotificationSettings] =
|
const [emailNotificationSettings, setEmailNotificationSettings] =
|
||||||
useState<notification_subscribe_types>('all')
|
useState<notification_subscribe_types>('all')
|
||||||
const [privateUser, setPrivateUser] = useState<PrivateUser | null>(null)
|
const [privateUser, setPrivateUser] = useState<PrivateUser | null>(null)
|
||||||
|
const [showModal, setShowModal] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) listenForPrivateUser(user.id, setPrivateUser)
|
if (user) listenForPrivateUser(user.id, setPrivateUser)
|
||||||
|
@ -121,12 +124,20 @@ export function NotificationSettings() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function NotificationSettingLine(props: {
|
function NotificationSettingLine(props: {
|
||||||
label: string
|
label: string | React.ReactNode
|
||||||
highlight: boolean
|
highlight: boolean
|
||||||
|
onClick?: () => void
|
||||||
}) {
|
}) {
|
||||||
const { label, highlight } = props
|
const { label, highlight, onClick } = props
|
||||||
return (
|
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} />}
|
{highlight ? <CheckIcon height={20} /> : <XIcon height={20} />}
|
||||||
{label}
|
{label}
|
||||||
</Row>
|
</Row>
|
||||||
|
@ -148,31 +159,45 @@ export function NotificationSettings() {
|
||||||
toggleClassName={'w-24'}
|
toggleClassName={'w-24'}
|
||||||
/>
|
/>
|
||||||
<div className={'mt-4 text-sm'}>
|
<div className={'mt-4 text-sm'}>
|
||||||
<div>
|
<Col className={''}>
|
||||||
<div className={''}>
|
<Row className={'my-1'}>
|
||||||
You will receive notifications for:
|
You will receive notifications for these general events:
|
||||||
<NotificationSettingLine
|
</Row>
|
||||||
label={"Resolution of questions you've interacted with"}
|
<NotificationSettingLine
|
||||||
highlight={notificationSettings !== 'none'}
|
highlight={notificationSettings !== 'none'}
|
||||||
/>
|
label={"Income & referral bonuses you've received"}
|
||||||
<NotificationSettingLine
|
/>
|
||||||
highlight={notificationSettings !== 'none'}
|
<Row className={'my-1'}>
|
||||||
label={'Activity on your own questions, comments, & answers'}
|
You will receive new comment, answer, & resolution notifications on
|
||||||
/>
|
questions:
|
||||||
<NotificationSettingLine
|
</Row>
|
||||||
highlight={notificationSettings !== 'none'}
|
<NotificationSettingLine
|
||||||
label={"Activity on questions you're betting on"}
|
highlight={notificationSettings !== 'none'}
|
||||||
/>
|
label={
|
||||||
<NotificationSettingLine
|
<span>
|
||||||
highlight={notificationSettings !== 'none'}
|
That <span className={'font-bold'}>you follow </span>- you
|
||||||
label={"Income & referral bonuses you've received"}
|
auto-follow questions if:
|
||||||
/>
|
</span>
|
||||||
<NotificationSettingLine
|
}
|
||||||
label={"Activity on questions you've ever bet or commented on"}
|
onClick={() => setShowModal(true)}
|
||||||
highlight={notificationSettings === 'all'}
|
/>
|
||||||
/>
|
<Col
|
||||||
</div>
|
className={clsx(
|
||||||
</div>
|
'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>
|
||||||
<div className={'mt-4'}>Email Notifications</div>
|
<div className={'mt-4'}>Email Notifications</div>
|
||||||
<ChoicesToggleGroup
|
<ChoicesToggleGroup
|
||||||
|
@ -205,6 +230,7 @@ export function NotificationSettings() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<FollowMarketModal setOpen={setShowModal} open={showModal} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,6 +22,7 @@ import { ContractDescription } from './contract-description'
|
||||||
import { ContractDetails } from './contract-details'
|
import { ContractDetails } from './contract-details'
|
||||||
import { NumericGraph } from './numeric-graph'
|
import { NumericGraph } from './numeric-graph'
|
||||||
import { ShareRow } from './share-row'
|
import { ShareRow } from './share-row'
|
||||||
|
import { FollowMarketButton } from 'web/components/follow-market-button'
|
||||||
|
|
||||||
export const ContractOverview = (props: {
|
export const ContractOverview = (props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
|
@ -44,47 +45,57 @@ export const ContractOverview = (props: {
|
||||||
<div className="text-2xl text-indigo-700 md:text-3xl">
|
<div className="text-2xl text-indigo-700 md:text-3xl">
|
||||||
<Linkify text={question} />
|
<Linkify text={question} />
|
||||||
</div>
|
</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 && (
|
{isBinary && (
|
||||||
<BinaryResolutionOrChance
|
<BinaryResolutionOrChance
|
||||||
className="hidden items-end xl:flex"
|
className="items-end"
|
||||||
contract={contract}
|
contract={contract}
|
||||||
large
|
large
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isPseudoNumeric && (
|
{isPseudoNumeric && (
|
||||||
<PseudoNumericResolutionOrExpectation
|
<PseudoNumericResolutionOrExpectation
|
||||||
contract={contract}
|
contract={contract}
|
||||||
className="hidden items-end xl:flex"
|
className="items-end"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{outcomeType === 'NUMERIC' && (
|
{outcomeType === 'NUMERIC' && (
|
||||||
<NumericResolutionOrExpectation
|
<NumericResolutionOrExpectation
|
||||||
contract={contract}
|
contract={contract}
|
||||||
className="hidden items-end xl:flex"
|
className="items-end"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</Row>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
{isBinary ? (
|
{isBinary ? (
|
||||||
<Row className="items-center justify-between gap-4 xl:hidden">
|
<Row className="items-center justify-between gap-4 xl:hidden">
|
||||||
<BinaryResolutionOrChance contract={contract} />
|
<BinaryResolutionOrChance contract={contract} />
|
||||||
|
<Row className={'items-start gap-2'}>
|
||||||
{tradingAllowed(contract) && (
|
<FollowMarketButton contract={contract} user={user} />
|
||||||
<BetButton contract={contract as CPMMBinaryContract} />
|
{tradingAllowed(contract) && (
|
||||||
)}
|
<BetButton contract={contract as CPMMBinaryContract} />
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
</Row>
|
</Row>
|
||||||
) : isPseudoNumeric ? (
|
) : isPseudoNumeric ? (
|
||||||
<Row className="items-center justify-between gap-4 xl:hidden">
|
<Row className="items-center justify-between gap-4 xl:hidden">
|
||||||
<PseudoNumericResolutionOrExpectation contract={contract} />
|
<PseudoNumericResolutionOrExpectation contract={contract} />
|
||||||
{tradingAllowed(contract) && <BetButton contract={contract} />}
|
<Row className={'items-start gap-2'}>
|
||||||
</Row>
|
<FollowMarketButton contract={contract} user={user} />
|
||||||
) : isPseudoNumeric ? (
|
{tradingAllowed(contract) && <BetButton contract={contract} />}
|
||||||
<Row className="items-center justify-between gap-4 xl:hidden">
|
</Row>
|
||||||
<PseudoNumericResolutionOrExpectation contract={contract} />
|
|
||||||
{tradingAllowed(contract) && <BetButton contract={contract} />}
|
|
||||||
</Row>
|
</Row>
|
||||||
) : (
|
) : (
|
||||||
(outcomeType === 'FREE_RESPONSE' ||
|
(outcomeType === 'FREE_RESPONSE' ||
|
||||||
|
|
33
web/components/contract/follow-market-modal.tsx
Normal file
33
web/components/contract/follow-market-modal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
76
web/components/follow-market-button.tsx
Normal file
76
web/components/follow-market-button.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,5 +1,6 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { listenForFollowers, listenForFollows } from 'web/lib/firebase/users'
|
import { listenForFollowers, listenForFollows } from 'web/lib/firebase/users'
|
||||||
|
import { contracts, listenForContractFollows } from 'web/lib/firebase/contracts'
|
||||||
|
|
||||||
export const useFollows = (userId: string | null | undefined) => {
|
export const useFollows = (userId: string | null | undefined) => {
|
||||||
const [followIds, setFollowIds] = useState<string[] | undefined>()
|
const [followIds, setFollowIds] = useState<string[] | undefined>()
|
||||||
|
@ -29,3 +30,13 @@ export const useFollowers = (userId: string | undefined) => {
|
||||||
|
|
||||||
return followerIds
|
return followerIds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const useContractFollows = (contractId: string) => {
|
||||||
|
const [followIds, setFollowIds] = useState<string[] | undefined>()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return listenForContractFollows(contractId, setFollowIds)
|
||||||
|
}, [contractId])
|
||||||
|
|
||||||
|
return followIds
|
||||||
|
}
|
||||||
|
|
|
@ -147,6 +147,7 @@ export function useUnseenPreferredNotifications(
|
||||||
const lessPriorityReasons = [
|
const lessPriorityReasons = [
|
||||||
'on_contract_with_users_comment',
|
'on_contract_with_users_comment',
|
||||||
'on_contract_with_users_answer',
|
'on_contract_with_users_answer',
|
||||||
|
// Notifications not currently generated for users who've sold their shares
|
||||||
'on_contract_with_users_shares_out',
|
'on_contract_with_users_shares_out',
|
||||||
// Not sure if users will want to see these w/ less:
|
// Not sure if users will want to see these w/ less:
|
||||||
// 'on_contract_with_users_shares_in',
|
// 'on_contract_with_users_shares_in',
|
||||||
|
|
|
@ -212,6 +212,29 @@ export function listenForContract(
|
||||||
return listenForValue<Contract>(contractRef, setContract)
|
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) {
|
function chooseRandomSubset(contracts: Contract[], count: number) {
|
||||||
const fiveMinutes = 5 * 60 * 1000
|
const fiveMinutes = 5 * 60 * 1000
|
||||||
const seed = Math.round(Date.now() / fiveMinutes).toString()
|
const seed = Math.round(Date.now() / fiveMinutes).toString()
|
||||||
|
|
Loading…
Reference in New Issue
Block a user