WIP liking markets with tip

This commit is contained in:
Ian Philips 2022-08-25 11:42:57 -06:00
parent b785d4b047
commit 6238ddd74a
12 changed files with 259 additions and 12 deletions

7
common/like.ts Normal file
View File

@ -0,0 +1,7 @@
export type Like = {
id: string
userId: string
contractId: string
createdTime: number
tipTxnId?: string
}

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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