Comment tips (attempt 2) (#539)
* Add tip arrows UI (visual) * move tipper into its own component * simplify score calculation * Add tip txns - more specific txn types - fix transact cloud function to be able to create tip txns - insert tips into comments via a context * Refactor tipper to send tip txns * Stop tipping yourself. Disable anons. * Style tipper (smaller) * remove default exports * capitalize tooltips * rename stuff * add exhausting hook dependencies * replace context with prop threading * fix eslint unused vars * fix: thread tips correctly into fr comments
This commit is contained in:
parent
bb934d8390
commit
833dd37469
|
@ -13,9 +13,27 @@ export type Txn = {
|
||||||
amount: number
|
amount: number
|
||||||
token: 'M$' // | 'USD' | MarketOutcome
|
token: 'M$' // | 'USD' | MarketOutcome
|
||||||
|
|
||||||
category: 'CHARITY' // | 'BET' | 'TIP'
|
category: 'CHARITY' | 'TIP' // | 'BET'
|
||||||
|
// Any extra data
|
||||||
|
data?: { [key: string]: any }
|
||||||
// Human-readable description
|
// Human-readable description
|
||||||
description?: string
|
description?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK'
|
export type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK'
|
||||||
|
|
||||||
|
export type DonationTxn = Omit<Txn, 'data'> & {
|
||||||
|
fromType: 'USER'
|
||||||
|
toType: 'CHARITY'
|
||||||
|
category: 'CHARITY'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TipTxn = Txn & {
|
||||||
|
fromType: 'USER'
|
||||||
|
toType: 'USER'
|
||||||
|
category: 'TIP'
|
||||||
|
data: {
|
||||||
|
contractId: string
|
||||||
|
commentId: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -11,7 +11,17 @@ export const transact = functions
|
||||||
const userId = context?.auth?.uid
|
const userId = context?.auth?.uid
|
||||||
if (!userId) return { status: 'error', message: 'Not authorized' }
|
if (!userId) return { status: 'error', message: 'Not authorized' }
|
||||||
|
|
||||||
const { amount, fromType, fromId, toId, toType, description } = data
|
const {
|
||||||
|
amount,
|
||||||
|
fromType,
|
||||||
|
fromId,
|
||||||
|
toId,
|
||||||
|
toType,
|
||||||
|
category,
|
||||||
|
token,
|
||||||
|
data: innerData,
|
||||||
|
description,
|
||||||
|
} = data
|
||||||
|
|
||||||
if (fromType !== 'USER')
|
if (fromType !== 'USER')
|
||||||
return {
|
return {
|
||||||
|
@ -25,7 +35,7 @@ export const transact = functions
|
||||||
message: 'Must be authenticated with userId equal to specified fromId.',
|
message: 'Must be authenticated with userId equal to specified fromId.',
|
||||||
}
|
}
|
||||||
|
|
||||||
if (amount <= 0 || isNaN(amount) || !isFinite(amount))
|
if (isNaN(amount) || !isFinite(amount))
|
||||||
return { status: 'error', message: 'Invalid amount' }
|
return { status: 'error', message: 'Invalid amount' }
|
||||||
|
|
||||||
// Run as transaction to prevent race conditions.
|
// Run as transaction to prevent race conditions.
|
||||||
|
@ -69,9 +79,10 @@ export const transact = functions
|
||||||
toType,
|
toType,
|
||||||
|
|
||||||
amount,
|
amount,
|
||||||
// TODO: Unhardcode once we have non-donation txns
|
category,
|
||||||
token: 'M$',
|
data: innerData,
|
||||||
category: 'CHARITY',
|
token,
|
||||||
|
|
||||||
description,
|
description,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import { Txn } from 'common/txn'
|
import { DonationTxn } from 'common/txn'
|
||||||
import { Avatar } from '../avatar'
|
import { Avatar } from '../avatar'
|
||||||
import { useUserById } from 'web/hooks/use-users'
|
import { useUserById } from 'web/hooks/use-users'
|
||||||
import { UserLink } from '../user-page'
|
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'
|
||||||
|
|
||||||
export function Donation(props: { txn: Txn }) {
|
export function Donation(props: { txn: DonationTxn }) {
|
||||||
const { txn } = props
|
const { txn } = props
|
||||||
const user = useUserById(txn.fromId)
|
const user = useUserById(txn.fromId)
|
||||||
|
|
||||||
|
|
|
@ -13,7 +13,6 @@ import {
|
||||||
NumericResolutionOrExpectation,
|
NumericResolutionOrExpectation,
|
||||||
} from './contract-card'
|
} from './contract-card'
|
||||||
import { Bet } from 'common/bet'
|
import { Bet } from 'common/bet'
|
||||||
import { Comment } from 'common/comment'
|
|
||||||
import BetRow from '../bet-row'
|
import BetRow from '../bet-row'
|
||||||
import { AnswersGraph } from '../answers/answers-graph'
|
import { AnswersGraph } from '../answers/answers-graph'
|
||||||
import { Contract } from 'common/contract'
|
import { Contract } from 'common/contract'
|
||||||
|
@ -25,7 +24,6 @@ import { NumericGraph } from './numeric-graph'
|
||||||
export const ContractOverview = (props: {
|
export const ContractOverview = (props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
bets: Bet[]
|
bets: Bet[]
|
||||||
comments: Comment[]
|
|
||||||
className?: string
|
className?: string
|
||||||
}) => {
|
}) => {
|
||||||
const { contract, bets, className } = props
|
const { contract, bets, className } = props
|
||||||
|
|
|
@ -7,14 +7,16 @@ import { ContractBetsTable, BetsSummary } from '../bets-list'
|
||||||
import { Spacer } from '../layout/spacer'
|
import { Spacer } from '../layout/spacer'
|
||||||
import { Tabs } from '../layout/tabs'
|
import { Tabs } from '../layout/tabs'
|
||||||
import { Col } from '../layout/col'
|
import { Col } from '../layout/col'
|
||||||
|
import { CommentTipMap } from 'web/hooks/use-tip-txns'
|
||||||
|
|
||||||
export function ContractTabs(props: {
|
export function ContractTabs(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
user: User | null | undefined
|
user: User | null | undefined
|
||||||
bets: Bet[]
|
bets: Bet[]
|
||||||
comments: Comment[]
|
comments: Comment[]
|
||||||
|
tips: CommentTipMap
|
||||||
}) {
|
}) {
|
||||||
const { contract, user, bets, comments } = props
|
const { contract, user, bets, comments, tips } = props
|
||||||
const { outcomeType } = contract
|
const { outcomeType } = contract
|
||||||
|
|
||||||
const userBets = user && bets.filter((bet) => bet.userId === user.id)
|
const userBets = user && bets.filter((bet) => bet.userId === user.id)
|
||||||
|
@ -24,6 +26,7 @@ export function ContractTabs(props: {
|
||||||
contract={contract}
|
contract={contract}
|
||||||
bets={bets}
|
bets={bets}
|
||||||
comments={comments}
|
comments={comments}
|
||||||
|
tips={tips}
|
||||||
user={user}
|
user={user}
|
||||||
mode="bets"
|
mode="bets"
|
||||||
betRowClassName="!mt-0 xl:hidden"
|
betRowClassName="!mt-0 xl:hidden"
|
||||||
|
@ -36,6 +39,7 @@ export function ContractTabs(props: {
|
||||||
contract={contract}
|
contract={contract}
|
||||||
bets={bets}
|
bets={bets}
|
||||||
comments={comments}
|
comments={comments}
|
||||||
|
tips={tips}
|
||||||
user={user}
|
user={user}
|
||||||
mode={
|
mode={
|
||||||
contract.outcomeType === 'FREE_RESPONSE'
|
contract.outcomeType === 'FREE_RESPONSE'
|
||||||
|
@ -52,6 +56,7 @@ export function ContractTabs(props: {
|
||||||
contract={contract}
|
contract={contract}
|
||||||
bets={bets}
|
bets={bets}
|
||||||
comments={comments}
|
comments={comments}
|
||||||
|
tips={tips}
|
||||||
user={user}
|
user={user}
|
||||||
mode={'comments'}
|
mode={'comments'}
|
||||||
betRowClassName="!mt-0 xl:hidden"
|
betRowClassName="!mt-0 xl:hidden"
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { getOutcomeProbability } from 'common/calculate'
|
||||||
import { Comment } from 'common/comment'
|
import { Comment } from 'common/comment'
|
||||||
import { Contract, FreeResponseContract } from 'common/contract'
|
import { Contract, FreeResponseContract } from 'common/contract'
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
|
import { CommentTipMap } from 'web/hooks/use-tip-txns'
|
||||||
|
|
||||||
export type ActivityItem =
|
export type ActivityItem =
|
||||||
| DescriptionItem
|
| DescriptionItem
|
||||||
|
@ -50,6 +51,7 @@ export type CommentThreadItem = BaseActivityItem & {
|
||||||
type: 'commentThread'
|
type: 'commentThread'
|
||||||
parentComment: Comment
|
parentComment: Comment
|
||||||
comments: Comment[]
|
comments: Comment[]
|
||||||
|
tips: CommentTipMap
|
||||||
bets: Bet[]
|
bets: Bet[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -58,6 +60,7 @@ export type AnswerGroupItem = BaseActivityItem & {
|
||||||
user: User | undefined | null
|
user: User | undefined | null
|
||||||
answer: Answer
|
answer: Answer
|
||||||
comments: Comment[]
|
comments: Comment[]
|
||||||
|
tips: CommentTipMap
|
||||||
bets: Bet[]
|
bets: Bet[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -73,6 +76,7 @@ function getAnswerAndCommentInputGroups(
|
||||||
contract: FreeResponseContract,
|
contract: FreeResponseContract,
|
||||||
bets: Bet[],
|
bets: Bet[],
|
||||||
comments: Comment[],
|
comments: Comment[],
|
||||||
|
tips: CommentTipMap,
|
||||||
user: User | undefined | null
|
user: User | undefined | null
|
||||||
) {
|
) {
|
||||||
let outcomes = uniq(bets.map((bet) => bet.outcome))
|
let outcomes = uniq(bets.map((bet) => bet.outcome))
|
||||||
|
@ -93,6 +97,7 @@ function getAnswerAndCommentInputGroups(
|
||||||
user,
|
user,
|
||||||
answer,
|
answer,
|
||||||
comments,
|
comments,
|
||||||
|
tips,
|
||||||
bets,
|
bets,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -103,6 +108,7 @@ function getAnswerAndCommentInputGroups(
|
||||||
function getCommentThreads(
|
function getCommentThreads(
|
||||||
bets: Bet[],
|
bets: Bet[],
|
||||||
comments: Comment[],
|
comments: Comment[],
|
||||||
|
tips: CommentTipMap,
|
||||||
contract: Contract
|
contract: Contract
|
||||||
) {
|
) {
|
||||||
const parentComments = comments.filter((comment) => !comment.replyToCommentId)
|
const parentComments = comments.filter((comment) => !comment.replyToCommentId)
|
||||||
|
@ -114,6 +120,7 @@ function getCommentThreads(
|
||||||
comments: comments,
|
comments: comments,
|
||||||
parentComment: comment,
|
parentComment: comment,
|
||||||
bets: bets,
|
bets: bets,
|
||||||
|
tips,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return items
|
return items
|
||||||
|
@ -132,6 +139,7 @@ export function getSpecificContractActivityItems(
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
bets: Bet[],
|
bets: Bet[],
|
||||||
comments: Comment[],
|
comments: Comment[],
|
||||||
|
tips: CommentTipMap,
|
||||||
user: User | null | undefined,
|
user: User | null | undefined,
|
||||||
options: {
|
options: {
|
||||||
mode: 'comments' | 'bets' | 'free-response-comment-answer-groups'
|
mode: 'comments' | 'bets' | 'free-response-comment-answer-groups'
|
||||||
|
@ -167,6 +175,7 @@ export function getSpecificContractActivityItems(
|
||||||
...getCommentThreads(
|
...getCommentThreads(
|
||||||
nonFreeResponseBets,
|
nonFreeResponseBets,
|
||||||
nonFreeResponseComments,
|
nonFreeResponseComments,
|
||||||
|
tips,
|
||||||
contract
|
contract
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
@ -190,6 +199,7 @@ export function getSpecificContractActivityItems(
|
||||||
contract as FreeResponseContract,
|
contract as FreeResponseContract,
|
||||||
bets,
|
bets,
|
||||||
comments,
|
comments,
|
||||||
|
tips,
|
||||||
user
|
user
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
@ -7,18 +7,20 @@ import { getSpecificContractActivityItems } from './activity-items'
|
||||||
import { FeedItems } from './feed-items'
|
import { FeedItems } from './feed-items'
|
||||||
import { User } from 'common/user'
|
import { User } from 'common/user'
|
||||||
import { useContractWithPreload } from 'web/hooks/use-contract'
|
import { useContractWithPreload } from 'web/hooks/use-contract'
|
||||||
|
import { CommentTipMap } from 'web/hooks/use-tip-txns'
|
||||||
|
|
||||||
export function ContractActivity(props: {
|
export function ContractActivity(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
bets: Bet[]
|
bets: Bet[]
|
||||||
comments: Comment[]
|
comments: Comment[]
|
||||||
|
tips: CommentTipMap
|
||||||
user: User | null | undefined
|
user: User | null | undefined
|
||||||
mode: 'comments' | 'bets' | 'free-response-comment-answer-groups'
|
mode: 'comments' | 'bets' | 'free-response-comment-answer-groups'
|
||||||
contractPath?: string
|
contractPath?: string
|
||||||
className?: string
|
className?: string
|
||||||
betRowClassName?: string
|
betRowClassName?: string
|
||||||
}) {
|
}) {
|
||||||
const { user, mode, className, betRowClassName } = props
|
const { user, mode, tips, className, betRowClassName } = props
|
||||||
|
|
||||||
const contract = useContractWithPreload(props.contract) ?? props.contract
|
const contract = useContractWithPreload(props.contract) ?? props.contract
|
||||||
|
|
||||||
|
@ -31,6 +33,7 @@ export function ContractActivity(props: {
|
||||||
contract,
|
contract,
|
||||||
bets,
|
bets,
|
||||||
comments,
|
comments,
|
||||||
|
tips,
|
||||||
user,
|
user,
|
||||||
{ mode }
|
{ mode }
|
||||||
)
|
)
|
||||||
|
|
|
@ -24,15 +24,17 @@ import { groupBy } 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 { getDpmOutcomeProbability } from 'common/calculate-dpm'
|
import { getDpmOutcomeProbability } from 'common/calculate-dpm'
|
||||||
|
import { CommentTipMap } from 'web/hooks/use-tip-txns'
|
||||||
|
|
||||||
export function FeedAnswerCommentGroup(props: {
|
export function FeedAnswerCommentGroup(props: {
|
||||||
contract: any
|
contract: any
|
||||||
user: User | undefined | null
|
user: User | undefined | null
|
||||||
answer: Answer
|
answer: Answer
|
||||||
comments: Comment[]
|
comments: Comment[]
|
||||||
|
tips: CommentTipMap
|
||||||
bets: Bet[]
|
bets: Bet[]
|
||||||
}) {
|
}) {
|
||||||
const { answer, contract, comments, bets, user } = props
|
const { answer, contract, comments, tips, bets, user } = props
|
||||||
const { username, avatarUrl, name, text } = answer
|
const { username, avatarUrl, name, text } = answer
|
||||||
|
|
||||||
const [replyToUsername, setReplyToUsername] = useState('')
|
const [replyToUsername, setReplyToUsername] = useState('')
|
||||||
|
@ -214,6 +216,7 @@ export function FeedAnswerCommentGroup(props: {
|
||||||
smallAvatar={true}
|
smallAvatar={true}
|
||||||
truncate={false}
|
truncate={false}
|
||||||
bets={bets}
|
bets={bets}
|
||||||
|
tips={tips}
|
||||||
scrollAndOpenReplyInput={scrollAndOpenReplyInput}
|
scrollAndOpenReplyInput={scrollAndOpenReplyInput}
|
||||||
treatFirstIndexEqually={true}
|
treatFirstIndexEqually={true}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -25,17 +25,27 @@ import { getProbability } from 'common/calculate'
|
||||||
import { LoadingIndicator } from 'web/components/loading-indicator'
|
import { LoadingIndicator } from 'web/components/loading-indicator'
|
||||||
import { PaperAirplaneIcon } from '@heroicons/react/outline'
|
import { PaperAirplaneIcon } from '@heroicons/react/outline'
|
||||||
import { track } from 'web/lib/service/analytics'
|
import { track } from 'web/lib/service/analytics'
|
||||||
|
import { Tipper } from '../tipper'
|
||||||
|
import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns'
|
||||||
|
|
||||||
export function FeedCommentThread(props: {
|
export function FeedCommentThread(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
comments: Comment[]
|
comments: Comment[]
|
||||||
|
tips: CommentTipMap
|
||||||
parentComment: Comment
|
parentComment: Comment
|
||||||
bets: Bet[]
|
bets: Bet[]
|
||||||
truncate?: boolean
|
truncate?: boolean
|
||||||
smallAvatar?: boolean
|
smallAvatar?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { contract, comments, bets, truncate, smallAvatar, parentComment } =
|
const {
|
||||||
props
|
contract,
|
||||||
|
comments,
|
||||||
|
bets,
|
||||||
|
tips,
|
||||||
|
truncate,
|
||||||
|
smallAvatar,
|
||||||
|
parentComment,
|
||||||
|
} = props
|
||||||
const [showReply, setShowReply] = useState(false)
|
const [showReply, setShowReply] = useState(false)
|
||||||
const [replyToUsername, setReplyToUsername] = useState('')
|
const [replyToUsername, setReplyToUsername] = useState('')
|
||||||
const betsByUserId = groupBy(bets, (bet) => bet.userId)
|
const betsByUserId = groupBy(bets, (bet) => bet.userId)
|
||||||
|
@ -64,6 +74,7 @@ export function FeedCommentThread(props: {
|
||||||
contract={contract}
|
contract={contract}
|
||||||
commentsList={commentsList}
|
commentsList={commentsList}
|
||||||
betsByUserId={betsByUserId}
|
betsByUserId={betsByUserId}
|
||||||
|
tips={tips}
|
||||||
smallAvatar={smallAvatar}
|
smallAvatar={smallAvatar}
|
||||||
truncate={truncate}
|
truncate={truncate}
|
||||||
bets={bets}
|
bets={bets}
|
||||||
|
@ -97,6 +108,7 @@ export function CommentRepliesList(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
commentsList: Comment[]
|
commentsList: Comment[]
|
||||||
betsByUserId: Dictionary<Bet[]>
|
betsByUserId: Dictionary<Bet[]>
|
||||||
|
tips: CommentTipMap
|
||||||
scrollAndOpenReplyInput: (comment: Comment) => void
|
scrollAndOpenReplyInput: (comment: Comment) => void
|
||||||
bets: Bet[]
|
bets: Bet[]
|
||||||
treatFirstIndexEqually?: boolean
|
treatFirstIndexEqually?: boolean
|
||||||
|
@ -107,6 +119,7 @@ export function CommentRepliesList(props: {
|
||||||
contract,
|
contract,
|
||||||
commentsList,
|
commentsList,
|
||||||
betsByUserId,
|
betsByUserId,
|
||||||
|
tips,
|
||||||
truncate,
|
truncate,
|
||||||
smallAvatar,
|
smallAvatar,
|
||||||
bets,
|
bets,
|
||||||
|
@ -134,6 +147,7 @@ export function CommentRepliesList(props: {
|
||||||
<FeedComment
|
<FeedComment
|
||||||
contract={contract}
|
contract={contract}
|
||||||
comment={comment}
|
comment={comment}
|
||||||
|
tips={tips[comment.id]}
|
||||||
betsBySameUser={betsByUserId[comment.userId] ?? []}
|
betsBySameUser={betsByUserId[comment.userId] ?? []}
|
||||||
onReplyClick={scrollAndOpenReplyInput}
|
onReplyClick={scrollAndOpenReplyInput}
|
||||||
probAtCreatedTime={
|
probAtCreatedTime={
|
||||||
|
@ -157,6 +171,7 @@ export function CommentRepliesList(props: {
|
||||||
export function FeedComment(props: {
|
export function FeedComment(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
comment: Comment
|
comment: Comment
|
||||||
|
tips: CommentTips
|
||||||
betsBySameUser: Bet[]
|
betsBySameUser: Bet[]
|
||||||
probAtCreatedTime?: number
|
probAtCreatedTime?: number
|
||||||
truncate?: boolean
|
truncate?: boolean
|
||||||
|
@ -166,6 +181,7 @@ export function FeedComment(props: {
|
||||||
const {
|
const {
|
||||||
contract,
|
contract,
|
||||||
comment,
|
comment,
|
||||||
|
tips,
|
||||||
betsBySameUser,
|
betsBySameUser,
|
||||||
probAtCreatedTime,
|
probAtCreatedTime,
|
||||||
truncate,
|
truncate,
|
||||||
|
@ -257,14 +273,17 @@ export function FeedComment(props: {
|
||||||
moreHref={contractPath(contract)}
|
moreHref={contractPath(contract)}
|
||||||
shouldTruncate={truncate}
|
shouldTruncate={truncate}
|
||||||
/>
|
/>
|
||||||
|
<Row className="mt-2 items-center gap-6 text-xs text-gray-500">
|
||||||
|
<Tipper comment={comment} tips={tips ?? {}} />
|
||||||
{onReplyClick && (
|
{onReplyClick && (
|
||||||
<button
|
<button
|
||||||
className={'text-xs font-bold text-gray-500 hover:underline'}
|
className="font-bold hover:underline"
|
||||||
onClick={() => onReplyClick(comment)}
|
onClick={() => onReplyClick(comment)}
|
||||||
>
|
>
|
||||||
Reply
|
Reply
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
</Row>
|
||||||
</div>
|
</div>
|
||||||
</Row>
|
</Row>
|
||||||
)
|
)
|
||||||
|
|
157
web/components/tipper.tsx
Normal file
157
web/components/tipper.tsx
Normal file
|
@ -0,0 +1,157 @@
|
||||||
|
import {
|
||||||
|
ChevronDoubleRightIcon,
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
} from '@heroicons/react/solid'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { Comment } from 'common/comment'
|
||||||
|
import { User } from 'common/user'
|
||||||
|
import { formatMoney } from 'common/util/format'
|
||||||
|
import { debounce, sumBy } from 'lodash'
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { CommentTips } from 'web/hooks/use-tip-txns'
|
||||||
|
import { useUser } from 'web/hooks/use-user'
|
||||||
|
import { transact } from 'web/lib/firebase/fn-call'
|
||||||
|
import { Row } from './layout/row'
|
||||||
|
import { Tooltip } from './tooltip'
|
||||||
|
|
||||||
|
// xth triangle number * 5 = 5 + 10 + 15 + ... + (x * 5)
|
||||||
|
const quad = (x: number) => (5 / 2) * x * (x + 1)
|
||||||
|
|
||||||
|
// inverse (see https://math.stackexchange.com/questions/2041988/how-to-get-inverse-of-formula-for-sum-of-integers-from-1-to-nsee )
|
||||||
|
const invQuad = (y: number) => Math.sqrt((2 / 5) * y + 1 / 4) - 1 / 2
|
||||||
|
|
||||||
|
export function Tipper(prop: { comment: Comment; tips: CommentTips }) {
|
||||||
|
const { comment, tips } = prop
|
||||||
|
|
||||||
|
const me = useUser()
|
||||||
|
const myId = me?.id ?? ''
|
||||||
|
const savedTip = tips[myId] as number | undefined
|
||||||
|
|
||||||
|
// optimistically increase the tip count, but debounce the update
|
||||||
|
const [localTip, setLocalTip] = useState(savedTip ?? 0)
|
||||||
|
const initialized = useRef(false)
|
||||||
|
useEffect(() => {
|
||||||
|
if (savedTip && !initialized.current) {
|
||||||
|
setLocalTip(savedTip)
|
||||||
|
initialized.current = true
|
||||||
|
}
|
||||||
|
}, [savedTip])
|
||||||
|
|
||||||
|
const score = useMemo(() => {
|
||||||
|
const tipVals = Object.values({ ...tips, [myId]: localTip })
|
||||||
|
return sumBy(tipVals, invQuad)
|
||||||
|
}, [localTip, tips, myId])
|
||||||
|
|
||||||
|
// declare debounced function only on first render
|
||||||
|
const [saveTip] = useState(() =>
|
||||||
|
debounce(async (user: User, change: number) => {
|
||||||
|
if (change === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await transact({
|
||||||
|
amount: change,
|
||||||
|
fromId: user.id,
|
||||||
|
fromType: 'USER',
|
||||||
|
toId: comment.userId,
|
||||||
|
toType: 'USER',
|
||||||
|
token: 'M$',
|
||||||
|
category: 'TIP',
|
||||||
|
data: {
|
||||||
|
contractId: comment.contractId,
|
||||||
|
commentId: comment.id,
|
||||||
|
},
|
||||||
|
description: `${user.name} tipped M$ ${change} to ${comment.userName} for a comment`,
|
||||||
|
})
|
||||||
|
}, 1500)
|
||||||
|
)
|
||||||
|
// instant save on unrender
|
||||||
|
useEffect(() => () => void saveTip.flush(), [saveTip])
|
||||||
|
|
||||||
|
const changeTip = (tip: number) => {
|
||||||
|
setLocalTip(tip)
|
||||||
|
me && saveTip(me, tip - (savedTip ?? 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row className="items-center gap-0.5">
|
||||||
|
<DownTip
|
||||||
|
value={localTip}
|
||||||
|
onChange={changeTip}
|
||||||
|
disabled={!me || localTip <= 0}
|
||||||
|
/>
|
||||||
|
<span className="font-bold">{Math.floor(score)} </span>
|
||||||
|
<UpTip
|
||||||
|
value={localTip}
|
||||||
|
onChange={changeTip}
|
||||||
|
disabled={!me || me.id === comment.userId}
|
||||||
|
/>
|
||||||
|
{localTip === 0 ? (
|
||||||
|
''
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'font-semibold',
|
||||||
|
localTip > 0 ? 'text-primary' : 'text-red-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
({formatMoney(localTip)} tip)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DownTip(prop: {
|
||||||
|
value: number
|
||||||
|
onChange: (tip: number) => void
|
||||||
|
disabled?: boolean
|
||||||
|
}) {
|
||||||
|
const { onChange, value, disabled } = prop
|
||||||
|
const marginal = 5 * invQuad(value)
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
className="tooltip-bottom"
|
||||||
|
text={!disabled && `Refund ${formatMoney(marginal)}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="flex h-max items-center hover:text-red-600 disabled:text-gray-300"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onChange(value - marginal)}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon className="h-6 w-6" />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function UpTip(prop: {
|
||||||
|
value: number
|
||||||
|
onChange: (tip: number) => void
|
||||||
|
disabled?: boolean
|
||||||
|
}) {
|
||||||
|
const { onChange, value, disabled } = prop
|
||||||
|
const marginal = 5 * invQuad(value) + 5
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
className="tooltip-bottom"
|
||||||
|
text={!disabled && `Tip ${formatMoney(marginal)}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="hover:text-primary flex h-max items-center disabled:text-gray-300"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onChange(value + marginal)}
|
||||||
|
>
|
||||||
|
{value >= quad(2) ? (
|
||||||
|
<ChevronDoubleRightIcon className="text-primary mx-1 h-6 w-6" />
|
||||||
|
) : value > 0 ? (
|
||||||
|
<ChevronRightIcon className="text-primary h-6 w-6" />
|
||||||
|
) : (
|
||||||
|
<ChevronRightIcon className="h-6 w-6" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
16
web/components/tooltip.tsx
Normal file
16
web/components/tooltip.tsx
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
export function Tooltip(
|
||||||
|
props: {
|
||||||
|
text: string | false | undefined | null
|
||||||
|
} & JSX.IntrinsicElements['div']
|
||||||
|
) {
|
||||||
|
const { text, children, className } = props
|
||||||
|
return text ? (
|
||||||
|
<div className={clsx(className, 'tooltip z-10')} data-tip={text}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>{children}</>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,9 +1,9 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Txn } from 'common/txn'
|
import { DonationTxn } from 'common/txn'
|
||||||
import { listenForCharityTxns } from 'web/lib/firebase/txns'
|
import { listenForCharityTxns } from 'web/lib/firebase/txns'
|
||||||
|
|
||||||
export const useCharityTxns = (charityId: string) => {
|
export const useCharityTxns = (charityId: string) => {
|
||||||
const [txns, setTxns] = useState<Txn[]>([])
|
const [txns, setTxns] = useState<DonationTxn[]>([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return listenForCharityTxns(charityId, setTxns)
|
return listenForCharityTxns(charityId, setTxns)
|
||||||
|
|
23
web/hooks/use-tip-txns.ts
Normal file
23
web/hooks/use-tip-txns.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import { TipTxn } from 'common/txn'
|
||||||
|
import { groupBy, mapValues, sumBy } from 'lodash'
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { listenForTipTxns } from 'web/lib/firebase/txns'
|
||||||
|
|
||||||
|
export type CommentTips = { [userId: string]: number }
|
||||||
|
export type CommentTipMap = { [commentId: string]: CommentTips }
|
||||||
|
|
||||||
|
export function useTipTxns(contractId: string): CommentTipMap {
|
||||||
|
const [txns, setTxns] = useState<TipTxn[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return listenForTipTxns(contractId, setTxns)
|
||||||
|
}, [contractId, setTxns])
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
const byComment = groupBy(txns, 'data.commentId')
|
||||||
|
return mapValues(byComment, (txns) => {
|
||||||
|
const bySender = groupBy(txns, 'fromId')
|
||||||
|
return mapValues(bySender, (t) => sumBy(t, 'amount'))
|
||||||
|
})
|
||||||
|
}, [txns])
|
||||||
|
}
|
|
@ -1,6 +1,5 @@
|
||||||
import { collection, query, where, orderBy } from 'firebase/firestore'
|
import { DonationTxn, TipTxn } from 'common/txn'
|
||||||
import { Txn } from 'common/txn'
|
import { collection, orderBy, query, where } from 'firebase/firestore'
|
||||||
|
|
||||||
import { db } from './init'
|
import { db } from './init'
|
||||||
import { getValues, listenForValues } from './utils'
|
import { getValues, listenForValues } from './utils'
|
||||||
|
|
||||||
|
@ -16,13 +15,27 @@ const getCharityQuery = (charityId: string) =>
|
||||||
|
|
||||||
export function listenForCharityTxns(
|
export function listenForCharityTxns(
|
||||||
charityId: string,
|
charityId: string,
|
||||||
setTxns: (txns: Txn[]) => void
|
setTxns: (txns: DonationTxn[]) => void
|
||||||
) {
|
) {
|
||||||
return listenForValues<Txn>(getCharityQuery(charityId), setTxns)
|
return listenForValues<DonationTxn>(getCharityQuery(charityId), setTxns)
|
||||||
}
|
}
|
||||||
|
|
||||||
const charitiesQuery = query(txnCollection, where('toType', '==', 'CHARITY'))
|
const charitiesQuery = query(txnCollection, where('toType', '==', 'CHARITY'))
|
||||||
|
|
||||||
export function getAllCharityTxns() {
|
export function getAllCharityTxns() {
|
||||||
return getValues<Txn>(charitiesQuery)
|
return getValues<DonationTxn>(charitiesQuery)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTipsQuery = (contractId: string) =>
|
||||||
|
query(
|
||||||
|
txnCollection,
|
||||||
|
where('category', '==', 'TIP'),
|
||||||
|
where('data.contractId', '==', contractId)
|
||||||
|
)
|
||||||
|
|
||||||
|
export function listenForTipTxns(
|
||||||
|
contractId: string,
|
||||||
|
setTxns: (txns: TipTxn[]) => void
|
||||||
|
) {
|
||||||
|
return listenForValues<TipTxn>(getTipsQuery(contractId), setTxns)
|
||||||
}
|
}
|
||||||
|
|
|
@ -41,6 +41,7 @@ import ContractEmbedPage from '../embed/[username]/[contractSlug]'
|
||||||
import { useBets } from 'web/hooks/use-bets'
|
import { useBets } from 'web/hooks/use-bets'
|
||||||
import { AlertBox } from 'web/components/alert-box'
|
import { AlertBox } from 'web/components/alert-box'
|
||||||
import { useTracking } from 'web/hooks/use-tracking'
|
import { useTracking } from 'web/hooks/use-tracking'
|
||||||
|
import { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns'
|
||||||
|
|
||||||
export const getStaticProps = fromPropz(getStaticPropz)
|
export const getStaticProps = fromPropz(getStaticPropz)
|
||||||
export async function getStaticPropz(props: {
|
export async function getStaticPropz(props: {
|
||||||
|
@ -119,6 +120,8 @@ export function ContractPageContent(
|
||||||
// Sort for now to see if bug is fixed.
|
// Sort for now to see if bug is fixed.
|
||||||
comments.sort((c1, c2) => c1.createdTime - c2.createdTime)
|
comments.sort((c1, c2) => c1.createdTime - c2.createdTime)
|
||||||
|
|
||||||
|
const tips = useTipTxns(contract.id)
|
||||||
|
|
||||||
const user = useUser()
|
const user = useUser()
|
||||||
const { width, height } = useWindowSize()
|
const { width, height } = useWindowSize()
|
||||||
|
|
||||||
|
@ -192,11 +195,7 @@ export function ContractPageContent(
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ContractOverview
|
<ContractOverview contract={contract} bets={bets} />
|
||||||
contract={contract}
|
|
||||||
bets={bets}
|
|
||||||
comments={comments ?? []}
|
|
||||||
/>
|
|
||||||
{isNumeric && (
|
{isNumeric && (
|
||||||
<AlertBox
|
<AlertBox
|
||||||
title="Warning"
|
title="Warning"
|
||||||
|
@ -224,6 +223,7 @@ export function ContractPageContent(
|
||||||
contract={contract}
|
contract={contract}
|
||||||
bets={bets}
|
bets={bets}
|
||||||
comments={comments}
|
comments={comments}
|
||||||
|
tips={tips}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Spacer h={12} />
|
<Spacer h={12} />
|
||||||
|
@ -234,6 +234,7 @@ export function ContractPageContent(
|
||||||
contract={contract}
|
contract={contract}
|
||||||
user={user}
|
user={user}
|
||||||
bets={bets}
|
bets={bets}
|
||||||
|
tips={tips}
|
||||||
comments={comments}
|
comments={comments}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
|
@ -290,8 +291,9 @@ function ContractTopTrades(props: {
|
||||||
contract: Contract
|
contract: Contract
|
||||||
bets: Bet[]
|
bets: Bet[]
|
||||||
comments: Comment[]
|
comments: Comment[]
|
||||||
|
tips: CommentTipMap
|
||||||
}) {
|
}) {
|
||||||
const { contract, bets, comments } = props
|
const { contract, bets, comments, tips } = props
|
||||||
const commentsById = keyBy(comments, 'id')
|
const commentsById = keyBy(comments, 'id')
|
||||||
const betsById = keyBy(bets, 'id')
|
const betsById = keyBy(bets, 'id')
|
||||||
|
|
||||||
|
@ -328,6 +330,7 @@ function ContractTopTrades(props: {
|
||||||
<FeedComment
|
<FeedComment
|
||||||
contract={contract}
|
contract={contract}
|
||||||
comment={commentsById[topCommentId]}
|
comment={commentsById[topCommentId]}
|
||||||
|
tips={tips[topCommentId]}
|
||||||
betsBySameUser={[betsById[topCommentId]]}
|
betsBySameUser={[betsById[topCommentId]]}
|
||||||
truncate={false}
|
truncate={false}
|
||||||
smallAvatar={false}
|
smallAvatar={false}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user