liking markets with tip/heart (#798)
* WIP liking markets with tip * Refactor Userlink, add MultiUserLink * Lint * Lint * Fix merge * Fix imports * wip liked contracts list * Cache likes and liked by user ids on contract * Refactor tip amount, reduce to M * Move back to M * Change positioning for large screens
This commit is contained in:
parent
e1f19c52ab
commit
a0402830c5
|
@ -59,6 +59,8 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
|
||||||
popularityScore?: number
|
popularityScore?: number
|
||||||
followerCount?: number
|
followerCount?: number
|
||||||
featuredOnHomeRank?: number
|
featuredOnHomeRank?: number
|
||||||
|
likedByUserIds?: string[]
|
||||||
|
likedByUserCount?: number
|
||||||
} & T
|
} & T
|
||||||
|
|
||||||
export type BinaryContract = Contract & Binary
|
export type BinaryContract = Contract & Binary
|
||||||
|
|
8
common/like.ts
Normal file
8
common/like.ts
Normal file
|
@ -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
|
|
@ -40,6 +40,8 @@ export type notification_source_types =
|
||||||
| 'challenge'
|
| 'challenge'
|
||||||
| 'betting_streak_bonus'
|
| 'betting_streak_bonus'
|
||||||
| 'loan'
|
| 'loan'
|
||||||
|
| 'like'
|
||||||
|
| 'tip_and_like'
|
||||||
|
|
||||||
export type notification_source_update_types =
|
export type notification_source_update_types =
|
||||||
| 'created'
|
| 'created'
|
||||||
|
@ -71,3 +73,5 @@ export type notification_reason_types =
|
||||||
| 'betting_streak_incremented'
|
| 'betting_streak_incremented'
|
||||||
| 'loan_income'
|
| 'loan_income'
|
||||||
| 'you_follow_contract'
|
| '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;
|
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} {
|
match /{somePath=**}/follows/{followUserId} {
|
||||||
allow read;
|
allow read;
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,6 +18,7 @@ import { TipTxn } from '../../common/txn'
|
||||||
import { Group, GROUP_CHAT_SLUG } from '../../common/group'
|
import { Group, GROUP_CHAT_SLUG } from '../../common/group'
|
||||||
import { Challenge } from '../../common/challenge'
|
import { Challenge } from '../../common/challenge'
|
||||||
import { richTextToString } from '../../common/util/parse'
|
import { richTextToString } from '../../common/util/parse'
|
||||||
|
import { Like } from '../../common/like'
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
type user_to_reason_texts = {
|
type user_to_reason_texts = {
|
||||||
|
@ -689,3 +690,36 @@ export const createBettingStreakBonusNotification = async (
|
||||||
}
|
}
|
||||||
return await notificationRef.set(removeUndefinedProps(notification))
|
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,8 @@ export * from './weekly-markets-emails'
|
||||||
export * from './reset-betting-streaks'
|
export * from './reset-betting-streaks'
|
||||||
export * from './reset-weekly-emails-flag'
|
export * from './reset-weekly-emails-flag'
|
||||||
export * from './on-update-contract-follow'
|
export * from './on-update-contract-follow'
|
||||||
|
export * from './on-create-like'
|
||||||
|
export * from './on-delete-like'
|
||||||
|
|
||||||
// v2
|
// v2
|
||||||
export * from './health'
|
export * from './health'
|
||||||
|
|
71
functions/src/on-create-like.ts
Normal file
71
functions/src/on-create-like.ts
Normal file
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
32
functions/src/on-delete-like.ts
Normal file
32
functions/src/on-delete-like.ts
Normal file
|
@ -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,
|
||||||
|
})
|
||||||
|
}
|
|
@ -2,6 +2,7 @@ import * as functions from 'firebase-functions'
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import { FieldValue } from 'firebase-admin/firestore'
|
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
|
export const onDeleteContractFollow = functions.firestore
|
||||||
.document('contracts/{contractId}/follows/{userId}')
|
.document('contracts/{contractId}/follows/{userId}')
|
||||||
.onDelete(async (change, context) => {
|
.onDelete(async (change, context) => {
|
||||||
|
|
|
@ -20,9 +20,9 @@ import { Modal } from 'web/components/layout/modal'
|
||||||
import { AnswerBetPanel } from 'web/components/answers/answer-bet-panel'
|
import { AnswerBetPanel } from 'web/components/answers/answer-bet-panel'
|
||||||
import { Row } from 'web/components/layout/row'
|
import { Row } from 'web/components/layout/row'
|
||||||
import { Avatar } from 'web/components/avatar'
|
import { Avatar } from 'web/components/avatar'
|
||||||
import { UserLink } from 'web/components/user-page'
|
|
||||||
import { Linkify } from 'web/components/linkify'
|
import { Linkify } from 'web/components/linkify'
|
||||||
import { BuyButton } from 'web/components/yes-no-selector'
|
import { BuyButton } from 'web/components/yes-no-selector'
|
||||||
|
import { UserLink } from 'web/components/user-link'
|
||||||
|
|
||||||
export function AnswersPanel(props: {
|
export function AnswersPanel(props: {
|
||||||
contract: FreeResponseContract | MultipleChoiceContract
|
contract: FreeResponseContract | MultipleChoiceContract
|
||||||
|
|
|
@ -21,7 +21,6 @@ import {
|
||||||
getBinaryProbPercent,
|
getBinaryProbPercent,
|
||||||
} from 'web/lib/firebase/contracts'
|
} from 'web/lib/firebase/contracts'
|
||||||
import { Row } from './layout/row'
|
import { Row } from './layout/row'
|
||||||
import { UserLink } from './user-page'
|
|
||||||
import { sellBet } from 'web/lib/firebase/api'
|
import { sellBet } from 'web/lib/firebase/api'
|
||||||
import { ConfirmationButton } from './confirmation-button'
|
import { ConfirmationButton } from './confirmation-button'
|
||||||
import { OutcomeLabel, YesLabel, NoLabel } from './outcome-label'
|
import { OutcomeLabel, YesLabel, NoLabel } from './outcome-label'
|
||||||
|
@ -48,6 +47,7 @@ import { LimitBet } from 'common/bet'
|
||||||
import { floatingEqual } from 'common/util/math'
|
import { floatingEqual } from 'common/util/math'
|
||||||
import { Pagination } from './pagination'
|
import { Pagination } from './pagination'
|
||||||
import { LimitOrderTable } from './limit-bets'
|
import { LimitOrderTable } from './limit-bets'
|
||||||
|
import { UserLink } from 'web/components/user-link'
|
||||||
import { useUserBetContracts } from 'web/hooks/use-contracts'
|
import { useUserBetContracts } from 'web/hooks/use-contracts'
|
||||||
|
|
||||||
type BetSort = 'newest' | 'profit' | 'closeTime' | 'value'
|
type BetSort = 'newest' | 'profit' | 'closeTime' | 'value'
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import { DonationTxn } from 'common/txn'
|
import { DonationTxn } from 'common/txn'
|
||||||
import { Avatar } from '../avatar'
|
import { Avatar } from '../avatar'
|
||||||
import { useUserById } from 'web/hooks/use-user'
|
import { useUserById } from 'web/hooks/use-user'
|
||||||
import { UserLink } from '../user-page'
|
|
||||||
import { manaToUSD } from '../../../common/util/format'
|
import { manaToUSD } from '../../../common/util/format'
|
||||||
import { RelativeTimestamp } from '../relative-timestamp'
|
import { RelativeTimestamp } from '../relative-timestamp'
|
||||||
|
import { UserLink } from 'web/components/user-link'
|
||||||
|
|
||||||
export function Donation(props: { txn: DonationTxn }) {
|
export function Donation(props: { txn: DonationTxn }) {
|
||||||
const { txn } = props
|
const { txn } = props
|
||||||
|
|
|
@ -6,11 +6,11 @@ import { SiteLink } from './site-link'
|
||||||
import { Row } from './layout/row'
|
import { Row } from './layout/row'
|
||||||
import { Avatar } from './avatar'
|
import { Avatar } from './avatar'
|
||||||
import { RelativeTimestamp } from './relative-timestamp'
|
import { RelativeTimestamp } from './relative-timestamp'
|
||||||
import { UserLink } from './user-page'
|
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
import { Col } from './layout/col'
|
import { Col } from './layout/col'
|
||||||
import { Content } from './editor'
|
import { Content } from './editor'
|
||||||
import { LoadingIndicator } from './loading-indicator'
|
import { LoadingIndicator } from './loading-indicator'
|
||||||
|
import { UserLink } from 'web/components/user-link'
|
||||||
import { PaginationNextPrev } from 'web/components/pagination'
|
import { PaginationNextPrev } from 'web/components/pagination'
|
||||||
|
|
||||||
type ContractKey = {
|
type ContractKey = {
|
||||||
|
|
|
@ -12,7 +12,6 @@ import dayjs from 'dayjs'
|
||||||
|
|
||||||
import { Row } from '../layout/row'
|
import { Row } from '../layout/row'
|
||||||
import { formatMoney } from 'common/util/format'
|
import { formatMoney } from 'common/util/format'
|
||||||
import { UserLink } from '../user-page'
|
|
||||||
import { Contract, updateContract } from 'web/lib/firebase/contracts'
|
import { Contract, updateContract } from 'web/lib/firebase/contracts'
|
||||||
import { DateTimeTooltip } from '../datetime-tooltip'
|
import { DateTimeTooltip } from '../datetime-tooltip'
|
||||||
import { fromNow } from 'web/lib/util/time'
|
import { fromNow } from 'web/lib/util/time'
|
||||||
|
@ -34,6 +33,7 @@ import { groupPath } from 'web/lib/firebase/groups'
|
||||||
import { insertContent } from '../editor/utils'
|
import { insertContent } from '../editor/utils'
|
||||||
import { contractMetrics } from 'common/contract-details'
|
import { contractMetrics } from 'common/contract-details'
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
|
import { UserLink } from 'web/components/user-link'
|
||||||
import { FeaturedContractBadge } from 'web/components/contract/featured-contract-badge'
|
import { FeaturedContractBadge } from 'web/components/contract/featured-contract-badge'
|
||||||
|
|
||||||
export type ShowTime = 'resolve-date' | 'close-date'
|
export type ShowTime = 'resolve-date' | 'close-date'
|
||||||
|
|
|
@ -21,6 +21,7 @@ import { ContractDescription } from './contract-description'
|
||||||
import { ContractDetails } from './contract-details'
|
import { ContractDetails } from './contract-details'
|
||||||
import { NumericGraph } from './numeric-graph'
|
import { NumericGraph } from './numeric-graph'
|
||||||
import { ShareRow } from './share-row'
|
import { ShareRow } from './share-row'
|
||||||
|
import { LikeMarketButton } from 'web/components/contract/like-market-button'
|
||||||
|
|
||||||
export const ContractOverview = (props: {
|
export const ContractOverview = (props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
|
@ -43,6 +44,13 @@ export const ContractOverview = (props: {
|
||||||
<div className="text-2xl text-indigo-700 md:text-3xl">
|
<div className="text-2xl text-indigo-700 md:text-3xl">
|
||||||
<Linkify text={question} />
|
<Linkify text={question} />
|
||||||
</div>
|
</div>
|
||||||
|
{(outcomeType === 'FREE_RESPONSE' ||
|
||||||
|
outcomeType === 'MULTIPLE_CHOICE') &&
|
||||||
|
!resolution && (
|
||||||
|
<div className={'sm:hidden'}>
|
||||||
|
<LikeMarketButton contract={contract} user={user} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Row className={'hidden gap-3 xl:flex'}>
|
<Row className={'hidden gap-3 xl:flex'}>
|
||||||
{isBinary && (
|
{isBinary && (
|
||||||
<BinaryResolutionOrChance
|
<BinaryResolutionOrChance
|
||||||
|
@ -72,28 +80,38 @@ export const ContractOverview = (props: {
|
||||||
<Row className="items-center justify-between gap-4 xl:hidden">
|
<Row className="items-center justify-between gap-4 xl:hidden">
|
||||||
<BinaryResolutionOrChance contract={contract} />
|
<BinaryResolutionOrChance contract={contract} />
|
||||||
{tradingAllowed(contract) && (
|
{tradingAllowed(contract) && (
|
||||||
<Col>
|
<Row>
|
||||||
<BetButton contract={contract as CPMMBinaryContract} />
|
<div className={'sm:hidden'}>
|
||||||
{!user && (
|
<LikeMarketButton contract={contract} user={user} />
|
||||||
<div className="mt-1 text-center text-sm text-gray-500">
|
</div>
|
||||||
(with play money!)
|
<Col>
|
||||||
</div>
|
<BetButton contract={contract as CPMMBinaryContract} />
|
||||||
)}
|
{!user && (
|
||||||
</Col>
|
<div className="mt-1 text-center text-sm text-gray-500">
|
||||||
|
(with play money!)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
)}
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
) : isPseudoNumeric ? (
|
) : isPseudoNumeric ? (
|
||||||
<Row className="items-center justify-between gap-4 xl:hidden">
|
<Row className="items-center justify-between gap-4 xl:hidden">
|
||||||
<PseudoNumericResolutionOrExpectation contract={contract} />
|
<PseudoNumericResolutionOrExpectation contract={contract} />
|
||||||
{tradingAllowed(contract) && (
|
{tradingAllowed(contract) && (
|
||||||
<Col>
|
<Row>
|
||||||
<BetButton contract={contract} />
|
<div className={'sm:hidden'}>
|
||||||
{!user && (
|
<LikeMarketButton contract={contract} user={user} />
|
||||||
<div className="mt-1 text-center text-sm text-gray-500">
|
</div>
|
||||||
(with play money!)
|
<Col>
|
||||||
</div>
|
<BetButton contract={contract} />
|
||||||
)}
|
{!user && (
|
||||||
</Col>
|
<div className="mt-1 text-center text-sm text-gray-500">
|
||||||
|
(with play money!)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
)}
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
) : (
|
) : (
|
||||||
|
|
55
web/components/contract/like-market-button.tsx
Normal file
55
web/components/contract/like-market-button.tsx
Normal file
|
@ -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 <div />
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Button
|
||||||
|
size={'lg'}
|
||||||
|
className={'mb-1'}
|
||||||
|
color={'gray-white'}
|
||||||
|
onClick={onLike}
|
||||||
|
>
|
||||||
|
<Row className={'gap-0 sm:gap-2'}>
|
||||||
|
<HeartIcon
|
||||||
|
className={clsx(
|
||||||
|
'h-6 w-6',
|
||||||
|
likedContractIds?.includes(contract.id)
|
||||||
|
? 'fill-red-500 text-red-500'
|
||||||
|
: ''
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className={'hidden sm:block'}>Tip</span>
|
||||||
|
</Row>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
|
@ -11,6 +11,7 @@ import { CHALLENGES_ENABLED } from 'common/challenge'
|
||||||
import { ShareModal } from './share-modal'
|
import { ShareModal } from './share-modal'
|
||||||
import { withTracking } from 'web/lib/service/analytics'
|
import { withTracking } from 'web/lib/service/analytics'
|
||||||
import { FollowMarketButton } from 'web/components/follow-market-button'
|
import { FollowMarketButton } from 'web/components/follow-market-button'
|
||||||
|
import { LikeMarketButton } from 'web/components/contract/like-market-button'
|
||||||
|
|
||||||
export function ShareRow(props: {
|
export function ShareRow(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
|
@ -64,6 +65,9 @@ export function ShareRow(props: {
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<FollowMarketButton contract={contract} user={user} />
|
<FollowMarketButton contract={contract} user={user} />
|
||||||
|
<div className={'hidden sm:block'}>
|
||||||
|
<LikeMarketButton contract={contract} user={user} />
|
||||||
|
</div>
|
||||||
</Row>
|
</Row>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,7 +6,6 @@ import React, { useEffect, useState } from 'react'
|
||||||
import { Col } from 'web/components/layout/col'
|
import { Col } from 'web/components/layout/col'
|
||||||
import { Row } from 'web/components/layout/row'
|
import { Row } from 'web/components/layout/row'
|
||||||
import { Avatar } from 'web/components/avatar'
|
import { Avatar } from 'web/components/avatar'
|
||||||
import { UserLink } from 'web/components/user-page'
|
|
||||||
import { Linkify } from 'web/components/linkify'
|
import { Linkify } from 'web/components/linkify'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import {
|
import {
|
||||||
|
@ -20,6 +19,7 @@ import { Dictionary } from 'lodash'
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
import { useEvent } from 'web/hooks/use-event'
|
import { useEvent } from 'web/hooks/use-event'
|
||||||
import { CommentTipMap } from 'web/hooks/use-tip-txns'
|
import { CommentTipMap } from 'web/hooks/use-tip-txns'
|
||||||
|
import { UserLink } from 'web/components/user-link'
|
||||||
|
|
||||||
export function FeedAnswerCommentGroup(props: {
|
export function FeedAnswerCommentGroup(props: {
|
||||||
contract: FreeResponseContract
|
contract: FreeResponseContract
|
||||||
|
|
|
@ -10,11 +10,11 @@ import { formatMoney, formatPercent } from 'common/util/format'
|
||||||
import { OutcomeLabel } from 'web/components/outcome-label'
|
import { OutcomeLabel } from 'web/components/outcome-label'
|
||||||
import { RelativeTimestamp } from 'web/components/relative-timestamp'
|
import { RelativeTimestamp } from 'web/components/relative-timestamp'
|
||||||
import React, { useEffect } from 'react'
|
import React, { useEffect } from 'react'
|
||||||
import { UserLink } from '../user-page'
|
|
||||||
import { formatNumericProbability } from 'common/pseudo-numeric'
|
import { formatNumericProbability } from 'common/pseudo-numeric'
|
||||||
import { SiteLink } from 'web/components/site-link'
|
import { SiteLink } from 'web/components/site-link'
|
||||||
import { getChallenge, getChallengeUrl } from 'web/lib/firebase/challenges'
|
import { getChallenge, getChallengeUrl } from 'web/lib/firebase/challenges'
|
||||||
import { Challenge } from 'common/challenge'
|
import { Challenge } from 'common/challenge'
|
||||||
|
import { UserLink } from 'web/components/user-link'
|
||||||
|
|
||||||
export function FeedBet(props: { contract: Contract; bet: Bet }) {
|
export function FeedBet(props: { contract: Contract; bet: Bet }) {
|
||||||
const { contract, bet } = props
|
const { contract, bet } = props
|
||||||
|
|
|
@ -10,7 +10,6 @@ import { useRouter } from 'next/router'
|
||||||
import { Row } from 'web/components/layout/row'
|
import { Row } from 'web/components/layout/row'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { Avatar } from 'web/components/avatar'
|
import { Avatar } from 'web/components/avatar'
|
||||||
import { UserLink } from 'web/components/user-page'
|
|
||||||
import { OutcomeLabel } from 'web/components/outcome-label'
|
import { OutcomeLabel } from 'web/components/outcome-label'
|
||||||
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
|
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
|
||||||
import { firebaseLogin } from 'web/lib/firebase/users'
|
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 { useWindowSize } from 'web/hooks/use-window-size'
|
||||||
import { Content, TextEditor, useTextEditor } from '../editor'
|
import { Content, TextEditor, useTextEditor } from '../editor'
|
||||||
import { Editor } from '@tiptap/react'
|
import { Editor } from '@tiptap/react'
|
||||||
|
import { UserLink } from 'web/components/user-link'
|
||||||
|
|
||||||
export function FeedCommentThread(props: {
|
export function FeedCommentThread(props: {
|
||||||
user: User | null | undefined
|
user: User | null | undefined
|
||||||
|
|
|
@ -6,8 +6,8 @@ import { Avatar, EmptyAvatar } from 'web/components/avatar'
|
||||||
import { formatMoney } from 'common/util/format'
|
import { formatMoney } from 'common/util/format'
|
||||||
import { RelativeTimestamp } from 'web/components/relative-timestamp'
|
import { RelativeTimestamp } from 'web/components/relative-timestamp'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { UserLink } from '../user-page'
|
|
||||||
import { LiquidityProvision } from 'common/liquidity-provision'
|
import { LiquidityProvision } from 'common/liquidity-provision'
|
||||||
|
import { UserLink } from 'web/components/user-link'
|
||||||
|
|
||||||
export function FeedLiquidity(props: {
|
export function FeedLiquidity(props: {
|
||||||
className?: string
|
className?: string
|
||||||
|
|
|
@ -6,8 +6,8 @@ import clsx from 'clsx'
|
||||||
import { Menu, Transition } from '@headlessui/react'
|
import { Menu, Transition } from '@headlessui/react'
|
||||||
import { Avatar } from 'web/components/avatar'
|
import { Avatar } from 'web/components/avatar'
|
||||||
import { Row } from 'web/components/layout/row'
|
import { Row } from 'web/components/layout/row'
|
||||||
import { UserLink } from 'web/components/user-page'
|
|
||||||
import { searchInAny } from 'common/util/parse'
|
import { searchInAny } from 'common/util/parse'
|
||||||
|
import { UserLink } from 'web/components/user-link'
|
||||||
|
|
||||||
export function FilterSelectUsers(props: {
|
export function FilterSelectUsers(props: {
|
||||||
setSelectedUsers: (users: User[]) => void
|
setSelectedUsers: (users: User[]) => void
|
||||||
|
|
|
@ -6,7 +6,7 @@ import { Avatar } from './avatar'
|
||||||
import { FollowButton } from './follow-button'
|
import { FollowButton } from './follow-button'
|
||||||
import { Col } from './layout/col'
|
import { Col } from './layout/col'
|
||||||
import { Row } from './layout/row'
|
import { Row } from './layout/row'
|
||||||
import { UserLink } from './user-page'
|
import { UserLink } from 'web/components/user-link'
|
||||||
|
|
||||||
export function FollowList(props: { userIds: string[] }) {
|
export function FollowList(props: { userIds: string[] }) {
|
||||||
const { userIds } = props
|
const { userIds } = props
|
||||||
|
|
|
@ -11,7 +11,6 @@ import { track } from 'web/lib/service/analytics'
|
||||||
import { firebaseLogin } from 'web/lib/firebase/users'
|
import { firebaseLogin } from 'web/lib/firebase/users'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { UserLink } from 'web/components/user-page'
|
|
||||||
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
|
import { CopyLinkDateTimeComponent } from 'web/components/feed/copy-link-date-time'
|
||||||
import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns'
|
import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns'
|
||||||
import { Tipper } from 'web/components/tipper'
|
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 { ChevronDownIcon, UsersIcon } from '@heroicons/react/outline'
|
||||||
import { setNotificationsAsSeen } from 'web/pages/notifications'
|
import { setNotificationsAsSeen } from 'web/pages/notifications'
|
||||||
import { usePrivateUser } from 'web/hooks/use-user'
|
import { usePrivateUser } from 'web/hooks/use-user'
|
||||||
|
import { UserLink } from 'web/components/user-link'
|
||||||
|
|
||||||
export function GroupChat(props: {
|
export function GroupChat(props: {
|
||||||
messages: GroupComment[]
|
messages: GroupComment[]
|
||||||
|
|
|
@ -2,13 +2,13 @@ import clsx from 'clsx'
|
||||||
import { Avatar } from './avatar'
|
import { Avatar } from './avatar'
|
||||||
import { Col } from './layout/col'
|
import { Col } from './layout/col'
|
||||||
import { Row } from './layout/row'
|
import { Row } from './layout/row'
|
||||||
import { UserLink } from './user-page'
|
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
import { UserCircleIcon } from '@heroicons/react/solid'
|
import { UserCircleIcon } from '@heroicons/react/solid'
|
||||||
import { useUsers } from 'web/hooks/use-users'
|
import { useUsers } from 'web/hooks/use-users'
|
||||||
import { partition } from 'lodash'
|
import { partition } from 'lodash'
|
||||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
import { useWindowSize } from 'web/hooks/use-window-size'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { UserLink } from 'web/components/user-link'
|
||||||
|
|
||||||
const isOnline = (user?: User) =>
|
const isOnline = (user?: User) =>
|
||||||
user && user.lastPingTime && user.lastPingTime > Date.now() - 5 * 60 * 1000
|
user && user.lastPingTime && user.lastPingTime > Date.now() - 5 * 60 * 1000
|
||||||
|
|
48
web/components/profile/user-likes-button.tsx
Normal file
48
web/components/profile/user-likes-button.tsx
Normal file
|
@ -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 (
|
||||||
|
<>
|
||||||
|
<TextButton onClick={() => setIsOpen(true)}>
|
||||||
|
<span className="font-semibold">{likedContracts?.length ?? ''}</span>{' '}
|
||||||
|
Likes
|
||||||
|
</TextButton>
|
||||||
|
<Modal open={isOpen} setOpen={setIsOpen}>
|
||||||
|
<Col className="rounded bg-white p-6">
|
||||||
|
<span className={'mb-4 text-xl'}>Liked Markets</span>
|
||||||
|
<Col className={'gap-4'}>
|
||||||
|
{likedContracts?.map((likedContract) => (
|
||||||
|
<Row key={likedContract.id} className={'justify-between gap-2'}>
|
||||||
|
<SiteLink
|
||||||
|
href={contractPath(likedContract)}
|
||||||
|
className={'truncate text-indigo-700'}
|
||||||
|
>
|
||||||
|
{likedContract.question}
|
||||||
|
</SiteLink>
|
||||||
|
<XIcon
|
||||||
|
className="ml-2 h-5 w-5 shrink-0 cursor-pointer"
|
||||||
|
onClick={() => unLikeContract(user.id, likedContract.id)}
|
||||||
|
/>
|
||||||
|
</Row>
|
||||||
|
))}
|
||||||
|
</Col>
|
||||||
|
</Col>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
|
@ -7,11 +7,11 @@ import { Modal } from './layout/modal'
|
||||||
import { Tabs } from './layout/tabs'
|
import { Tabs } from './layout/tabs'
|
||||||
import { Row } from 'web/components/layout/row'
|
import { Row } from 'web/components/layout/row'
|
||||||
import { Avatar } from 'web/components/avatar'
|
import { Avatar } from 'web/components/avatar'
|
||||||
import { UserLink } from 'web/components/user-page'
|
|
||||||
import { useReferrals } from 'web/hooks/use-referrals'
|
import { useReferrals } from 'web/hooks/use-referrals'
|
||||||
import { FilterSelectUsers } from 'web/components/filter-select-users'
|
import { FilterSelectUsers } from 'web/components/filter-select-users'
|
||||||
import { getUser, updateUser } from 'web/lib/firebase/users'
|
import { getUser, updateUser } from 'web/lib/firebase/users'
|
||||||
import { TextButton } from 'web/components/text-button'
|
import { TextButton } from 'web/components/text-button'
|
||||||
|
import { UserLink } from 'web/components/user-link'
|
||||||
|
|
||||||
export function ReferralsButton(props: { user: User; currentUser?: User }) {
|
export function ReferralsButton(props: { user: User; currentUser?: User }) {
|
||||||
const { user, currentUser } = props
|
const { user, currentUser } = props
|
||||||
|
|
102
web/components/user-link.tsx
Normal file
102
web/components/user-link.tsx
Normal file
|
@ -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 (
|
||||||
|
<SiteLink
|
||||||
|
href={`/${username}`}
|
||||||
|
className={clsx('z-10 truncate', className)}
|
||||||
|
>
|
||||||
|
{shortName}
|
||||||
|
{showUsername && ` (@${username})`}
|
||||||
|
</SiteLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<Row
|
||||||
|
className={clsx('mr-1 inline-flex gap-1', linkClass, className)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{userInfos.map((userInfo, index) =>
|
||||||
|
index < maxShowCount ? (
|
||||||
|
<span key={userInfo.username + 'shortened'} className={linkClass}>
|
||||||
|
{shortenName(userInfo.name) +
|
||||||
|
(index < maxShowCount - 1 ? ', ' : '')}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className={linkClass}>
|
||||||
|
& {userInfos.length - maxShowCount} more
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
<Modal open={open} setOpen={setOpen} size={'sm'}>
|
||||||
|
<Col className="items-start gap-4 rounded-md bg-white p-6">
|
||||||
|
<span className={'text-xl'}>Who tipped you</span>
|
||||||
|
{userInfos.map((userInfo) => (
|
||||||
|
<Row
|
||||||
|
key={userInfo.username + 'list'}
|
||||||
|
className="w-full items-center gap-2"
|
||||||
|
>
|
||||||
|
<span className="text-primary min-w-[3.5rem]">
|
||||||
|
+{formatMoney(userInfo.amountTipped)}
|
||||||
|
</span>
|
||||||
|
<Avatar
|
||||||
|
username={userInfo.username}
|
||||||
|
avatarUrl={userInfo.avatarUrl}
|
||||||
|
/>
|
||||||
|
<UserLink name={userInfo.name} username={userInfo.username} />
|
||||||
|
</Row>
|
||||||
|
))}
|
||||||
|
</Col>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
|
@ -31,35 +31,7 @@ import { ENV_CONFIG } from 'common/envs/constants'
|
||||||
import { BettingStreakModal } from 'web/components/profile/betting-streak-modal'
|
import { BettingStreakModal } from 'web/components/profile/betting-streak-modal'
|
||||||
import { REFERRAL_AMOUNT } from 'common/economy'
|
import { REFERRAL_AMOUNT } from 'common/economy'
|
||||||
import { LoansModal } from './profile/loans-modal'
|
import { LoansModal } from './profile/loans-modal'
|
||||||
|
import { UserLikesButton } from 'web/components/profile/user-likes-button'
|
||||||
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 (
|
|
||||||
<SiteLink
|
|
||||||
href={`/${username}`}
|
|
||||||
className={clsx('z-10 truncate', className)}
|
|
||||||
>
|
|
||||||
{short ? shortName : name}
|
|
||||||
{showUsername && ` (@${username})`}
|
|
||||||
</SiteLink>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UserPage(props: { user: User }) {
|
export function UserPage(props: { user: User }) {
|
||||||
const { user } = props
|
const { user } = props
|
||||||
|
@ -302,6 +274,7 @@ export function UserPage(props: { user: User }) {
|
||||||
<FollowersButton user={user} />
|
<FollowersButton user={user} />
|
||||||
<ReferralsButton user={user} />
|
<ReferralsButton user={user} />
|
||||||
<GroupsButton user={user} />
|
<GroupsButton user={user} />
|
||||||
|
<UserLikesButton user={user} />
|
||||||
</Row>
|
</Row>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
38
web/hooks/use-likes.ts
Normal file
38
web/hooks/use-likes.ts
Normal file
|
@ -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<Like[] | undefined>()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (userId) return listenForLikes(userId, setContractIds)
|
||||||
|
}, [userId])
|
||||||
|
|
||||||
|
return contractIds
|
||||||
|
}
|
||||||
|
export const useUserLikedContracts = (userId: string | undefined) => {
|
||||||
|
const [likes, setLikes] = useState<Like[] | undefined>()
|
||||||
|
const [contracts, setContracts] = useState<Contract[] | undefined>()
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
|
@ -63,7 +63,13 @@ export function groupNotifications(notifications: Notification[]) {
|
||||||
const notificationGroupsByDay = groupBy(notifications, (notification) =>
|
const notificationGroupsByDay = groupBy(notifications, (notification) =>
|
||||||
new Date(notification.createdTime).toDateString()
|
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) => {
|
Object.keys(notificationGroupsByDay).forEach((day) => {
|
||||||
const notificationsGroupedByDay = notificationGroupsByDay[day]
|
const notificationsGroupedByDay = notificationGroupsByDay[day]
|
||||||
|
|
54
web/lib/firebase/likes.ts
Normal file
54
web/lib/firebase/likes.ts
Normal file
|
@ -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)
|
||||||
|
}
|
|
@ -28,6 +28,7 @@ import utc from 'dayjs/plugin/utc'
|
||||||
dayjs.extend(utc)
|
dayjs.extend(utc)
|
||||||
|
|
||||||
import { track } from '@amplitude/analytics-browser'
|
import { track } from '@amplitude/analytics-browser'
|
||||||
|
import { Like } from 'common/like'
|
||||||
|
|
||||||
export const users = coll<User>('users')
|
export const users = coll<User>('users')
|
||||||
export const privateUsers = coll<PrivateUser>('private-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))
|
||||||
|
}
|
||||||
|
|
|
@ -21,7 +21,6 @@ import { Page } from 'web/components/page'
|
||||||
import { useUser, useUserById } from 'web/hooks/use-user'
|
import { useUser, useUserById } from 'web/hooks/use-user'
|
||||||
import { AcceptChallengeButton } from 'web/components/challenges/accept-challenge-button'
|
import { AcceptChallengeButton } from 'web/components/challenges/accept-challenge-button'
|
||||||
import { Avatar } from 'web/components/avatar'
|
import { Avatar } from 'web/components/avatar'
|
||||||
import { UserLink } from 'web/components/user-page'
|
|
||||||
import { BinaryOutcomeLabel } from 'web/components/outcome-label'
|
import { BinaryOutcomeLabel } from 'web/components/outcome-label'
|
||||||
import { formatMoney } from 'common/util/format'
|
import { formatMoney } from 'common/util/format'
|
||||||
import { LoadingIndicator } from 'web/components/loading-indicator'
|
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 { BinaryContract } from 'common/contract'
|
||||||
import { Title } from 'web/components/title'
|
import { Title } from 'web/components/title'
|
||||||
import { getOpenGraphProps } from 'common/contract-details'
|
import { getOpenGraphProps } from 'common/contract-details'
|
||||||
|
import { UserLink } from 'web/components/user-link'
|
||||||
|
|
||||||
export const getStaticProps = fromPropz(getStaticPropz)
|
export const getStaticProps = fromPropz(getStaticPropz)
|
||||||
|
|
||||||
|
|
|
@ -19,7 +19,6 @@ import {
|
||||||
import { Challenge, CHALLENGES_ENABLED } from 'common/challenge'
|
import { Challenge, CHALLENGES_ENABLED } from 'common/challenge'
|
||||||
import { Tabs } from 'web/components/layout/tabs'
|
import { Tabs } from 'web/components/layout/tabs'
|
||||||
import { SiteLink } from 'web/components/site-link'
|
import { SiteLink } from 'web/components/site-link'
|
||||||
import { UserLink } from 'web/components/user-page'
|
|
||||||
import { Avatar } from 'web/components/avatar'
|
import { Avatar } from 'web/components/avatar'
|
||||||
import Router from 'next/router'
|
import Router from 'next/router'
|
||||||
import { contractPathWithoutContract } from 'web/lib/firebase/contracts'
|
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 { Modal } from 'web/components/layout/modal'
|
||||||
import { QRCode } from 'web/components/qr-code'
|
import { QRCode } from 'web/components/qr-code'
|
||||||
import { CreateChallengeModal } from 'web/components/challenges/create-challenge-modal'
|
import { CreateChallengeModal } from 'web/components/challenges/create-challenge-modal'
|
||||||
|
import { UserLink } from 'web/components/user-link'
|
||||||
|
|
||||||
dayjs.extend(customParseFormat)
|
dayjs.extend(customParseFormat)
|
||||||
const columnClass = 'sm:px-5 px-2 py-3.5 max-w-[100px] truncate'
|
const columnClass = 'sm:px-5 px-2 py-3.5 max-w-[100px] truncate'
|
||||||
|
|
|
@ -17,7 +17,6 @@ import {
|
||||||
updateGroup,
|
updateGroup,
|
||||||
} from 'web/lib/firebase/groups'
|
} from 'web/lib/firebase/groups'
|
||||||
import { Row } from 'web/components/layout/row'
|
import { Row } from 'web/components/layout/row'
|
||||||
import { UserLink } from 'web/components/user-page'
|
|
||||||
import { firebaseLogin, getUser, User } from 'web/lib/firebase/users'
|
import { firebaseLogin, getUser, User } from 'web/lib/firebase/users'
|
||||||
import { Col } from 'web/components/layout/col'
|
import { Col } from 'web/components/layout/col'
|
||||||
import { useUser } from 'web/hooks/use-user'
|
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 { listAllCommentsOnGroup } from 'web/lib/firebase/comments'
|
||||||
import { GroupComment } from 'common/comment'
|
import { GroupComment } from 'common/comment'
|
||||||
import { REFERRAL_AMOUNT } from 'common/economy'
|
import { REFERRAL_AMOUNT } from 'common/economy'
|
||||||
|
import { UserLink } from 'web/components/user-link'
|
||||||
import { GroupAboutPost } from 'web/components/groups/group-about-post'
|
import { GroupAboutPost } from 'web/components/groups/group-about-post'
|
||||||
import { getPost } from 'web/lib/firebase/posts'
|
import { getPost } from 'web/lib/firebase/posts'
|
||||||
import { Post } from 'common/post'
|
import { Post } from 'common/post'
|
||||||
|
|
|
@ -16,9 +16,9 @@ import { SiteLink } from 'web/components/site-link'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { Avatar } from 'web/components/avatar'
|
import { Avatar } from 'web/components/avatar'
|
||||||
import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button'
|
import { JoinOrLeaveGroupButton } from 'web/components/groups/groups-button'
|
||||||
import { UserLink } from 'web/components/user-page'
|
|
||||||
import { searchInAny } from 'common/util/parse'
|
import { searchInAny } from 'common/util/parse'
|
||||||
import { SEO } from 'web/components/SEO'
|
import { SEO } from 'web/components/SEO'
|
||||||
|
import { UserLink } from 'web/components/user-link'
|
||||||
|
|
||||||
export async function getStaticProps() {
|
export async function getStaticProps() {
|
||||||
const groups = await listAllGroups().catch((_) => [])
|
const groups = await listAllGroups().catch((_) => [])
|
||||||
|
|
|
@ -18,7 +18,6 @@ import { ManalinkTxn } from 'common/txn'
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
import { Avatar } from 'web/components/avatar'
|
import { Avatar } from 'web/components/avatar'
|
||||||
import { RelativeTimestamp } from 'web/components/relative-timestamp'
|
import { RelativeTimestamp } from 'web/components/relative-timestamp'
|
||||||
import { UserLink } from 'web/components/user-page'
|
|
||||||
import { CreateLinksButton } from 'web/components/manalinks/create-links-button'
|
import { CreateLinksButton } from 'web/components/manalinks/create-links-button'
|
||||||
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
|
import { redirectIfLoggedOut } from 'web/lib/firebase/server-auth'
|
||||||
|
|
||||||
|
@ -27,6 +26,7 @@ import { Pagination } from 'web/components/pagination'
|
||||||
import { Manalink } from 'common/manalink'
|
import { Manalink } from 'common/manalink'
|
||||||
import { SiteLink } from 'web/components/site-link'
|
import { SiteLink } from 'web/components/site-link'
|
||||||
import { REFERRAL_AMOUNT } from 'common/economy'
|
import { REFERRAL_AMOUNT } from 'common/economy'
|
||||||
|
import { UserLink } from 'web/components/user-link'
|
||||||
|
|
||||||
const LINKS_PER_PAGE = 24
|
const LINKS_PER_PAGE = 24
|
||||||
|
|
||||||
|
|
|
@ -7,7 +7,6 @@ import { Page } from 'web/components/page'
|
||||||
import { Title } from 'web/components/title'
|
import { Title } from 'web/components/title'
|
||||||
import { doc, updateDoc } from 'firebase/firestore'
|
import { doc, updateDoc } from 'firebase/firestore'
|
||||||
import { db } from 'web/lib/firebase/init'
|
import { db } from 'web/lib/firebase/init'
|
||||||
import { UserLink } from 'web/components/user-page'
|
|
||||||
import {
|
import {
|
||||||
MANIFOLD_AVATAR_URL,
|
MANIFOLD_AVATAR_URL,
|
||||||
MANIFOLD_USERNAME,
|
MANIFOLD_USERNAME,
|
||||||
|
@ -35,7 +34,7 @@ import {
|
||||||
BETTING_STREAK_BONUS_AMOUNT,
|
BETTING_STREAK_BONUS_AMOUNT,
|
||||||
UNIQUE_BETTOR_BONUS_AMOUNT,
|
UNIQUE_BETTOR_BONUS_AMOUNT,
|
||||||
} from 'common/economy'
|
} from 'common/economy'
|
||||||
import { groupBy, sum, uniq } from 'lodash'
|
import { groupBy, sum, uniqBy } from 'lodash'
|
||||||
import { track } from '@amplitude/analytics-browser'
|
import { track } from '@amplitude/analytics-browser'
|
||||||
import { Pagination } from 'web/components/pagination'
|
import { Pagination } from 'web/components/pagination'
|
||||||
import { useWindowSize } from 'web/hooks/use-window-size'
|
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 { NotificationSettings } from 'web/components/NotificationSettings'
|
||||||
import { SEO } from 'web/components/SEO'
|
import { SEO } from 'web/components/SEO'
|
||||||
import { useUser } from 'web/hooks/use-user'
|
import { useUser } from 'web/hooks/use-user'
|
||||||
|
import {
|
||||||
|
MultiUserTipLink,
|
||||||
|
MultiUserLinkInfo,
|
||||||
|
UserLink,
|
||||||
|
} from 'web/components/user-link'
|
||||||
import { LoadingIndicator } from 'web/components/loading-indicator'
|
import { LoadingIndicator } from 'web/components/loading-indicator'
|
||||||
|
|
||||||
export const NOTIFICATIONS_PER_PAGE = 30
|
export const NOTIFICATIONS_PER_PAGE = 30
|
||||||
const MULTIPLE_USERS_KEY = 'multipleUsers'
|
|
||||||
const HIGHLIGHT_CLASS = 'bg-indigo-50'
|
const HIGHLIGHT_CLASS = 'bg-indigo-50'
|
||||||
|
|
||||||
export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => {
|
export const getServerSideProps = redirectIfLoggedOut('/', async (_, creds) => {
|
||||||
|
@ -233,13 +236,26 @@ function IncomeNotificationGroupItem(props: {
|
||||||
let sum = 0
|
let sum = 0
|
||||||
notificationsForSourceTitle.forEach(
|
notificationsForSourceTitle.forEach(
|
||||||
(notification) =>
|
(notification) =>
|
||||||
notification.sourceText &&
|
(sum = parseInt(notification.sourceText ?? '0') + sum)
|
||||||
(sum = parseInt(notification.sourceText) + sum)
|
|
||||||
)
|
)
|
||||||
const uniqueUsers = uniq(
|
const uniqueUsers = uniqBy(
|
||||||
notificationsForSourceTitle.map((notification) => {
|
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 = {
|
const newNotification = {
|
||||||
|
@ -247,7 +263,7 @@ function IncomeNotificationGroupItem(props: {
|
||||||
sourceText: sum.toString(),
|
sourceText: sum.toString(),
|
||||||
sourceUserUsername:
|
sourceUserUsername:
|
||||||
uniqueUsers.length > 1
|
uniqueUsers.length > 1
|
||||||
? MULTIPLE_USERS_KEY
|
? JSON.stringify(uniqueUsers)
|
||||||
: notificationsForSourceTitle[0].sourceType,
|
: notificationsForSourceTitle[0].sourceType,
|
||||||
}
|
}
|
||||||
newNotifications.push(newNotification)
|
newNotifications.push(newNotification)
|
||||||
|
@ -385,6 +401,9 @@ function IncomeNotificationItem(props: {
|
||||||
else reasonText = 'for your'
|
else reasonText = 'for your'
|
||||||
} else if (sourceType === 'loan' && sourceText) {
|
} else if (sourceType === 'loan' && sourceText) {
|
||||||
reasonText = `of your invested bets returned as a`
|
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 =
|
const streakInDays =
|
||||||
|
@ -493,9 +512,11 @@ function IncomeNotificationItem(props: {
|
||||||
<span className={'mr-1'}>{incomeNotificationLabel()}</span>
|
<span className={'mr-1'}>{incomeNotificationLabel()}</span>
|
||||||
</div>
|
</div>
|
||||||
<span>
|
<span>
|
||||||
{sourceType === 'tip' &&
|
{(sourceType === 'tip' || sourceType === 'tip_and_like') &&
|
||||||
(sourceUserUsername === MULTIPLE_USERS_KEY ? (
|
(sourceUserUsername?.includes(',') ? (
|
||||||
<span className={'mr-1 truncate'}>Multiple users</span>
|
<MultiUserTipLink
|
||||||
|
userInfos={JSON.parse(sourceUserUsername)}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<UserLink
|
<UserLink
|
||||||
name={sourceUserName || ''}
|
name={sourceUserName || ''}
|
||||||
|
|
|
@ -5,7 +5,6 @@ import { Post } from 'common/post'
|
||||||
import { Title } from 'web/components/title'
|
import { Title } from 'web/components/title'
|
||||||
import { Spacer } from 'web/components/layout/spacer'
|
import { Spacer } from 'web/components/layout/spacer'
|
||||||
import { Content } from 'web/components/editor'
|
import { Content } from 'web/components/editor'
|
||||||
import { UserLink } from 'web/components/user-page'
|
|
||||||
import { getUser, User } from 'web/lib/firebase/users'
|
import { getUser, User } from 'web/lib/firebase/users'
|
||||||
import { ShareIcon } from '@heroicons/react/solid'
|
import { ShareIcon } from '@heroicons/react/solid'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
@ -16,6 +15,7 @@ import { Row } from 'web/components/layout/row'
|
||||||
import { Col } from 'web/components/layout/col'
|
import { Col } from 'web/components/layout/col'
|
||||||
import { ENV_CONFIG } from 'common/envs/constants'
|
import { ENV_CONFIG } from 'common/envs/constants'
|
||||||
import Custom404 from 'web/pages/404'
|
import Custom404 from 'web/pages/404'
|
||||||
|
import { UserLink } from 'web/components/user-link'
|
||||||
|
|
||||||
export async function getStaticProps(props: { params: { slugs: string[] } }) {
|
export async function getStaticProps(props: { params: { slugs: string[] } }) {
|
||||||
const { slugs } = props.params
|
const { slugs } = props.params
|
||||||
|
|
Loading…
Reference in New Issue
Block a user