diff --git a/common/like.ts b/common/like.ts
new file mode 100644
index 00000000..1b9ce481
--- /dev/null
+++ b/common/like.ts
@@ -0,0 +1,7 @@
+export type Like = {
+ id: string
+ userId: string
+ contractId: string
+ createdTime: number
+ tipTxnId?: string
+}
diff --git a/common/notification.ts b/common/notification.ts
index f10bd3f6..657ea2c1 100644
--- a/common/notification.ts
+++ b/common/notification.ts
@@ -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'
diff --git a/firestore.rules b/firestore.rules
index 4cd718d3..5de1fe64 100644
--- a/firestore.rules
+++ b/firestore.rules
@@ -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;
}
diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts
index 035126c5..9c5d98c1 100644
--- a/functions/src/create-notification.ts
+++ b/functions/src/create-notification.ts
@@ -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))
+}
diff --git a/functions/src/index.ts b/functions/src/index.ts
index 012ba241..b3523eff 100644
--- a/functions/src/index.ts
+++ b/functions/src/index.ts
@@ -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'
diff --git a/functions/src/on-create-like.ts b/functions/src/on-create-like.ts
new file mode 100644
index 00000000..80fc88a2
--- /dev/null
+++ b/functions/src/on-create-like.ts
@@ -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
+ )
+}
diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx
index ac6b20f9..1fe63c37 100644
--- a/web/components/contract/contract-overview.tsx
+++ b/web/components/contract/contract-overview.tsx
@@ -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: {
+ {(outcomeType === 'FREE_RESPONSE' ||
+ outcomeType === 'MULTIPLE_CHOICE') &&
+ !resolution && }
{isBinary && (
- {tradingAllowed(contract) && (
-
-
- {!user && (
-
- (with play money!)
-
- )}
-
- )}
+
+
+ {tradingAllowed(contract) && (
+
+
+ {!user && (
+
+ (with play money!)
+
+ )}
+
+ )}
+
) : isPseudoNumeric ? (
- {tradingAllowed(contract) && }
+
+
+ {tradingAllowed(contract) && (
+
+
+ {!user && (
+
+ (with play money!)
+
+ )}
+
+ )}
+
) : (
(outcomeType === 'FREE_RESPONSE' ||
diff --git a/web/components/contract/like-market-button.tsx b/web/components/contract/like-market-button.tsx
new file mode 100644
index 00000000..ca4cf204
--- /dev/null
+++ b/web/components/contract/like-market-button.tsx
@@ -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
+
+ 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 (
+
+ )
+}
diff --git a/web/hooks/use-likes.ts b/web/hooks/use-likes.ts
new file mode 100644
index 00000000..755001fa
--- /dev/null
+++ b/web/hooks/use-likes.ts
@@ -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()
+
+ useEffect(() => {
+ if (userId) return listenForLikes(userId, setContractIds)
+ }, [userId])
+
+ return contractIds
+}
diff --git a/web/hooks/use-notifications.ts b/web/hooks/use-notifications.ts
index 32500943..0954865a 100644
--- a/web/hooks/use-notifications.ts
+++ b/web/hooks/use-notifications.ts
@@ -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]
diff --git a/web/lib/firebase/users.ts b/web/lib/firebase/users.ts
index 6cfee163..b4335efa 100644
--- a/web/lib/firebase/users.ts
+++ b/web/lib/firebase/users.ts
@@ -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('users')
export const privateUsers = coll('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(likes, (docs) => setLikes(docs))
+}
diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx
index 0fe3b179..6948716b 100644
--- a/web/pages/notifications.tsx
+++ b/web/pages/notifications.tsx
@@ -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 =