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 =