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'
|
||||
| 'betting_streak_bonus'
|
||||
| 'loan'
|
||||
| 'like'
|
||||
| 'tip_and_like'
|
||||
|
||||
export type notification_source_update_types =
|
||||
| 'created'
|
||||
|
@ -71,3 +73,5 @@ export type notification_reason_types =
|
|||
| 'betting_streak_incremented'
|
||||
| 'loan_income'
|
||||
| '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;
|
||||
}
|
||||
|
||||
match /users/{userId}/likes/{likeId} {
|
||||
allow read;
|
||||
allow write: if request.auth.uid == userId;
|
||||
}
|
||||
|
||||
match /{somePath=**}/follows/{followUserId} {
|
||||
allow read;
|
||||
}
|
||||
|
|
|
@ -18,6 +18,7 @@ import { TipTxn } from '../../common/txn'
|
|||
import { Group, GROUP_CHAT_SLUG } from '../../common/group'
|
||||
import { Challenge } from '../../common/challenge'
|
||||
import { richTextToString } from '../../common/util/parse'
|
||||
import { Like } from '../../common/like'
|
||||
const firestore = admin.firestore()
|
||||
|
||||
type user_to_reason_texts = {
|
||||
|
@ -689,3 +690,36 @@ export const createBettingStreakBonusNotification = async (
|
|||
}
|
||||
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-weekly-emails-flag'
|
||||
export * from './on-update-contract-follow'
|
||||
export * from './on-create-like'
|
||||
|
||||
// v2
|
||||
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 { NumericGraph } from './numeric-graph'
|
||||
import { ShareRow } from './share-row'
|
||||
import { LikeMarketButton } from 'web/components/contract/like-market-button'
|
||||
|
||||
export const ContractOverview = (props: {
|
||||
contract: Contract
|
||||
|
@ -43,6 +44,9 @@ 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 && <LikeMarketButton contract={contract} user={user} />}
|
||||
<Row className={'hidden gap-3 xl:flex'}>
|
||||
{isBinary && (
|
||||
<BinaryResolutionOrChance
|
||||
|
@ -71,21 +75,36 @@ export const ContractOverview = (props: {
|
|||
{isBinary ? (
|
||||
<Row className="items-center justify-between gap-4 xl:hidden">
|
||||
<BinaryResolutionOrChance contract={contract} />
|
||||
{tradingAllowed(contract) && (
|
||||
<Col>
|
||||
<BetButton contract={contract as CPMMBinaryContract} />
|
||||
{!user && (
|
||||
<div className="mt-1 text-sm text-gray-500">
|
||||
(with play money!)
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
)}
|
||||
<Row className={'items-center justify-center'}>
|
||||
<LikeMarketButton contract={contract} user={user} />
|
||||
{tradingAllowed(contract) && (
|
||||
<Col>
|
||||
<BetButton contract={contract as CPMMBinaryContract} />
|
||||
{!user && (
|
||||
<div className="mt-1 text-center text-sm text-gray-500">
|
||||
(with play money!)
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
</Row>
|
||||
) : isPseudoNumeric ? (
|
||||
<Row className="items-center justify-between gap-4 xl:hidden">
|
||||
<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>
|
||||
) : (
|
||||
(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) =>
|
||||
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) => {
|
||||
const notificationsGroupedByDay = notificationGroupsByDay[day]
|
||||
|
|
|
@ -27,6 +27,7 @@ import utc from 'dayjs/plugin/utc'
|
|||
dayjs.extend(utc)
|
||||
|
||||
import { track } from '@amplitude/analytics-browser'
|
||||
import { Like } from 'common/like'
|
||||
|
||||
export const users = coll<User>('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'
|
||||
} else if (sourceType === 'loan' && sourceText) {
|
||||
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 =
|
||||
|
|
Loading…
Reference in New Issue
Block a user