diff --git a/common/contract.ts b/common/contract.ts index 2b330201..5dc4b696 100644 --- a/common/contract.ts +++ b/common/contract.ts @@ -59,6 +59,8 @@ export type Contract = { popularityScore?: number followerCount?: number featuredOnHomeRank?: number + likedByUserIds?: string[] + likedByUserCount?: number } & T export type BinaryContract = Contract & Binary diff --git a/common/like.ts b/common/like.ts new file mode 100644 index 00000000..85140e02 --- /dev/null +++ b/common/like.ts @@ -0,0 +1,8 @@ +export type Like = { + id: string // will be id of the object liked, i.e. contract.id + userId: string + type: 'contract' + createdTime: number + tipTxnId?: string +} +export const LIKE_TIP_AMOUNT = 5 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 26aa52e0..7b263e1a 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 32bc16c4..6ede39a0 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -31,6 +31,8 @@ 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' +export * from './on-delete-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..8c5885b0 --- /dev/null +++ b/functions/src/on-create-like.ts @@ -0,0 +1,71 @@ +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' +import { uniq } from 'lodash' + +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 + if (like.type === 'contract') { + await handleCreateLikeNotification(like, eventId) + await updateContractLikes(like) + } + }) + +const updateContractLikes = async (like: Like) => { + const contract = await getContract(like.id) + if (!contract) { + log('Could not find contract') + return + } + const likedByUserIds = uniq(contract.likedByUserIds ?? []) + likedByUserIds.push(like.userId) + await firestore + .collection('contracts') + .doc(like.id) + .update({ likedByUserIds, likedByUserCount: likedByUserIds.length }) +} + +const handleCreateLikeNotification = async (like: Like, eventId: string) => { + const contract = await getContract(like.id) + 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/functions/src/on-delete-like.ts b/functions/src/on-delete-like.ts new file mode 100644 index 00000000..151614b0 --- /dev/null +++ b/functions/src/on-delete-like.ts @@ -0,0 +1,32 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' +import { Like } from '../../common/like' +import { getContract, log } from './utils' +import { uniq } from 'lodash' + +const firestore = admin.firestore() + +export const onDeleteLike = functions.firestore + .document('users/{userId}/likes/{likeId}') + .onDelete(async (change) => { + const like = change.data() as Like + if (like.type === 'contract') { + await removeContractLike(like) + } + }) + +const removeContractLike = async (like: Like) => { + const contract = await getContract(like.id) + if (!contract) { + log('Could not find contract') + return + } + const likedByUserIds = uniq(contract.likedByUserIds ?? []) + const newLikedByUserIds = likedByUserIds.filter( + (userId) => userId !== like.userId + ) + await firestore.collection('contracts').doc(like.id).update({ + likedByUserIds: newLikedByUserIds, + likedByUserCount: newLikedByUserIds.length, + }) +} diff --git a/functions/src/on-update-contract-follow.ts b/functions/src/on-update-contract-follow.ts index f7d54fe8..20ef8e30 100644 --- a/functions/src/on-update-contract-follow.ts +++ b/functions/src/on-update-contract-follow.ts @@ -2,6 +2,7 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' import { FieldValue } from 'firebase-admin/firestore' +// TODO: should cache the follower user ids in the contract as these triggers aren't idempotent export const onDeleteContractFollow = functions.firestore .document('contracts/{contractId}/follows/{userId}') .onDelete(async (change, context) => { diff --git a/web/components/answers/answers-panel.tsx b/web/components/answers/answers-panel.tsx index 3de99c34..e53153b1 100644 --- a/web/components/answers/answers-panel.tsx +++ b/web/components/answers/answers-panel.tsx @@ -20,9 +20,9 @@ import { Modal } from 'web/components/layout/modal' import { AnswerBetPanel } from 'web/components/answers/answer-bet-panel' import { Row } from 'web/components/layout/row' import { Avatar } from 'web/components/avatar' -import { UserLink } from 'web/components/user-page' import { Linkify } from 'web/components/linkify' import { BuyButton } from 'web/components/yes-no-selector' +import { UserLink } from 'web/components/user-link' export function AnswersPanel(props: { contract: FreeResponseContract | MultipleChoiceContract diff --git a/web/components/bets-list.tsx b/web/components/bets-list.tsx index 3217da3d..932d689c 100644 --- a/web/components/bets-list.tsx +++ b/web/components/bets-list.tsx @@ -21,7 +21,6 @@ import { getBinaryProbPercent, } from 'web/lib/firebase/contracts' import { Row } from './layout/row' -import { UserLink } from './user-page' import { sellBet } from 'web/lib/firebase/api' import { ConfirmationButton } from './confirmation-button' import { OutcomeLabel, YesLabel, NoLabel } from './outcome-label' @@ -48,6 +47,7 @@ import { LimitBet } from 'common/bet' import { floatingEqual } from 'common/util/math' import { Pagination } from './pagination' import { LimitOrderTable } from './limit-bets' +import { UserLink } from 'web/components/user-link' import { useUserBetContracts } from 'web/hooks/use-contracts' type BetSort = 'newest' | 'profit' | 'closeTime' | 'value' diff --git a/web/components/charity/feed-items.tsx b/web/components/charity/feed-items.tsx index 365aa606..b589f34b 100644 --- a/web/components/charity/feed-items.tsx +++ b/web/components/charity/feed-items.tsx @@ -1,9 +1,9 @@ import { DonationTxn } from 'common/txn' import { Avatar } from '../avatar' import { useUserById } from 'web/hooks/use-user' -import { UserLink } from '../user-page' import { manaToUSD } from '../../../common/util/format' import { RelativeTimestamp } from '../relative-timestamp' +import { UserLink } from 'web/components/user-link' export function Donation(props: { txn: DonationTxn }) { const { txn } = props diff --git a/web/components/comments-list.tsx b/web/components/comments-list.tsx index 12ae0649..0b1c3843 100644 --- a/web/components/comments-list.tsx +++ b/web/components/comments-list.tsx @@ -6,11 +6,11 @@ import { SiteLink } from './site-link' import { Row } from './layout/row' import { Avatar } from './avatar' import { RelativeTimestamp } from './relative-timestamp' -import { UserLink } from './user-page' import { User } from 'common/user' import { Col } from './layout/col' import { Content } from './editor' import { LoadingIndicator } from './loading-indicator' +import { UserLink } from 'web/components/user-link' import { PaginationNextPrev } from 'web/components/pagination' type ContractKey = { diff --git a/web/components/contract/contract-details.tsx b/web/components/contract/contract-details.tsx index 175b36b5..72ecbb1f 100644 --- a/web/components/contract/contract-details.tsx +++ b/web/components/contract/contract-details.tsx @@ -12,7 +12,6 @@ import dayjs from 'dayjs' import { Row } from '../layout/row' import { formatMoney } from 'common/util/format' -import { UserLink } from '../user-page' import { Contract, updateContract } from 'web/lib/firebase/contracts' import { DateTimeTooltip } from '../datetime-tooltip' import { fromNow } from 'web/lib/util/time' @@ -34,6 +33,7 @@ import { groupPath } from 'web/lib/firebase/groups' import { insertContent } from '../editor/utils' import { contractMetrics } from 'common/contract-details' import { User } from 'common/user' +import { UserLink } from 'web/components/user-link' import { FeaturedContractBadge } from 'web/components/contract/featured-contract-badge' export type ShowTime = 'resolve-date' | 'close-date' diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index 23485179..37639d79 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,13 @@ export const ContractOverview = (props: {
+ {(outcomeType === 'FREE_RESPONSE' || + outcomeType === 'MULTIPLE_CHOICE') && + !resolution && ( +
+ +
+ )} {isBinary && ( {tradingAllowed(contract) && ( - - - {!user && ( -
- (with play money!) -
- )} - + +
+ +
+ + + {!user && ( +
+ (with play money!) +
+ )} + +
)}
) : isPseudoNumeric ? ( {tradingAllowed(contract) && ( - - - {!user && ( -
- (with play money!) -
- )} - + +
+ +
+ + + {!user && ( +
+ (with play money!) +
+ )} + +
)}
) : ( diff --git a/web/components/contract/like-market-button.tsx b/web/components/contract/like-market-button.tsx new file mode 100644 index 00000000..f0cb77b0 --- /dev/null +++ b/web/components/contract/like-market-button.tsx @@ -0,0 +1,55 @@ +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 { useUserLikes } from 'web/hooks/use-likes' +import toast from 'react-hot-toast' +import { formatMoney } from 'common/util/format' +import { likeContract, unLikeContract } from 'web/lib/firebase/likes' +import { LIKE_TIP_AMOUNT } from 'common/like' +import clsx from 'clsx' +import { Row } from 'web/components/layout/row' + +export function LikeMarketButton(props: { + contract: Contract + user: User | null | undefined +}) { + const { contract, user } = props + + const likes = useUserLikes(user?.id) + const likedContractIds = likes + ?.filter((l) => l.type === 'contract') + .map((l) => l.id) + if (!user) return
+ + const onLike = async () => { + if (likedContractIds?.includes(contract.id)) { + await unLikeContract(user.id, contract.id) + return + } + await likeContract(user, contract) + toast(`You tipped ${contract.creatorName} ${formatMoney(LIKE_TIP_AMOUNT)}!`) + } + + return ( + + ) +} diff --git a/web/components/contract/share-row.tsx b/web/components/contract/share-row.tsx index 1af52291..03bd99e6 100644 --- a/web/components/contract/share-row.tsx +++ b/web/components/contract/share-row.tsx @@ -11,6 +11,7 @@ import { CHALLENGES_ENABLED } from 'common/challenge' import { ShareModal } from './share-modal' import { withTracking } from 'web/lib/service/analytics' import { FollowMarketButton } from 'web/components/follow-market-button' +import { LikeMarketButton } from 'web/components/contract/like-market-button' export function ShareRow(props: { contract: Contract @@ -64,6 +65,9 @@ export function ShareRow(props: { )} +
+ +
) } diff --git a/web/components/feed/feed-answer-comment-group.tsx b/web/components/feed/feed-answer-comment-group.tsx index 2df8cb4a..7758ec82 100644 --- a/web/components/feed/feed-answer-comment-group.tsx +++ b/web/components/feed/feed-answer-comment-group.tsx @@ -6,7 +6,6 @@ import React, { useEffect, useState } from 'react' import { Col } from 'web/components/layout/col' import { Row } from 'web/components/layout/row' import { Avatar } from 'web/components/avatar' -import { UserLink } from 'web/components/user-page' import { Linkify } from 'web/components/linkify' import clsx from 'clsx' import { @@ -20,6 +19,7 @@ import { Dictionary } from 'lodash' import { User } from 'common/user' import { useEvent } from 'web/hooks/use-event' import { CommentTipMap } from 'web/hooks/use-tip-txns' +import { UserLink } from 'web/components/user-link' export function FeedAnswerCommentGroup(props: { contract: FreeResponseContract diff --git a/web/components/feed/feed-bets.tsx b/web/components/feed/feed-bets.tsx index b7aeb321..cf444061 100644 --- a/web/components/feed/feed-bets.tsx +++ b/web/components/feed/feed-bets.tsx @@ -10,11 +10,11 @@ import { formatMoney, formatPercent } from 'common/util/format' import { OutcomeLabel } from 'web/components/outcome-label' import { RelativeTimestamp } from 'web/components/relative-timestamp' import React, { useEffect } from 'react' -import { UserLink } from '../user-page' import { formatNumericProbability } from 'common/pseudo-numeric' import { SiteLink } from 'web/components/site-link' import { getChallenge, getChallengeUrl } from 'web/lib/firebase/challenges' import { Challenge } from 'common/challenge' +import { UserLink } from 'web/components/user-link' export function FeedBet(props: { contract: Contract; bet: Bet }) { const { contract, bet } = props diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx index 9e6c3cd5..1aebb27b 100644 --- a/web/components/feed/feed-comments.tsx +++ b/web/components/feed/feed-comments.tsx @@ -10,7 +10,6 @@ import { useRouter } from 'next/router' import { Row } from 'web/components/layout/row' import clsx from 'clsx' import { Avatar } from 'web/components/avatar' -import { UserLink } from 'web/components/user-page' import { OutcomeLabel } from 'web/components/outcome-label' import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' import { firebaseLogin } from 'web/lib/firebase/users' @@ -29,6 +28,7 @@ import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns' import { useWindowSize } from 'web/hooks/use-window-size' import { Content, TextEditor, useTextEditor } from '../editor' import { Editor } from '@tiptap/react' +import { UserLink } from 'web/components/user-link' export function FeedCommentThread(props: { user: User | null | undefined diff --git a/web/components/feed/feed-liquidity.tsx b/web/components/feed/feed-liquidity.tsx index 3a9ffdeb..ee2e34e5 100644 --- a/web/components/feed/feed-liquidity.tsx +++ b/web/components/feed/feed-liquidity.tsx @@ -6,8 +6,8 @@ import { Avatar, EmptyAvatar } from 'web/components/avatar' import { formatMoney } from 'common/util/format' import { RelativeTimestamp } from 'web/components/relative-timestamp' import React from 'react' -import { UserLink } from '../user-page' import { LiquidityProvision } from 'common/liquidity-provision' +import { UserLink } from 'web/components/user-link' export function FeedLiquidity(props: { className?: string diff --git a/web/components/filter-select-users.tsx b/web/components/filter-select-users.tsx index a19ab6af..415a6d57 100644 --- a/web/components/filter-select-users.tsx +++ b/web/components/filter-select-users.tsx @@ -6,8 +6,8 @@ import clsx from 'clsx' import { Menu, Transition } from '@headlessui/react' import { Avatar } from 'web/components/avatar' import { Row } from 'web/components/layout/row' -import { UserLink } from 'web/components/user-page' import { searchInAny } from 'common/util/parse' +import { UserLink } from 'web/components/user-link' export function FilterSelectUsers(props: { setSelectedUsers: (users: User[]) => void diff --git a/web/components/follow-list.tsx b/web/components/follow-list.tsx index c935f73d..65b9ef4a 100644 --- a/web/components/follow-list.tsx +++ b/web/components/follow-list.tsx @@ -6,7 +6,7 @@ import { Avatar } from './avatar' import { FollowButton } from './follow-button' import { Col } from './layout/col' import { Row } from './layout/row' -import { UserLink } from './user-page' +import { UserLink } from 'web/components/user-link' export function FollowList(props: { userIds: string[] }) { const { userIds } = props diff --git a/web/components/groups/group-chat.tsx b/web/components/groups/group-chat.tsx index 244a3ffe..9a60c9c7 100644 --- a/web/components/groups/group-chat.tsx +++ b/web/components/groups/group-chat.tsx @@ -11,7 +11,6 @@ import { track } from 'web/lib/service/analytics' import { firebaseLogin } from 'web/lib/firebase/users' import { useRouter } from 'next/router' import clsx from 'clsx' -import { UserLink } from 'web/components/user-page' import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time' import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns' import { Tipper } from 'web/components/tipper' @@ -23,6 +22,7 @@ import { useUnseenNotifications } from 'web/hooks/use-notifications' import { ChevronDownIcon, UsersIcon } from '@heroicons/react/outline' import { setNotificationsAsSeen } from 'web/pages/notifications' import { usePrivateUser } from 'web/hooks/use-user' +import { UserLink } from 'web/components/user-link' export function GroupChat(props: { messages: GroupComment[] diff --git a/web/components/online-user-list.tsx b/web/components/online-user-list.tsx index d7f52d56..e5e006ac 100644 --- a/web/components/online-user-list.tsx +++ b/web/components/online-user-list.tsx @@ -2,13 +2,13 @@ import clsx from 'clsx' import { Avatar } from './avatar' import { Col } from './layout/col' import { Row } from './layout/row' -import { UserLink } from './user-page' import { User } from 'common/user' import { UserCircleIcon } from '@heroicons/react/solid' import { useUsers } from 'web/hooks/use-users' import { partition } from 'lodash' import { useWindowSize } from 'web/hooks/use-window-size' import { useState } from 'react' +import { UserLink } from 'web/components/user-link' const isOnline = (user?: User) => user && user.lastPingTime && user.lastPingTime > Date.now() - 5 * 60 * 1000 diff --git a/web/components/profile/user-likes-button.tsx b/web/components/profile/user-likes-button.tsx new file mode 100644 index 00000000..3d4fa9ac --- /dev/null +++ b/web/components/profile/user-likes-button.tsx @@ -0,0 +1,48 @@ +import { User } from 'common/user' +import { useState } from 'react' +import { TextButton } from 'web/components/text-button' +import { Modal } from 'web/components/layout/modal' +import { Col } from 'web/components/layout/col' +import { useUserLikedContracts } from 'web/hooks/use-likes' +import { SiteLink } from 'web/components/site-link' +import { Row } from 'web/components/layout/row' +import { XIcon } from '@heroicons/react/outline' +import { unLikeContract } from 'web/lib/firebase/likes' +import { contractPath } from 'web/lib/firebase/contracts' + +export function UserLikesButton(props: { user: User }) { + const { user } = props + const [isOpen, setIsOpen] = useState(false) + + const likedContracts = useUserLikedContracts(user.id) + + return ( + <> + setIsOpen(true)}> + {likedContracts?.length ?? ''}{' '} + Likes + + + + Liked Markets + + {likedContracts?.map((likedContract) => ( + + + {likedContract.question} + + unLikeContract(user.id, likedContract.id)} + /> + + ))} + + + + + ) +} diff --git a/web/components/referrals-button.tsx b/web/components/referrals-button.tsx index fed8fb6b..3cf77cfd 100644 --- a/web/components/referrals-button.tsx +++ b/web/components/referrals-button.tsx @@ -7,11 +7,11 @@ import { Modal } from './layout/modal' import { Tabs } from './layout/tabs' import { Row } from 'web/components/layout/row' import { Avatar } from 'web/components/avatar' -import { UserLink } from 'web/components/user-page' import { useReferrals } from 'web/hooks/use-referrals' import { FilterSelectUsers } from 'web/components/filter-select-users' import { getUser, updateUser } from 'web/lib/firebase/users' import { TextButton } from 'web/components/text-button' +import { UserLink } from 'web/components/user-link' export function ReferralsButton(props: { user: User; currentUser?: User }) { const { user, currentUser } = props diff --git a/web/components/user-link.tsx b/web/components/user-link.tsx new file mode 100644 index 00000000..5eeab1c4 --- /dev/null +++ b/web/components/user-link.tsx @@ -0,0 +1,102 @@ +import { linkClass, SiteLink } from 'web/components/site-link' +import clsx from 'clsx' +import { Row } from 'web/components/layout/row' +import { Modal } from 'web/components/layout/modal' +import { Col } from 'web/components/layout/col' +import { useState } from 'react' +import { Avatar } from 'web/components/avatar' +import { formatMoney } from 'common/util/format' + +function shortenName(name: string) { + const firstName = name.split(' ')[0] + const maxLength = 10 + const shortName = + firstName.length >= 3 + ? firstName.length < maxLength + ? firstName + : firstName.substring(0, maxLength - 3) + '...' + : name.length > maxLength + ? name.substring(0, maxLength) + '...' + : name + return shortName +} + +export function UserLink(props: { + name: string + username: string + showUsername?: boolean + className?: string + short?: boolean +}) { + const { name, username, showUsername, className, short } = props + const shortName = short ? shortenName(name) : name + return ( + + {shortName} + {showUsername && ` (@${username})`} + + ) +} + +export type MultiUserLinkInfo = { + name: string + username: string + avatarUrl: string | undefined + amountTipped: number +} + +export function MultiUserTipLink(props: { + userInfos: MultiUserLinkInfo[] + className?: string +}) { + const { userInfos, className } = props + const [open, setOpen] = useState(false) + const maxShowCount = 2 + return ( + <> + { + e.stopPropagation() + setOpen(true) + }} + > + {userInfos.map((userInfo, index) => + index < maxShowCount ? ( + + {shortenName(userInfo.name) + + (index < maxShowCount - 1 ? ', ' : '')} + + ) : ( + + & {userInfos.length - maxShowCount} more + + ) + )} + + + + Who tipped you + {userInfos.map((userInfo) => ( + + + +{formatMoney(userInfo.amountTipped)} + + + + + ))} + + + + ) +} diff --git a/web/components/user-page.tsx b/web/components/user-page.tsx index a20fc58a..8312f16e 100644 --- a/web/components/user-page.tsx +++ b/web/components/user-page.tsx @@ -31,35 +31,7 @@ import { ENV_CONFIG } from 'common/envs/constants' import { BettingStreakModal } from 'web/components/profile/betting-streak-modal' import { REFERRAL_AMOUNT } from 'common/economy' import { LoansModal } from './profile/loans-modal' - -export function UserLink(props: { - name: string - username: string - showUsername?: boolean - className?: string - short?: boolean -}) { - const { name, username, showUsername, className, short } = props - const firstName = name.split(' ')[0] - const maxLength = 10 - const shortName = - firstName.length >= 3 - ? firstName.length < maxLength - ? firstName - : firstName.substring(0, maxLength - 3) + '...' - : name.length > maxLength - ? name.substring(0, maxLength) + '...' - : name - return ( - - {short ? shortName : name} - {showUsername && ` (@${username})`} - - ) -} +import { UserLikesButton } from 'web/components/profile/user-likes-button' export function UserPage(props: { user: User }) { const { user } = props @@ -302,6 +274,7 @@ export function UserPage(props: { user: User }) { + ), }, diff --git a/web/hooks/use-likes.ts b/web/hooks/use-likes.ts new file mode 100644 index 00000000..015d2c3c --- /dev/null +++ b/web/hooks/use-likes.ts @@ -0,0 +1,38 @@ +import { useEffect, useState } from 'react' +import { listenForLikes } from 'web/lib/firebase/users' +import { Like } from 'common/like' +import { Contract } from 'common/contract' +import { getContractFromId } from 'web/lib/firebase/contracts' +import { filterDefined } from 'common/util/array' + +export const useUserLikes = (userId: string | undefined) => { + const [contractIds, setContractIds] = useState() + + useEffect(() => { + if (userId) return listenForLikes(userId, setContractIds) + }, [userId]) + + return contractIds +} +export const useUserLikedContracts = (userId: string | undefined) => { + const [likes, setLikes] = useState() + const [contracts, setContracts] = useState() + + useEffect(() => { + if (userId) + return listenForLikes(userId, (likes) => { + setLikes(likes.filter((l) => l.type === 'contract')) + }) + }, [userId]) + + useEffect(() => { + if (likes) + Promise.all( + likes.map(async (like) => { + return await getContractFromId(like.id) + }) + ).then((contracts) => setContracts(filterDefined(contracts))) + }, [likes]) + + return contracts +} diff --git a/web/hooks/use-notifications.ts b/web/hooks/use-notifications.ts index b2f1701f..60d0e43e 100644 --- a/web/hooks/use-notifications.ts +++ b/web/hooks/use-notifications.ts @@ -63,7 +63,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/likes.ts b/web/lib/firebase/likes.ts new file mode 100644 index 00000000..f16bedb7 --- /dev/null +++ b/web/lib/firebase/likes.ts @@ -0,0 +1,54 @@ +import { collection, deleteDoc, doc, setDoc } from 'firebase/firestore' +import { db } from 'web/lib/firebase/init' +import toast from 'react-hot-toast' +import { transact } from 'web/lib/firebase/api' +import { removeUndefinedProps } from 'common/util/object' +import { Like, LIKE_TIP_AMOUNT } from 'common/like' +import { track } from '@amplitude/analytics-browser' +import { User } from 'common/user' +import { Contract } from 'common/contract' + +function getLikesCollection(userId: string) { + return collection(db, 'users', userId, 'likes') +} + +export const unLikeContract = async (userId: string, contractId: string) => { + const ref = await doc(getLikesCollection(userId), contractId) + return await deleteDoc(ref) +} + +export const likeContract = async (user: User, contract: Contract) => { + 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.id) + // contract slug and question are set via trigger + const like = removeUndefinedProps({ + id: ref.id, + userId: user.id, + createdTime: Date.now(), + type: 'contract', + tipTxnId: result.txn.id, + } as Like) + track('like', { + contractId: contract.id, + }) + await setDoc(ref, like) +} diff --git a/web/lib/firebase/users.ts b/web/lib/firebase/users.ts index c0764f0a..fc024e04 100644 --- a/web/lib/firebase/users.ts +++ b/web/lib/firebase/users.ts @@ -28,6 +28,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/challenges/[username]/[contractSlug]/[challengeSlug].tsx b/web/pages/challenges/[username]/[contractSlug]/[challengeSlug].tsx index f15c5809..6b8152d6 100644 --- a/web/pages/challenges/[username]/[contractSlug]/[challengeSlug].tsx +++ b/web/pages/challenges/[username]/[contractSlug]/[challengeSlug].tsx @@ -21,7 +21,6 @@ import { Page } from 'web/components/page' import { useUser, useUserById } from 'web/hooks/use-user' import { AcceptChallengeButton } from 'web/components/challenges/accept-challenge-button' import { Avatar } from 'web/components/avatar' -import { UserLink } from 'web/components/user-page' import { BinaryOutcomeLabel } from 'web/components/outcome-label' import { formatMoney } from 'common/util/format' import { LoadingIndicator } from 'web/components/loading-indicator' @@ -33,6 +32,7 @@ import { useSaveReferral } from 'web/hooks/use-save-referral' import { BinaryContract } from 'common/contract' import { Title } from 'web/components/title' import { getOpenGraphProps } from 'common/contract-details' +import { UserLink } from 'web/components/user-link' export const getStaticProps = fromPropz(getStaticPropz) diff --git a/web/pages/challenges/index.tsx b/web/pages/challenges/index.tsx index ad4136f0..11d0f9ab 100644 --- a/web/pages/challenges/index.tsx +++ b/web/pages/challenges/index.tsx @@ -19,7 +19,6 @@ import { import { Challenge, CHALLENGES_ENABLED } from 'common/challenge' import { Tabs } from 'web/components/layout/tabs' import { SiteLink } from 'web/components/site-link' -import { UserLink } from 'web/components/user-page' import { Avatar } from 'web/components/avatar' import Router from 'next/router' import { contractPathWithoutContract } from 'web/lib/firebase/contracts' @@ -30,6 +29,7 @@ import toast from 'react-hot-toast' import { Modal } from 'web/components/layout/modal' import { QRCode } from 'web/components/qr-code' import { CreateChallengeModal } from 'web/components/challenges/create-challenge-modal' +import { UserLink } from 'web/components/user-link' dayjs.extend(customParseFormat) const columnClass = 'sm:px-5 px-2 py-3.5 max-w-[100px] truncate' diff --git a/web/pages/group/[...slugs]/index.tsx b/web/pages/group/[...slugs]/index.tsx index 5271a395..bf29cc8b 100644 --- a/web/pages/group/[...slugs]/index.tsx +++ b/web/pages/group/[...slugs]/index.tsx @@ -17,7 +17,6 @@ import { updateGroup, } from 'web/lib/firebase/groups' import { Row } from 'web/components/layout/row' -import { UserLink } from 'web/components/user-page' import { firebaseLogin, getUser, User } from 'web/lib/firebase/users' import { Col } from 'web/components/layout/col' import { useUser } from 'web/hooks/use-user' @@ -45,6 +44,7 @@ import { Button } from 'web/components/button' import { listAllCommentsOnGroup } from 'web/lib/firebase/comments' import { GroupComment } from 'common/comment' import { REFERRAL_AMOUNT } from 'common/economy' +import { UserLink } from 'web/components/user-link' import { GroupAboutPost } from 'web/components/groups/group-about-post' import { getPost } from 'web/lib/firebase/posts' import { Post } from 'common/post' diff --git a/web/pages/groups.tsx b/web/pages/groups.tsx index 521742b2..aaf1374c 100644 --- a/web/pages/groups.tsx +++ b/web/pages/groups.tsx @@ -16,9 +16,9 @@ import { SiteLink } from 'web/components/site-link' import clsx from 'clsx' import { Avatar } from 'web/components/avatar' import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button' -import { UserLink } from 'web/components/user-page' import { searchInAny } from 'common/util/parse' import { SEO } from 'web/components/SEO' +import { UserLink } from 'web/components/user-link' export async function getStaticProps() { const groups = await listAllGroups().catch((_) => []) diff --git a/web/pages/links.tsx b/web/pages/links.tsx index 6f57dc14..4c4a0be1 100644 --- a/web/pages/links.tsx +++ b/web/pages/links.tsx @@ -18,7 +18,6 @@ import { ManalinkTxn } from 'common/txn' import { User } from 'common/user' import { Avatar } from 'web/components/avatar' import { RelativeTimestamp } from 'web/components/relative-timestamp' -import { UserLink } from 'web/components/user-page' import { CreateLinksButton } from 'web/components/manalinks/create-links-button' import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth' @@ -27,6 +26,7 @@ import { Pagination } from 'web/components/pagination' import { Manalink } from 'common/manalink' import { SiteLink } from 'web/components/site-link' import { REFERRAL_AMOUNT } from 'common/economy' +import { UserLink } from 'web/components/user-link' const LINKS_PER_PAGE = 24 diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index 85cbcbae..f1995568 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -7,7 +7,6 @@ import { Page } from 'web/components/page' import { Title } from 'web/components/title' import { doc, updateDoc } from 'firebase/firestore' import { db } from 'web/lib/firebase/init' -import { UserLink } from 'web/components/user-page' import { MANIFOLD_AVATAR_URL, MANIFOLD_USERNAME, @@ -35,7 +34,7 @@ import { BETTING_STREAK_BONUS_AMOUNT, UNIQUE_BETTOR_BONUS_AMOUNT, } from 'common/economy' -import { groupBy, sum, uniq } from 'lodash' +import { groupBy, sum, uniqBy } from 'lodash' import { track } from '@amplitude/analytics-browser' import { Pagination } from 'web/components/pagination' import { useWindowSize } from 'web/hooks/use-window-size' @@ -45,10 +44,14 @@ import { SiteLink } from 'web/components/site-link' import { NotificationSettings } from 'web/components/NotificationSettings' import { SEO } from 'web/components/SEO' import { useUser } from 'web/hooks/use-user' +import { + MultiUserTipLink, + MultiUserLinkInfo, + UserLink, +} from 'web/components/user-link' import { LoadingIndicator } from 'web/components/loading-indicator' export const NOTIFICATIONS_PER_PAGE = 30 -const MULTIPLE_USERS_KEY = 'multipleUsers' const HIGHLIGHT_CLASS = 'bg-indigo-50' export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => { @@ -233,13 +236,26 @@ function IncomeNotificationGroupItem(props: { let sum = 0 notificationsForSourceTitle.forEach( (notification) => - notification.sourceText && - (sum = parseInt(notification.sourceText) + sum) + (sum = parseInt(notification.sourceText ?? '0') + sum) ) - const uniqueUsers = uniq( + const uniqueUsers = uniqBy( notificationsForSourceTitle.map((notification) => { - return notification.sourceUserUsername - }) + let thisSum = 0 + notificationsForSourceTitle + .filter( + (n) => n.sourceUserUsername === notification.sourceUserUsername + ) + .forEach( + (n) => (thisSum = parseInt(n.sourceText ?? '0') + thisSum) + ) + return { + username: notification.sourceUserUsername, + name: notification.sourceUserName, + avatarUrl: notification.sourceUserAvatarUrl, + amountTipped: thisSum, + } as MultiUserLinkInfo + }), + (n) => n.username ) const newNotification = { @@ -247,7 +263,7 @@ function IncomeNotificationGroupItem(props: { sourceText: sum.toString(), sourceUserUsername: uniqueUsers.length > 1 - ? MULTIPLE_USERS_KEY + ? JSON.stringify(uniqueUsers) : notificationsForSourceTitle[0].sourceType, } newNotifications.push(newNotification) @@ -385,6 +401,9 @@ function IncomeNotificationItem(props: { else reasonText = 'for your' } else if (sourceType === 'loan' && sourceText) { reasonText = `of your invested bets returned as a` + // TODO: support just 'like' notification without a tip + } else if (sourceType === 'tip_and_like' && sourceText) { + reasonText = !simple ? `liked` : `in likes on` } const streakInDays = @@ -493,9 +512,11 @@ function IncomeNotificationItem(props: { {incomeNotificationLabel()}
- {sourceType === 'tip' && - (sourceUserUsername === MULTIPLE_USERS_KEY ? ( - Multiple users + {(sourceType === 'tip' || sourceType === 'tip_and_like') && + (sourceUserUsername?.includes(',') ? ( + ) : (