diff --git a/common/txn.ts b/common/txn.ts index 8beea234..5bd6ca09 100644 --- a/common/txn.ts +++ b/common/txn.ts @@ -13,9 +13,27 @@ export type Txn = { amount: number token: 'M$' // | 'USD' | MarketOutcome - category: 'CHARITY' // | 'BET' | 'TIP' + category: 'CHARITY' | 'TIP' // | 'BET' + // Any extra data + data?: { [key: string]: any } // Human-readable description description?: string } export type SourceType = 'USER' | 'CONTRACT' | 'CHARITY' | 'BANK' + +export type DonationTxn = Omit & { + fromType: 'USER' + toType: 'CHARITY' + category: 'CHARITY' +} + +export type TipTxn = Txn & { + fromType: 'USER' + toType: 'USER' + category: 'TIP' + data: { + contractId: string + commentId: string + } +} diff --git a/functions/src/transact.ts b/functions/src/transact.ts index 79b5ccb8..0c9bd696 100644 --- a/functions/src/transact.ts +++ b/functions/src/transact.ts @@ -11,7 +11,17 @@ export const transact = functions const userId = context?.auth?.uid 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') return { @@ -25,7 +35,7 @@ export const transact = functions 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' } // Run as transaction to prevent race conditions. @@ -69,9 +79,10 @@ export const transact = functions toType, amount, - // TODO: Unhardcode once we have non-donation txns - token: 'M$', - category: 'CHARITY', + category, + data: innerData, + token, + description, }) diff --git a/web/components/charity/feed-items.tsx b/web/components/charity/feed-items.tsx index 9a411e76..6e6def00 100644 --- a/web/components/charity/feed-items.tsx +++ b/web/components/charity/feed-items.tsx @@ -1,11 +1,11 @@ -import { Txn } from 'common/txn' +import { DonationTxn } from 'common/txn' import { Avatar } from '../avatar' import { useUserById } from 'web/hooks/use-users' import { UserLink } from '../user-page' import { manaToUSD } from '../../../common/util/format' import { RelativeTimestamp } from '../relative-timestamp' -export function Donation(props: { txn: Txn }) { +export function Donation(props: { txn: DonationTxn }) { const { txn } = props const user = useUserById(txn.fromId) diff --git a/web/components/contract/contract-overview.tsx b/web/components/contract/contract-overview.tsx index a8608ae6..a68f37be 100644 --- a/web/components/contract/contract-overview.tsx +++ b/web/components/contract/contract-overview.tsx @@ -13,7 +13,6 @@ import { NumericResolutionOrExpectation, } from './contract-card' import { Bet } from 'common/bet' -import { Comment } from 'common/comment' import BetRow from '../bet-row' import { AnswersGraph } from '../answers/answers-graph' import { Contract } from 'common/contract' @@ -25,7 +24,6 @@ import { NumericGraph } from './numeric-graph' export const ContractOverview = (props: { contract: Contract bets: Bet[] - comments: Comment[] className?: string }) => { const { contract, bets, className } = props diff --git a/web/components/contract/contract-tabs.tsx b/web/components/contract/contract-tabs.tsx index 243e62ff..cb4a3537 100644 --- a/web/components/contract/contract-tabs.tsx +++ b/web/components/contract/contract-tabs.tsx @@ -7,14 +7,16 @@ import { ContractBetsTable, BetsSummary } from '../bets-list' import { Spacer } from '../layout/spacer' import { Tabs } from '../layout/tabs' import { Col } from '../layout/col' +import { CommentTipMap } from 'web/hooks/use-tip-txns' export function ContractTabs(props: { contract: Contract user: User | null | undefined bets: Bet[] comments: Comment[] + tips: CommentTipMap }) { - const { contract, user, bets, comments } = props + const { contract, user, bets, comments, tips } = props const { outcomeType } = contract const userBets = user && bets.filter((bet) => bet.userId === user.id) @@ -24,6 +26,7 @@ export function ContractTabs(props: { contract={contract} bets={bets} comments={comments} + tips={tips} user={user} mode="bets" betRowClassName="!mt-0 xl:hidden" @@ -36,6 +39,7 @@ export function ContractTabs(props: { contract={contract} bets={bets} comments={comments} + tips={tips} user={user} mode={ contract.outcomeType === 'FREE_RESPONSE' @@ -52,6 +56,7 @@ export function ContractTabs(props: { contract={contract} bets={bets} comments={comments} + tips={tips} user={user} mode={'comments'} betRowClassName="!mt-0 xl:hidden" diff --git a/web/components/feed/activity-items.ts b/web/components/feed/activity-items.ts index ee7239e3..dd5263e0 100644 --- a/web/components/feed/activity-items.ts +++ b/web/components/feed/activity-items.ts @@ -6,6 +6,7 @@ import { getOutcomeProbability } from 'common/calculate' import { Comment } from 'common/comment' import { Contract, FreeResponseContract } from 'common/contract' import { User } from 'common/user' +import { CommentTipMap } from 'web/hooks/use-tip-txns' export type ActivityItem = | DescriptionItem @@ -50,6 +51,7 @@ export type CommentThreadItem = BaseActivityItem & { type: 'commentThread' parentComment: Comment comments: Comment[] + tips: CommentTipMap bets: Bet[] } @@ -58,6 +60,7 @@ export type AnswerGroupItem = BaseActivityItem & { user: User | undefined | null answer: Answer comments: Comment[] + tips: CommentTipMap bets: Bet[] } @@ -73,6 +76,7 @@ function getAnswerAndCommentInputGroups( contract: FreeResponseContract, bets: Bet[], comments: Comment[], + tips: CommentTipMap, user: User | undefined | null ) { let outcomes = uniq(bets.map((bet) => bet.outcome)) @@ -93,6 +97,7 @@ function getAnswerAndCommentInputGroups( user, answer, comments, + tips, bets, } }) @@ -103,6 +108,7 @@ function getAnswerAndCommentInputGroups( function getCommentThreads( bets: Bet[], comments: Comment[], + tips: CommentTipMap, contract: Contract ) { const parentComments = comments.filter((comment) => !comment.replyToCommentId) @@ -114,6 +120,7 @@ function getCommentThreads( comments: comments, parentComment: comment, bets: bets, + tips, })) return items @@ -132,6 +139,7 @@ export function getSpecificContractActivityItems( contract: Contract, bets: Bet[], comments: Comment[], + tips: CommentTipMap, user: User | null | undefined, options: { mode: 'comments' | 'bets' | 'free-response-comment-answer-groups' @@ -167,6 +175,7 @@ export function getSpecificContractActivityItems( ...getCommentThreads( nonFreeResponseBets, nonFreeResponseComments, + tips, contract ) ) @@ -190,6 +199,7 @@ export function getSpecificContractActivityItems( contract as FreeResponseContract, bets, comments, + tips, user ) ) diff --git a/web/components/feed/contract-activity.tsx b/web/components/feed/contract-activity.tsx index 8c436333..9ffc8717 100644 --- a/web/components/feed/contract-activity.tsx +++ b/web/components/feed/contract-activity.tsx @@ -7,18 +7,20 @@ import { getSpecificContractActivityItems } from './activity-items' import { FeedItems } from './feed-items' import { User } from 'common/user' import { useContractWithPreload } from 'web/hooks/use-contract' +import { CommentTipMap } from 'web/hooks/use-tip-txns' export function ContractActivity(props: { contract: Contract bets: Bet[] comments: Comment[] + tips: CommentTipMap user: User | null | undefined mode: 'comments' | 'bets' | 'free-response-comment-answer-groups' contractPath?: string className?: string betRowClassName?: string }) { - const { user, mode, className, betRowClassName } = props + const { user, mode, tips, className, betRowClassName } = props const contract = useContractWithPreload(props.contract) ?? props.contract @@ -31,6 +33,7 @@ export function ContractActivity(props: { contract, bets, comments, + tips, user, { mode } ) diff --git a/web/components/feed/feed-answer-comment-group.tsx b/web/components/feed/feed-answer-comment-group.tsx index 7e9e19aa..e6072c30 100644 --- a/web/components/feed/feed-answer-comment-group.tsx +++ b/web/components/feed/feed-answer-comment-group.tsx @@ -24,15 +24,17 @@ import { groupBy } from 'lodash' import { User } from 'common/user' import { useEvent } from 'web/hooks/use-event' import { getDpmOutcomeProbability } from 'common/calculate-dpm' +import { CommentTipMap } from 'web/hooks/use-tip-txns' export function FeedAnswerCommentGroup(props: { contract: any user: User | undefined | null answer: Answer comments: Comment[] + tips: CommentTipMap bets: Bet[] }) { - const { answer, contract, comments, bets, user } = props + const { answer, contract, comments, tips, bets, user } = props const { username, avatarUrl, name, text } = answer const [replyToUsername, setReplyToUsername] = useState('') @@ -214,6 +216,7 @@ export function FeedAnswerCommentGroup(props: { smallAvatar={true} truncate={false} bets={bets} + tips={tips} scrollAndOpenReplyInput={scrollAndOpenReplyInput} treatFirstIndexEqually={true} /> diff --git a/web/components/feed/feed-comments.tsx b/web/components/feed/feed-comments.tsx index daf8e197..46feca9a 100644 --- a/web/components/feed/feed-comments.tsx +++ b/web/components/feed/feed-comments.tsx @@ -25,17 +25,27 @@ import { getProbability } from 'common/calculate' import { LoadingIndicator } from 'web/components/loading-indicator' import { PaperAirplaneIcon } from '@heroicons/react/outline' import { track } from 'web/lib/service/analytics' +import { Tipper } from '../tipper' +import { CommentTipMap, CommentTips } from 'web/hooks/use-tip-txns' export function FeedCommentThread(props: { contract: Contract comments: Comment[] + tips: CommentTipMap parentComment: Comment bets: Bet[] truncate?: boolean smallAvatar?: boolean }) { - const { contract, comments, bets, truncate, smallAvatar, parentComment } = - props + const { + contract, + comments, + bets, + tips, + truncate, + smallAvatar, + parentComment, + } = props const [showReply, setShowReply] = useState(false) const [replyToUsername, setReplyToUsername] = useState('') const betsByUserId = groupBy(bets, (bet) => bet.userId) @@ -64,6 +74,7 @@ export function FeedCommentThread(props: { contract={contract} commentsList={commentsList} betsByUserId={betsByUserId} + tips={tips} smallAvatar={smallAvatar} truncate={truncate} bets={bets} @@ -97,6 +108,7 @@ export function CommentRepliesList(props: { contract: Contract commentsList: Comment[] betsByUserId: Dictionary + tips: CommentTipMap scrollAndOpenReplyInput: (comment: Comment) => void bets: Bet[] treatFirstIndexEqually?: boolean @@ -107,6 +119,7 @@ export function CommentRepliesList(props: { contract, commentsList, betsByUserId, + tips, truncate, smallAvatar, bets, @@ -134,6 +147,7 @@ export function CommentRepliesList(props: { - {onReplyClick && ( - - )} + + + {onReplyClick && ( + + )} + ) diff --git a/web/components/tipper.tsx b/web/components/tipper.tsx new file mode 100644 index 00000000..6c5a2555 --- /dev/null +++ b/web/components/tipper.tsx @@ -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 ( + + + {Math.floor(score)} + + {localTip === 0 ? ( + '' + ) : ( + 0 ? 'text-primary' : 'text-red-400' + )} + > + ({formatMoney(localTip)} tip) + + )} + + ) +} + +function DownTip(prop: { + value: number + onChange: (tip: number) => void + disabled?: boolean +}) { + const { onChange, value, disabled } = prop + const marginal = 5 * invQuad(value) + return ( + + + + ) +} + +function UpTip(prop: { + value: number + onChange: (tip: number) => void + disabled?: boolean +}) { + const { onChange, value, disabled } = prop + const marginal = 5 * invQuad(value) + 5 + + return ( + + + + ) +} diff --git a/web/components/tooltip.tsx b/web/components/tooltip.tsx new file mode 100644 index 00000000..46d51762 --- /dev/null +++ b/web/components/tooltip.tsx @@ -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 ? ( +
+ {children} +
+ ) : ( + <>{children} + ) +} diff --git a/web/hooks/use-charity-txns.ts b/web/hooks/use-charity-txns.ts index 13050fb1..25000554 100644 --- a/web/hooks/use-charity-txns.ts +++ b/web/hooks/use-charity-txns.ts @@ -1,9 +1,9 @@ import { useEffect, useState } from 'react' -import { Txn } from 'common/txn' +import { DonationTxn } from 'common/txn' import { listenForCharityTxns } from 'web/lib/firebase/txns' export const useCharityTxns = (charityId: string) => { - const [txns, setTxns] = useState([]) + const [txns, setTxns] = useState([]) useEffect(() => { return listenForCharityTxns(charityId, setTxns) diff --git a/web/hooks/use-tip-txns.ts b/web/hooks/use-tip-txns.ts new file mode 100644 index 00000000..13ef3d34 --- /dev/null +++ b/web/hooks/use-tip-txns.ts @@ -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([]) + + 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]) +} diff --git a/web/lib/firebase/txns.ts b/web/lib/firebase/txns.ts index 74f210d5..58ba7bf6 100644 --- a/web/lib/firebase/txns.ts +++ b/web/lib/firebase/txns.ts @@ -1,6 +1,5 @@ -import { collection, query, where, orderBy } from 'firebase/firestore' -import { Txn } from 'common/txn' - +import { DonationTxn, TipTxn } from 'common/txn' +import { collection, orderBy, query, where } from 'firebase/firestore' import { db } from './init' import { getValues, listenForValues } from './utils' @@ -16,13 +15,27 @@ const getCharityQuery = (charityId: string) => export function listenForCharityTxns( charityId: string, - setTxns: (txns: Txn[]) => void + setTxns: (txns: DonationTxn[]) => void ) { - return listenForValues(getCharityQuery(charityId), setTxns) + return listenForValues(getCharityQuery(charityId), setTxns) } const charitiesQuery = query(txnCollection, where('toType', '==', 'CHARITY')) export function getAllCharityTxns() { - return getValues(charitiesQuery) + return getValues(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(getTipsQuery(contractId), setTxns) } diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index a8ffd461..099ba57e 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -41,6 +41,7 @@ import ContractEmbedPage from '../embed/[username]/[contractSlug]' import { useBets } from 'web/hooks/use-bets' import { AlertBox } from 'web/components/alert-box' import { useTracking } from 'web/hooks/use-tracking' +import { CommentTipMap, useTipTxns } from 'web/hooks/use-tip-txns' export const getStaticProps = fromPropz(getStaticPropz) export async function getStaticPropz(props: { @@ -119,6 +120,8 @@ export function ContractPageContent( // Sort for now to see if bug is fixed. comments.sort((c1, c2) => c1.createdTime - c2.createdTime) + const tips = useTipTxns(contract.id) + const user = useUser() const { width, height } = useWindowSize() @@ -192,11 +195,7 @@ export function ContractPageContent( )} - + {isNumeric && ( @@ -234,6 +234,7 @@ export function ContractPageContent( contract={contract} user={user} bets={bets} + tips={tips} comments={comments} /> @@ -290,8 +291,9 @@ function ContractTopTrades(props: { contract: Contract bets: Bet[] comments: Comment[] + tips: CommentTipMap }) { - const { contract, bets, comments } = props + const { contract, bets, comments, tips } = props const commentsById = keyBy(comments, 'id') const betsById = keyBy(bets, 'id') @@ -328,6 +330,7 @@ function ContractTopTrades(props: {