WIP liking markets with tip
This commit is contained in:
parent
b785d4b047
commit
6238ddd74a
7
common/like.ts
Normal file
7
common/like.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
export type Like = {
|
||||||
|
id: string
|
||||||
|
userId: string
|
||||||
|
contractId: string
|
||||||
|
createdTime: number
|
||||||
|
tipTxnId?: string
|
||||||
|
}
|
|
@ -40,6 +40,8 @@ export type notification_source_types =
|
||||||
| 'challenge'
|
| 'challenge'
|
||||||
| 'betting_streak_bonus'
|
| 'betting_streak_bonus'
|
||||||
| 'loan'
|
| 'loan'
|
||||||
|
| 'like'
|
||||||
|
| 'tip_and_like'
|
||||||
|
|
||||||
export type notification_source_update_types =
|
export type notification_source_update_types =
|
||||||
| 'created'
|
| 'created'
|
||||||
|
@ -71,3 +73,5 @@ export type notification_reason_types =
|
||||||
| 'betting_streak_incremented'
|
| 'betting_streak_incremented'
|
||||||
| 'loan_income'
|
| 'loan_income'
|
||||||
| 'you_follow_contract'
|
| 'you_follow_contract'
|
||||||
|
| 'liked_your_contract'
|
||||||
|
| 'liked_and_tipped_your_contract'
|
||||||
|
|
|
@ -62,6 +62,11 @@ service cloud.firestore {
|
||||||
allow write: if request.auth.uid == userId;
|
allow write: if request.auth.uid == userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
match /users/{userId}/likes/{likeId} {
|
||||||
|
allow read;
|
||||||
|
allow write: if request.auth.uid == userId;
|
||||||
|
}
|
||||||
|
|
||||||
match /{somePath=**}/follows/{followUserId} {
|
match /{somePath=**}/follows/{followUserId} {
|
||||||
allow read;
|
allow read;
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,6 +18,7 @@ import { TipTxn } from '../../common/txn'
|
||||||
import { Group, GROUP_CHAT_SLUG } from '../../common/group'
|
import { Group, GROUP_CHAT_SLUG } from '../../common/group'
|
||||||
import { Challenge } from '../../common/challenge'
|
import { Challenge } from '../../common/challenge'
|
||||||
import { richTextToString } from '../../common/util/parse'
|
import { richTextToString } from '../../common/util/parse'
|
||||||
|
import { Like } from '../../common/like'
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
type user_to_reason_texts = {
|
type user_to_reason_texts = {
|
||||||
|
@ -689,3 +690,36 @@ export const createBettingStreakBonusNotification = async (
|
||||||
}
|
}
|
||||||
return await notificationRef.set(removeUndefinedProps(notification))
|
return await notificationRef.set(removeUndefinedProps(notification))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const createLikeNotification = async (
|
||||||
|
fromUser: User,
|
||||||
|
toUser: User,
|
||||||
|
like: Like,
|
||||||
|
idempotencyKey: string,
|
||||||
|
contract: Contract,
|
||||||
|
tip?: TipTxn
|
||||||
|
) => {
|
||||||
|
const notificationRef = firestore
|
||||||
|
.collection(`/users/${toUser.id}/notifications`)
|
||||||
|
.doc(idempotencyKey)
|
||||||
|
const notification: Notification = {
|
||||||
|
id: idempotencyKey,
|
||||||
|
userId: toUser.id,
|
||||||
|
reason: tip ? 'liked_and_tipped_your_contract' : 'liked_your_contract',
|
||||||
|
createdTime: Date.now(),
|
||||||
|
isSeen: false,
|
||||||
|
sourceId: like.id,
|
||||||
|
sourceType: tip ? 'tip_and_like' : 'like',
|
||||||
|
sourceUpdateType: 'created',
|
||||||
|
sourceUserName: fromUser.name,
|
||||||
|
sourceUserUsername: fromUser.username,
|
||||||
|
sourceUserAvatarUrl: fromUser.avatarUrl,
|
||||||
|
sourceText: tip?.amount.toString(),
|
||||||
|
sourceContractCreatorUsername: contract.creatorUsername,
|
||||||
|
sourceContractTitle: contract.question,
|
||||||
|
sourceContractSlug: contract.slug,
|
||||||
|
sourceSlug: contract.slug,
|
||||||
|
sourceTitle: contract.question,
|
||||||
|
}
|
||||||
|
return await notificationRef.set(removeUndefinedProps(notification))
|
||||||
|
}
|
||||||
|
|
|
@ -31,6 +31,7 @@ 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'
|
export * from './on-update-contract-follow'
|
||||||
|
export * from './on-create-like'
|
||||||
|
|
||||||
// v2
|
// v2
|
||||||
export * from './health'
|
export * from './health'
|
||||||
|
|
53
functions/src/on-create-like.ts
Normal file
53
functions/src/on-create-like.ts
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
import * as functions from 'firebase-functions'
|
||||||
|
import * as admin from 'firebase-admin'
|
||||||
|
import { Like } from '../../common/like'
|
||||||
|
import { getContract, getUser, log } from './utils'
|
||||||
|
import { createLikeNotification } from './create-notification'
|
||||||
|
import { TipTxn } from '../../common/txn'
|
||||||
|
|
||||||
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
|
export const onCreateLike = functions.firestore
|
||||||
|
.document('users/{userId}/likes/{likeId}')
|
||||||
|
.onCreate(async (change, context) => {
|
||||||
|
const like = change.data() as Like
|
||||||
|
const { eventId } = context
|
||||||
|
await handleCreateLike(like, eventId)
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleCreateLike = async (like: Like, eventId: string) => {
|
||||||
|
const contract = await getContract(like.contractId)
|
||||||
|
if (!contract) {
|
||||||
|
log('Could not find contract')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const contractCreator = await getUser(contract.creatorId)
|
||||||
|
if (!contractCreator) {
|
||||||
|
log('Could not find contract creator')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const liker = await getUser(like.userId)
|
||||||
|
if (!liker) {
|
||||||
|
log('Could not find liker')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let tipTxnData = undefined
|
||||||
|
|
||||||
|
if (like.tipTxnId) {
|
||||||
|
const tipTxn = await firestore.collection('txns').doc(like.tipTxnId).get()
|
||||||
|
if (!tipTxn.exists) {
|
||||||
|
log('Could not find tip txn')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tipTxnData = tipTxn.data() as TipTxn
|
||||||
|
}
|
||||||
|
|
||||||
|
await createLikeNotification(
|
||||||
|
liker,
|
||||||
|
contractCreator,
|
||||||
|
like,
|
||||||
|
eventId,
|
||||||
|
contract,
|
||||||
|
tipTxnData
|
||||||
|
)
|
||||||
|
}
|
|
@ -21,6 +21,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 { LikeMarketButton } from 'web/components/contract/like-market-button'
|
||||||
|
|
||||||
export const ContractOverview = (props: {
|
export const ContractOverview = (props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
|
@ -43,6 +44,9 @@ 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 && <LikeMarketButton contract={contract} user={user} />}
|
||||||
<Row className={'hidden gap-3 xl:flex'}>
|
<Row className={'hidden gap-3 xl:flex'}>
|
||||||
{isBinary && (
|
{isBinary && (
|
||||||
<BinaryResolutionOrChance
|
<BinaryResolutionOrChance
|
||||||
|
@ -71,21 +75,36 @@ export const ContractOverview = (props: {
|
||||||
{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} />
|
||||||
{tradingAllowed(contract) && (
|
<Row className={'items-center justify-center'}>
|
||||||
<Col>
|
<LikeMarketButton contract={contract} user={user} />
|
||||||
<BetButton contract={contract as CPMMBinaryContract} />
|
{tradingAllowed(contract) && (
|
||||||
{!user && (
|
<Col>
|
||||||
<div className="mt-1 text-sm text-gray-500">
|
<BetButton contract={contract as CPMMBinaryContract} />
|
||||||
(with play money!)
|
{!user && (
|
||||||
</div>
|
<div className="mt-1 text-center text-sm text-gray-500">
|
||||||
)}
|
(with play money!)
|
||||||
</Col>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
</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-center justify-center'}>
|
||||||
|
<LikeMarketButton contract={contract} user={user} />
|
||||||
|
{tradingAllowed(contract) && (
|
||||||
|
<Col>
|
||||||
|
<BetButton contract={contract} />
|
||||||
|
{!user && (
|
||||||
|
<div className="mt-1 text-center text-sm text-gray-500">
|
||||||
|
(with play money!)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
</Row>
|
</Row>
|
||||||
) : (
|
) : (
|
||||||
(outcomeType === 'FREE_RESPONSE' ||
|
(outcomeType === 'FREE_RESPONSE' ||
|
||||||
|
|
92
web/components/contract/like-market-button.tsx
Normal file
92
web/components/contract/like-market-button.tsx
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
import { HeartIcon } from '@heroicons/react/outline'
|
||||||
|
import { Button } from 'web/components/button'
|
||||||
|
import React from 'react'
|
||||||
|
import { Contract } from 'common/contract'
|
||||||
|
import { User } from 'common/user'
|
||||||
|
import { collection, deleteDoc, doc, setDoc } from 'firebase/firestore'
|
||||||
|
import { removeUndefinedProps } from 'common/util/object'
|
||||||
|
import { track } from '@amplitude/analytics-browser'
|
||||||
|
import { db } from 'web/lib/firebase/init'
|
||||||
|
import { Like } from 'common/like'
|
||||||
|
import { useUserLikes } from 'web/hooks/use-likes'
|
||||||
|
import { transact } from 'web/lib/firebase/api'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import { formatMoney } from 'common/util/format'
|
||||||
|
|
||||||
|
function getLikesCollection(userId: string) {
|
||||||
|
return collection(db, 'users', userId, 'likes')
|
||||||
|
}
|
||||||
|
const LIKE_TIP_AMOUNT = 5
|
||||||
|
|
||||||
|
export function LikeMarketButton(props: {
|
||||||
|
contract: Contract
|
||||||
|
user: User | null | undefined
|
||||||
|
}) {
|
||||||
|
const { contract, user } = props
|
||||||
|
|
||||||
|
const likes = useUserLikes(user?.id)
|
||||||
|
const likedContractIds = likes?.map((l) => l.contractId)
|
||||||
|
if (!user) return <div />
|
||||||
|
|
||||||
|
const onLike = async () => {
|
||||||
|
if (likedContractIds?.includes(contract.id)) {
|
||||||
|
const ref = doc(
|
||||||
|
getLikesCollection(user.id),
|
||||||
|
likes?.find((l) => l.contractId === contract.id)?.id
|
||||||
|
)
|
||||||
|
await deleteDoc(ref)
|
||||||
|
toast(`You removed this market from your likes`)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (user.balance < LIKE_TIP_AMOUNT) {
|
||||||
|
toast('You do not have enough M$ to like this market!')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let result: any = {}
|
||||||
|
if (LIKE_TIP_AMOUNT > 0) {
|
||||||
|
result = await transact({
|
||||||
|
amount: LIKE_TIP_AMOUNT,
|
||||||
|
fromId: user.id,
|
||||||
|
fromType: 'USER',
|
||||||
|
toId: contract.creatorId,
|
||||||
|
toType: 'USER',
|
||||||
|
token: 'M$',
|
||||||
|
category: 'TIP',
|
||||||
|
data: { contractId: contract.id },
|
||||||
|
description: `${user.name} liked contract ${contract.id} for M$ ${LIKE_TIP_AMOUNT} to ${contract.creatorId} `,
|
||||||
|
})
|
||||||
|
console.log('result', result)
|
||||||
|
}
|
||||||
|
// create new like in db under users collection
|
||||||
|
const ref = doc(getLikesCollection(user.id))
|
||||||
|
// contract slug and question are set via trigger
|
||||||
|
const like = removeUndefinedProps({
|
||||||
|
id: ref.id,
|
||||||
|
userId: user.id,
|
||||||
|
createdTime: Date.now(),
|
||||||
|
contractId: contract.id,
|
||||||
|
tipTxnId: result.txn.id,
|
||||||
|
} as Like)
|
||||||
|
track('like', {
|
||||||
|
contractId: contract.id,
|
||||||
|
})
|
||||||
|
await setDoc(ref, like)
|
||||||
|
toast(`You tipped ${contract.creatorName} ${formatMoney(LIKE_TIP_AMOUNT)}!`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size={'md'}
|
||||||
|
className={'mb-1'}
|
||||||
|
color={'gray-white'}
|
||||||
|
onClick={onLike}
|
||||||
|
>
|
||||||
|
{likedContractIds?.includes(contract.id) ? (
|
||||||
|
<HeartIcon className="h-6 w-6 fill-red-500 text-red-500" />
|
||||||
|
) : (
|
||||||
|
<HeartIcon className="h-6 w-6 text-gray-500" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
13
web/hooks/use-likes.ts
Normal file
13
web/hooks/use-likes.ts
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { listenForLikes } from 'web/lib/firebase/users'
|
||||||
|
import { Like } from 'common/like'
|
||||||
|
|
||||||
|
export const useUserLikes = (userId: string | undefined) => {
|
||||||
|
const [contractIds, setContractIds] = useState<Like[] | undefined>()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (userId) return listenForLikes(userId, setContractIds)
|
||||||
|
}, [userId])
|
||||||
|
|
||||||
|
return contractIds
|
||||||
|
}
|
|
@ -67,7 +67,13 @@ export function groupNotifications(notifications: Notification[]) {
|
||||||
const notificationGroupsByDay = groupBy(notifications, (notification) =>
|
const notificationGroupsByDay = groupBy(notifications, (notification) =>
|
||||||
new Date(notification.createdTime).toDateString()
|
new Date(notification.createdTime).toDateString()
|
||||||
)
|
)
|
||||||
const incomeSourceTypes = ['bonus', 'tip', 'loan', 'betting_streak_bonus']
|
const incomeSourceTypes = [
|
||||||
|
'bonus',
|
||||||
|
'tip',
|
||||||
|
'loan',
|
||||||
|
'betting_streak_bonus',
|
||||||
|
'tip_and_like',
|
||||||
|
]
|
||||||
|
|
||||||
Object.keys(notificationGroupsByDay).forEach((day) => {
|
Object.keys(notificationGroupsByDay).forEach((day) => {
|
||||||
const notificationsGroupedByDay = notificationGroupsByDay[day]
|
const notificationsGroupedByDay = notificationGroupsByDay[day]
|
||||||
|
|
|
@ -27,6 +27,7 @@ import utc from 'dayjs/plugin/utc'
|
||||||
dayjs.extend(utc)
|
dayjs.extend(utc)
|
||||||
|
|
||||||
import { track } from '@amplitude/analytics-browser'
|
import { track } from '@amplitude/analytics-browser'
|
||||||
|
import { Like } from 'common/like'
|
||||||
|
|
||||||
export const users = coll<User>('users')
|
export const users = coll<User>('users')
|
||||||
export const privateUsers = coll<PrivateUser>('private-users')
|
export const privateUsers = coll<PrivateUser>('private-users')
|
||||||
|
@ -310,3 +311,11 @@ export function listenForReferrals(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listenForLikes(
|
||||||
|
userId: string,
|
||||||
|
setLikes: (likes: Like[]) => void
|
||||||
|
) {
|
||||||
|
const likes = collection(users, userId, 'likes')
|
||||||
|
return listenForValues<Like>(likes, (docs) => setLikes(docs))
|
||||||
|
}
|
||||||
|
|
|
@ -401,6 +401,10 @@ function IncomeNotificationItem(props: {
|
||||||
reasonText = 'for your'
|
reasonText = 'for your'
|
||||||
} else if (sourceType === 'loan' && sourceText) {
|
} else if (sourceType === 'loan' && sourceText) {
|
||||||
reasonText = `of your invested bets returned as a`
|
reasonText = `of your invested bets returned as a`
|
||||||
|
// TODO: support just 'like' notification without a tip
|
||||||
|
// TODO: show who tip-liked your market
|
||||||
|
} else if (sourceType === 'tip_and_like' && sourceText) {
|
||||||
|
reasonText = `in likes on`
|
||||||
}
|
}
|
||||||
|
|
||||||
const streakInDays =
|
const streakInDays =
|
||||||
|
|
Loading…
Reference in New Issue
Block a user