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
3ce2bdc9f1
commit
54f7b740dc
|
@ -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
|
||||
|
|
|
@ -70,3 +70,4 @@ export type notification_reason_types =
|
|||
| 'challenge_accepted'
|
||||
| 'betting_streak_incremented'
|
||||
| 'loan_income'
|
||||
| 'you_follow_contract'
|
||||
|
|
|
@ -42,6 +42,7 @@ export type User = {
|
|||
shouldShowWelcome?: boolean
|
||||
lastBetTime?: number
|
||||
currentBettingStreak?: number
|
||||
hasSeenContractFollowModal?: boolean
|
||||
}
|
||||
|
||||
export type PrivateUser = {
|
||||
|
|
2
dev.sh
2
dev.sh
|
@ -24,7 +24,7 @@ then
|
|||
npx concurrently \
|
||||
-n FIRESTORE,FUNCTIONS,NEXT,TS \
|
||||
-c green,white,magenta,cyan \
|
||||
"yarn --cwd=functions firestore" \
|
||||
"yarn --cwd=functions localDbScript" \
|
||||
"cross-env FIRESTORE_EMULATOR_HOST=localhost:8080 yarn --cwd=functions dev" \
|
||||
"cross-env NEXT_PUBLIC_FUNCTIONS_URL=http://localhost:8088 \
|
||||
NEXT_PUBLIC_FIREBASE_EMULATE=TRUE \
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -7,7 +7,7 @@ import {
|
|||
} from '../../common/notification'
|
||||
import { User } from '../../common/user'
|
||||
import { Contract } from '../../common/contract'
|
||||
import { getValues } from './utils'
|
||||
import { getValues, log } from './utils'
|
||||
import { Comment } from '../../common/comment'
|
||||
import { uniq } from 'lodash'
|
||||
import { Bet, LimitBet } from '../../common/bet'
|
||||
|
@ -33,19 +33,12 @@ export const createNotification = async (
|
|||
sourceText: string,
|
||||
miscData?: {
|
||||
contract?: Contract
|
||||
relatedSourceType?: notification_source_types
|
||||
recipients?: string[]
|
||||
slug?: string
|
||||
title?: string
|
||||
}
|
||||
) => {
|
||||
const {
|
||||
contract: sourceContract,
|
||||
relatedSourceType,
|
||||
recipients,
|
||||
slug,
|
||||
title,
|
||||
} = miscData ?? {}
|
||||
const { contract: sourceContract, recipients, slug, title } = miscData ?? {}
|
||||
|
||||
const shouldGetNotification = (
|
||||
userId: string,
|
||||
|
@ -90,24 +83,6 @@ export const createNotification = async (
|
|||
)
|
||||
}
|
||||
|
||||
const notifyLiquidityProviders = async (
|
||||
userToReasonTexts: user_to_reason_texts,
|
||||
contract: Contract
|
||||
) => {
|
||||
const liquidityProviders = await firestore
|
||||
.collection(`contracts/${contract.id}/liquidity`)
|
||||
.get()
|
||||
const liquidityProvidersIds = uniq(
|
||||
liquidityProviders.docs.map((doc) => doc.data().userId)
|
||||
)
|
||||
liquidityProvidersIds.forEach((userId) => {
|
||||
if (!shouldGetNotification(userId, userToReasonTexts)) return
|
||||
userToReasonTexts[userId] = {
|
||||
reason: 'on_contract_with_users_shares_in',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const notifyUsersFollowers = async (
|
||||
userToReasonTexts: user_to_reason_texts
|
||||
) => {
|
||||
|
@ -129,23 +104,6 @@ export const createNotification = async (
|
|||
})
|
||||
}
|
||||
|
||||
const notifyRepliedUser = (
|
||||
userToReasonTexts: user_to_reason_texts,
|
||||
relatedUserId: string,
|
||||
relatedSourceType: notification_source_types
|
||||
) => {
|
||||
if (!shouldGetNotification(relatedUserId, userToReasonTexts)) return
|
||||
if (relatedSourceType === 'comment') {
|
||||
userToReasonTexts[relatedUserId] = {
|
||||
reason: 'reply_to_users_comment',
|
||||
}
|
||||
} else if (relatedSourceType === 'answer') {
|
||||
userToReasonTexts[relatedUserId] = {
|
||||
reason: 'reply_to_users_answer',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const notifyFollowedUser = (
|
||||
userToReasonTexts: user_to_reason_texts,
|
||||
followedUserId: string
|
||||
|
@ -182,71 +140,6 @@ export const createNotification = async (
|
|||
}
|
||||
}
|
||||
|
||||
const notifyOtherAnswerersOnContract = async (
|
||||
userToReasonTexts: user_to_reason_texts,
|
||||
sourceContract: Contract
|
||||
) => {
|
||||
const answers = await getValues<Answer>(
|
||||
firestore
|
||||
.collection('contracts')
|
||||
.doc(sourceContract.id)
|
||||
.collection('answers')
|
||||
)
|
||||
const recipientUserIds = uniq(answers.map((answer) => answer.userId))
|
||||
recipientUserIds.forEach((userId) => {
|
||||
if (shouldGetNotification(userId, userToReasonTexts))
|
||||
userToReasonTexts[userId] = {
|
||||
reason: 'on_contract_with_users_answer',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const notifyOtherCommentersOnContract = async (
|
||||
userToReasonTexts: user_to_reason_texts,
|
||||
sourceContract: Contract
|
||||
) => {
|
||||
const comments = await getValues<Comment>(
|
||||
firestore
|
||||
.collection('contracts')
|
||||
.doc(sourceContract.id)
|
||||
.collection('comments')
|
||||
)
|
||||
const recipientUserIds = uniq(comments.map((comment) => comment.userId))
|
||||
recipientUserIds.forEach((userId) => {
|
||||
if (shouldGetNotification(userId, userToReasonTexts))
|
||||
userToReasonTexts[userId] = {
|
||||
reason: 'on_contract_with_users_comment',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const notifyBettorsOnContract = async (
|
||||
userToReasonTexts: user_to_reason_texts,
|
||||
sourceContract: Contract
|
||||
) => {
|
||||
const betsSnap = await firestore
|
||||
.collection(`contracts/${sourceContract.id}/bets`)
|
||||
.get()
|
||||
const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
|
||||
// filter bets for only users that have an amount invested still
|
||||
const recipientUserIds = uniq(bets.map((bet) => bet.userId)).filter(
|
||||
(userId) => {
|
||||
return (
|
||||
getContractBetMetrics(
|
||||
sourceContract,
|
||||
bets.filter((bet) => bet.userId === userId)
|
||||
).invested > 0
|
||||
)
|
||||
}
|
||||
)
|
||||
recipientUserIds.forEach((userId) => {
|
||||
if (shouldGetNotification(userId, userToReasonTexts))
|
||||
userToReasonTexts[userId] = {
|
||||
reason: 'on_contract_with_users_shares_in',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const notifyUserAddedToGroup = (
|
||||
userToReasonTexts: user_to_reason_texts,
|
||||
relatedUserId: string
|
||||
|
@ -266,58 +159,289 @@ export const createNotification = async (
|
|||
}
|
||||
}
|
||||
|
||||
const getUsersToNotify = async () => {
|
||||
const userToReasonTexts: user_to_reason_texts = {}
|
||||
// The following functions modify the userToReasonTexts object in place.
|
||||
if (sourceType === 'follow' && recipients?.[0]) {
|
||||
notifyFollowedUser(userToReasonTexts, recipients[0])
|
||||
} else if (
|
||||
sourceType === 'group' &&
|
||||
sourceUpdateType === 'created' &&
|
||||
recipients
|
||||
) {
|
||||
recipients.forEach((r) => notifyUserAddedToGroup(userToReasonTexts, r))
|
||||
}
|
||||
const userToReasonTexts: user_to_reason_texts = {}
|
||||
// The following functions modify the userToReasonTexts object in place.
|
||||
|
||||
// The following functions need sourceContract to be defined.
|
||||
if (!sourceContract) return userToReasonTexts
|
||||
|
||||
if (
|
||||
sourceType === 'comment' ||
|
||||
sourceType === 'answer' ||
|
||||
(sourceType === 'contract' &&
|
||||
(sourceUpdateType === 'updated' || sourceUpdateType === 'resolved'))
|
||||
) {
|
||||
if (sourceType === 'comment') {
|
||||
if (recipients?.[0] && relatedSourceType)
|
||||
notifyRepliedUser(userToReasonTexts, recipients[0], relatedSourceType)
|
||||
if (sourceText) notifyTaggedUsers(userToReasonTexts, recipients ?? [])
|
||||
}
|
||||
await notifyContractCreator(userToReasonTexts, sourceContract)
|
||||
await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract)
|
||||
await notifyLiquidityProviders(userToReasonTexts, sourceContract)
|
||||
await notifyBettorsOnContract(userToReasonTexts, sourceContract)
|
||||
await notifyOtherCommentersOnContract(userToReasonTexts, sourceContract)
|
||||
} else if (sourceType === 'contract' && sourceUpdateType === 'created') {
|
||||
await notifyUsersFollowers(userToReasonTexts)
|
||||
notifyTaggedUsers(userToReasonTexts, recipients ?? [])
|
||||
} else if (sourceType === 'contract' && sourceUpdateType === 'closed') {
|
||||
await notifyContractCreator(userToReasonTexts, sourceContract, {
|
||||
force: true,
|
||||
})
|
||||
} else if (sourceType === 'liquidity' && sourceUpdateType === 'created') {
|
||||
await notifyContractCreator(userToReasonTexts, sourceContract)
|
||||
} else if (sourceType === 'bonus' && sourceUpdateType === 'created') {
|
||||
// Note: the daily bonus won't have a contract attached to it
|
||||
await notifyContractCreatorOfUniqueBettorsBonus(
|
||||
userToReasonTexts,
|
||||
sourceContract.creatorId
|
||||
)
|
||||
}
|
||||
return userToReasonTexts
|
||||
if (sourceType === 'follow' && recipients?.[0]) {
|
||||
notifyFollowedUser(userToReasonTexts, recipients[0])
|
||||
} else if (
|
||||
sourceType === 'group' &&
|
||||
sourceUpdateType === 'created' &&
|
||||
recipients
|
||||
) {
|
||||
recipients.forEach((r) => notifyUserAddedToGroup(userToReasonTexts, r))
|
||||
} else if (
|
||||
sourceType === 'contract' &&
|
||||
sourceUpdateType === 'created' &&
|
||||
sourceContract
|
||||
) {
|
||||
await notifyUsersFollowers(userToReasonTexts)
|
||||
notifyTaggedUsers(userToReasonTexts, recipients ?? [])
|
||||
} else if (
|
||||
sourceType === 'contract' &&
|
||||
sourceUpdateType === 'closed' &&
|
||||
sourceContract
|
||||
) {
|
||||
await notifyContractCreator(userToReasonTexts, sourceContract, {
|
||||
force: true,
|
||||
})
|
||||
} else if (
|
||||
sourceType === 'liquidity' &&
|
||||
sourceUpdateType === 'created' &&
|
||||
sourceContract
|
||||
) {
|
||||
await notifyContractCreator(userToReasonTexts, sourceContract)
|
||||
} else if (
|
||||
sourceType === 'bonus' &&
|
||||
sourceUpdateType === 'created' &&
|
||||
sourceContract
|
||||
) {
|
||||
// Note: the daily bonus won't have a contract attached to it
|
||||
await notifyContractCreatorOfUniqueBettorsBonus(
|
||||
userToReasonTexts,
|
||||
sourceContract.creatorId
|
||||
)
|
||||
}
|
||||
|
||||
const userToReasonTexts = await getUsersToNotify()
|
||||
await createUsersNotifications(userToReasonTexts)
|
||||
}
|
||||
|
||||
export const createCommentOrAnswerOrUpdatedContractNotification = async (
|
||||
sourceId: string,
|
||||
sourceType: notification_source_types,
|
||||
sourceUpdateType: notification_source_update_types,
|
||||
sourceUser: User,
|
||||
idempotencyKey: string,
|
||||
sourceText: string,
|
||||
sourceContract: Contract,
|
||||
miscData?: {
|
||||
relatedSourceType?: notification_source_types
|
||||
repliedUserId?: string
|
||||
taggedUserIds?: string[]
|
||||
}
|
||||
) => {
|
||||
const { relatedSourceType, repliedUserId, taggedUserIds } = miscData ?? {}
|
||||
|
||||
const createUsersNotifications = async (
|
||||
userToReasonTexts: user_to_reason_texts
|
||||
) => {
|
||||
await Promise.all(
|
||||
Object.keys(userToReasonTexts).map(async (userId) => {
|
||||
const notificationRef = firestore
|
||||
.collection(`/users/${userId}/notifications`)
|
||||
.doc(idempotencyKey)
|
||||
const notification: Notification = {
|
||||
id: idempotencyKey,
|
||||
userId,
|
||||
reason: userToReasonTexts[userId].reason,
|
||||
createdTime: Date.now(),
|
||||
isSeen: false,
|
||||
sourceId,
|
||||
sourceType,
|
||||
sourceUpdateType,
|
||||
sourceContractId: sourceContract.id,
|
||||
sourceUserName: sourceUser.name,
|
||||
sourceUserUsername: sourceUser.username,
|
||||
sourceUserAvatarUrl: sourceUser.avatarUrl,
|
||||
sourceText,
|
||||
sourceContractCreatorUsername: sourceContract.creatorUsername,
|
||||
sourceContractTitle: sourceContract.question,
|
||||
sourceContractSlug: sourceContract.slug,
|
||||
sourceSlug: sourceContract.slug,
|
||||
sourceTitle: sourceContract.question,
|
||||
}
|
||||
await notificationRef.set(removeUndefinedProps(notification))
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// get contract follower documents and check here if they're a follower
|
||||
const contractFollowersSnap = await firestore
|
||||
.collection(`contracts/${sourceContract.id}/follows`)
|
||||
.get()
|
||||
const contractFollowersIds = contractFollowersSnap.docs.map(
|
||||
(doc) => doc.data().id
|
||||
)
|
||||
log('contractFollowerIds', contractFollowersIds)
|
||||
|
||||
const stillFollowingContract = (userId: string) => {
|
||||
return contractFollowersIds.includes(userId)
|
||||
}
|
||||
|
||||
const shouldGetNotification = (
|
||||
userId: string,
|
||||
userToReasonTexts: user_to_reason_texts
|
||||
) => {
|
||||
return (
|
||||
sourceUser.id != userId &&
|
||||
!Object.keys(userToReasonTexts).includes(userId)
|
||||
)
|
||||
}
|
||||
|
||||
const notifyContractFollowers = async (
|
||||
userToReasonTexts: user_to_reason_texts
|
||||
) => {
|
||||
for (const userId of contractFollowersIds) {
|
||||
if (shouldGetNotification(userId, userToReasonTexts))
|
||||
userToReasonTexts[userId] = {
|
||||
reason: 'you_follow_contract',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const notifyContractCreator = async (
|
||||
userToReasonTexts: user_to_reason_texts
|
||||
) => {
|
||||
if (
|
||||
shouldGetNotification(sourceContract.creatorId, userToReasonTexts) &&
|
||||
stillFollowingContract(sourceContract.creatorId)
|
||||
)
|
||||
userToReasonTexts[sourceContract.creatorId] = {
|
||||
reason: 'on_users_contract',
|
||||
}
|
||||
}
|
||||
|
||||
const notifyOtherAnswerersOnContract = async (
|
||||
userToReasonTexts: user_to_reason_texts
|
||||
) => {
|
||||
const answers = await getValues<Answer>(
|
||||
firestore
|
||||
.collection('contracts')
|
||||
.doc(sourceContract.id)
|
||||
.collection('answers')
|
||||
)
|
||||
const recipientUserIds = uniq(answers.map((answer) => answer.userId))
|
||||
recipientUserIds.forEach((userId) => {
|
||||
if (
|
||||
shouldGetNotification(userId, userToReasonTexts) &&
|
||||
stillFollowingContract(userId)
|
||||
)
|
||||
userToReasonTexts[userId] = {
|
||||
reason: 'on_contract_with_users_answer',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const notifyOtherCommentersOnContract = async (
|
||||
userToReasonTexts: user_to_reason_texts
|
||||
) => {
|
||||
const comments = await getValues<Comment>(
|
||||
firestore
|
||||
.collection('contracts')
|
||||
.doc(sourceContract.id)
|
||||
.collection('comments')
|
||||
)
|
||||
const recipientUserIds = uniq(comments.map((comment) => comment.userId))
|
||||
recipientUserIds.forEach((userId) => {
|
||||
if (
|
||||
shouldGetNotification(userId, userToReasonTexts) &&
|
||||
stillFollowingContract(userId)
|
||||
)
|
||||
userToReasonTexts[userId] = {
|
||||
reason: 'on_contract_with_users_comment',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const notifyBettorsOnContract = async (
|
||||
userToReasonTexts: user_to_reason_texts
|
||||
) => {
|
||||
const betsSnap = await firestore
|
||||
.collection(`contracts/${sourceContract.id}/bets`)
|
||||
.get()
|
||||
const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
|
||||
// filter bets for only users that have an amount invested still
|
||||
const recipientUserIds = uniq(bets.map((bet) => bet.userId)).filter(
|
||||
(userId) => {
|
||||
return (
|
||||
getContractBetMetrics(
|
||||
sourceContract,
|
||||
bets.filter((bet) => bet.userId === userId)
|
||||
).invested > 0
|
||||
)
|
||||
}
|
||||
)
|
||||
recipientUserIds.forEach((userId) => {
|
||||
if (
|
||||
shouldGetNotification(userId, userToReasonTexts) &&
|
||||
stillFollowingContract(userId)
|
||||
)
|
||||
userToReasonTexts[userId] = {
|
||||
reason: 'on_contract_with_users_shares_in',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const notifyRepliedUser = (
|
||||
userToReasonTexts: user_to_reason_texts,
|
||||
relatedUserId: string,
|
||||
relatedSourceType: notification_source_types
|
||||
) => {
|
||||
if (
|
||||
shouldGetNotification(relatedUserId, userToReasonTexts) &&
|
||||
stillFollowingContract(relatedUserId)
|
||||
) {
|
||||
if (relatedSourceType === 'comment') {
|
||||
userToReasonTexts[relatedUserId] = {
|
||||
reason: 'reply_to_users_comment',
|
||||
}
|
||||
} else if (relatedSourceType === 'answer') {
|
||||
userToReasonTexts[relatedUserId] = {
|
||||
reason: 'reply_to_users_answer',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const notifyTaggedUsers = (
|
||||
userToReasonTexts: user_to_reason_texts,
|
||||
userIds: (string | undefined)[]
|
||||
) => {
|
||||
userIds.forEach((id) => {
|
||||
console.log('tagged user: ', id)
|
||||
// Allowing non-following users to get tagged
|
||||
if (id && shouldGetNotification(id, userToReasonTexts))
|
||||
userToReasonTexts[id] = {
|
||||
reason: 'tagged_user',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const notifyLiquidityProviders = async (
|
||||
userToReasonTexts: user_to_reason_texts
|
||||
) => {
|
||||
const liquidityProviders = await firestore
|
||||
.collection(`contracts/${sourceContract.id}/liquidity`)
|
||||
.get()
|
||||
const liquidityProvidersIds = uniq(
|
||||
liquidityProviders.docs.map((doc) => doc.data().userId)
|
||||
)
|
||||
liquidityProvidersIds.forEach((userId) => {
|
||||
if (
|
||||
shouldGetNotification(userId, userToReasonTexts) &&
|
||||
stillFollowingContract(userId)
|
||||
) {
|
||||
userToReasonTexts[userId] = {
|
||||
reason: 'on_contract_with_users_shares_in',
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
const userToReasonTexts: user_to_reason_texts = {}
|
||||
|
||||
if (sourceType === 'comment') {
|
||||
if (repliedUserId && relatedSourceType)
|
||||
notifyRepliedUser(userToReasonTexts, repliedUserId, relatedSourceType)
|
||||
if (sourceText) notifyTaggedUsers(userToReasonTexts, taggedUserIds ?? [])
|
||||
}
|
||||
await notifyContractCreator(userToReasonTexts)
|
||||
await notifyOtherAnswerersOnContract(userToReasonTexts)
|
||||
await notifyLiquidityProviders(userToReasonTexts)
|
||||
await notifyBettorsOnContract(userToReasonTexts)
|
||||
await notifyOtherCommentersOnContract(userToReasonTexts)
|
||||
// if they weren't added previously, add them now
|
||||
await notifyContractFollowers(userToReasonTexts)
|
||||
|
||||
await createUsersNotifications(userToReasonTexts)
|
||||
}
|
||||
|
||||
|
|
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 './reset-betting-streaks'
|
||||
export * from './reset-weekly-emails-flag'
|
||||
export * from './on-update-contract-follow'
|
||||
|
||||
// v2
|
||||
export * from './health'
|
||||
|
|
|
@ -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
|
||||
)
|
||||
})
|
||||
|
|
|
@ -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([
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
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 { 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
|
||||
)
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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) {
|
||||
|
|
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 { 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.')
|
||||
|
|
|
@ -9,6 +9,8 @@ import { Row } from 'web/components/layout/row'
|
|||
import clsx from 'clsx'
|
||||
import { CheckIcon, XIcon } from '@heroicons/react/outline'
|
||||
import { ChoicesToggleGroup } from 'web/components/choices-toggle-group'
|
||||
import { Col } from 'web/components/layout/col'
|
||||
import { FollowMarketModal } from 'web/components/contract/follow-market-modal'
|
||||
|
||||
export function NotificationSettings() {
|
||||
const user = useUser()
|
||||
|
@ -17,6 +19,7 @@ export function NotificationSettings() {
|
|||
const [emailNotificationSettings, setEmailNotificationSettings] =
|
||||
useState<notification_subscribe_types>('all')
|
||||
const [privateUser, setPrivateUser] = useState<PrivateUser | null>(null)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (user) listenForPrivateUser(user.id, setPrivateUser)
|
||||
|
@ -121,12 +124,20 @@ export function NotificationSettings() {
|
|||
}
|
||||
|
||||
function NotificationSettingLine(props: {
|
||||
label: string
|
||||
label: string | React.ReactNode
|
||||
highlight: boolean
|
||||
onClick?: () => void
|
||||
}) {
|
||||
const { label, highlight } = props
|
||||
const { label, highlight, onClick } = props
|
||||
return (
|
||||
<Row className={clsx('my-1 text-gray-300', highlight && '!text-black')}>
|
||||
<Row
|
||||
className={clsx(
|
||||
'my-1 gap-1 text-gray-300',
|
||||
highlight && '!text-black',
|
||||
onClick ? 'cursor-pointer' : ''
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{highlight ? <CheckIcon height={20} /> : <XIcon height={20} />}
|
||||
{label}
|
||||
</Row>
|
||||
|
@ -148,31 +159,45 @@ export function NotificationSettings() {
|
|||
toggleClassName={'w-24'}
|
||||
/>
|
||||
<div className={'mt-4 text-sm'}>
|
||||
<div>
|
||||
<div className={''}>
|
||||
You will receive notifications for:
|
||||
<NotificationSettingLine
|
||||
label={"Resolution of questions you've interacted with"}
|
||||
highlight={notificationSettings !== 'none'}
|
||||
/>
|
||||
<NotificationSettingLine
|
||||
highlight={notificationSettings !== 'none'}
|
||||
label={'Activity on your own questions, comments, & answers'}
|
||||
/>
|
||||
<NotificationSettingLine
|
||||
highlight={notificationSettings !== 'none'}
|
||||
label={"Activity on questions you're betting on"}
|
||||
/>
|
||||
<NotificationSettingLine
|
||||
highlight={notificationSettings !== 'none'}
|
||||
label={"Income & referral bonuses you've received"}
|
||||
/>
|
||||
<NotificationSettingLine
|
||||
label={"Activity on questions you've ever bet or commented on"}
|
||||
highlight={notificationSettings === 'all'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Col className={''}>
|
||||
<Row className={'my-1'}>
|
||||
You will receive notifications for these general events:
|
||||
</Row>
|
||||
<NotificationSettingLine
|
||||
highlight={notificationSettings !== 'none'}
|
||||
label={"Income & referral bonuses you've received"}
|
||||
/>
|
||||
<Row className={'my-1'}>
|
||||
You will receive new comment, answer, & resolution notifications on
|
||||
questions:
|
||||
</Row>
|
||||
<NotificationSettingLine
|
||||
highlight={notificationSettings !== 'none'}
|
||||
label={
|
||||
<span>
|
||||
That <span className={'font-bold'}>you follow </span>- you
|
||||
auto-follow questions if:
|
||||
</span>
|
||||
}
|
||||
onClick={() => setShowModal(true)}
|
||||
/>
|
||||
<Col
|
||||
className={clsx(
|
||||
'mb-2 ml-8',
|
||||
'gap-1 text-gray-300',
|
||||
notificationSettings !== 'none' && '!text-black'
|
||||
)}
|
||||
>
|
||||
<Row>• You create it</Row>
|
||||
<Row>• You bet, comment on, or answer it</Row>
|
||||
<Row>• You add liquidity to it</Row>
|
||||
<Row>
|
||||
• If you select 'Less' and you've commented on or answered a
|
||||
question, you'll only receive notification on direct replies to
|
||||
your comments or answers
|
||||
</Row>
|
||||
</Col>
|
||||
</Col>
|
||||
</div>
|
||||
<div className={'mt-4'}>Email Notifications</div>
|
||||
<ChoicesToggleGroup
|
||||
|
@ -205,6 +230,7 @@ export function NotificationSettings() {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<FollowMarketModal setOpen={setShowModal} open={showModal} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -22,6 +22,7 @@ import { ContractDescription } from './contract-description'
|
|||
import { ContractDetails } from './contract-details'
|
||||
import { NumericGraph } from './numeric-graph'
|
||||
import { ShareRow } from './share-row'
|
||||
import { FollowMarketButton } from 'web/components/follow-market-button'
|
||||
|
||||
export const ContractOverview = (props: {
|
||||
contract: Contract
|
||||
|
@ -44,47 +45,57 @@ export const ContractOverview = (props: {
|
|||
<div className="text-2xl text-indigo-700 md:text-3xl">
|
||||
<Linkify text={question} />
|
||||
</div>
|
||||
{(outcomeType === 'FREE_RESPONSE' ||
|
||||
outcomeType === 'MULTIPLE_CHOICE') &&
|
||||
!resolution && (
|
||||
<div className={'xl:hidden'}>
|
||||
<FollowMarketButton contract={contract} user={user} />
|
||||
</div>
|
||||
)}
|
||||
<Row className={'hidden gap-3 xl:flex'}>
|
||||
<FollowMarketButton contract={contract} user={user} />
|
||||
|
||||
{isBinary && (
|
||||
<BinaryResolutionOrChance
|
||||
className="hidden items-end xl:flex"
|
||||
contract={contract}
|
||||
large
|
||||
/>
|
||||
)}
|
||||
{isBinary && (
|
||||
<BinaryResolutionOrChance
|
||||
className="items-end"
|
||||
contract={contract}
|
||||
large
|
||||
/>
|
||||
)}
|
||||
|
||||
{isPseudoNumeric && (
|
||||
<PseudoNumericResolutionOrExpectation
|
||||
contract={contract}
|
||||
className="hidden items-end xl:flex"
|
||||
/>
|
||||
)}
|
||||
{isPseudoNumeric && (
|
||||
<PseudoNumericResolutionOrExpectation
|
||||
contract={contract}
|
||||
className="items-end"
|
||||
/>
|
||||
)}
|
||||
|
||||
{outcomeType === 'NUMERIC' && (
|
||||
<NumericResolutionOrExpectation
|
||||
contract={contract}
|
||||
className="hidden items-end xl:flex"
|
||||
/>
|
||||
)}
|
||||
{outcomeType === 'NUMERIC' && (
|
||||
<NumericResolutionOrExpectation
|
||||
contract={contract}
|
||||
className="items-end"
|
||||
/>
|
||||
)}
|
||||
</Row>
|
||||
</Row>
|
||||
|
||||
{isBinary ? (
|
||||
<Row className="items-center justify-between gap-4 xl:hidden">
|
||||
<BinaryResolutionOrChance contract={contract} />
|
||||
|
||||
{tradingAllowed(contract) && (
|
||||
<BetButton contract={contract as CPMMBinaryContract} />
|
||||
)}
|
||||
<Row className={'items-start gap-2'}>
|
||||
<FollowMarketButton contract={contract} user={user} />
|
||||
{tradingAllowed(contract) && (
|
||||
<BetButton contract={contract as CPMMBinaryContract} />
|
||||
)}
|
||||
</Row>
|
||||
</Row>
|
||||
) : isPseudoNumeric ? (
|
||||
<Row className="items-center justify-between gap-4 xl:hidden">
|
||||
<PseudoNumericResolutionOrExpectation contract={contract} />
|
||||
{tradingAllowed(contract) && <BetButton contract={contract} />}
|
||||
</Row>
|
||||
) : isPseudoNumeric ? (
|
||||
<Row className="items-center justify-between gap-4 xl:hidden">
|
||||
<PseudoNumericResolutionOrExpectation contract={contract} />
|
||||
{tradingAllowed(contract) && <BetButton contract={contract} />}
|
||||
<Row className={'items-start gap-2'}>
|
||||
<FollowMarketButton contract={contract} user={user} />
|
||||
{tradingAllowed(contract) && <BetButton contract={contract} />}
|
||||
</Row>
|
||||
</Row>
|
||||
) : (
|
||||
(outcomeType === 'FREE_RESPONSE' ||
|
||||
|
|
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 { 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
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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()
|
||||
|
|
Loading…
Reference in New Issue
Block a user