diff --git a/backend/api/src/reaction.ts b/backend/api/src/reaction.ts index 8fcd3a2256..9606e80f2e 100644 --- a/backend/api/src/reaction.ts +++ b/backend/api/src/reaction.ts @@ -1,21 +1,25 @@ -import { createSupabaseDirectClient } from 'shared/supabase/init' -import { APIError, APIHandler } from './helpers/endpoint' -import { createLikeNotification } from 'shared/create-notification' import { assertUnreachable } from 'common/util/types' +import { createLikeNotification } from 'shared/create-notification' +import { createSupabaseDirectClient } from 'shared/supabase/init' import { log } from 'shared/utils' +import { APIError, APIHandler } from './helpers/endpoint' export const addOrRemoveReaction: APIHandler<'react'> = async (props, auth) => { - const { contentId, contentType, remove } = props + const { contentId, contentType, remove, reactionType = 'like' } = props const userId = auth.uid const pg = createSupabaseDirectClient() - if (remove) { + const deleteReaction = async (deleteReactionType: string) => { await pg.none( `delete from user_reactions - where user_id = $1 and content_id = $2 and content_type = $3`, - [userId, contentId, contentType] + where user_id = $1 and content_id = $2 and content_type = $3 and reaction_type = $4`, + [userId, contentId, contentType, deleteReactionType] ) + } + + if (remove) { + await deleteReaction(reactionType) } else { // get the id of the person this content belongs to, to denormalize the owner let ownerId: string @@ -54,36 +58,57 @@ export const addOrRemoveReaction: APIHandler<'react'> = async (props, auth) => { ) if (existingReactions.length > 0) { - log('Reaction already exists, do nothing') - return { result: { success: true }, continue: async () => {} } + const existingReactionType = existingReactions[0].reaction_type + // if it's the same reaction type, do nothing + if (existingReactionType === reactionType) { + return { result: { success: true }, continue: async () => {} } + } else { + // otherwise, remove the other reaction type + await deleteReaction(existingReactionType) + } } // actually do the insert const reactionRow = await pg.one( `insert into user_reactions - (content_id, content_type, content_owner_id, user_id) - values ($1, $2, $3, $4) + (content_id, content_type, content_owner_id, user_id, reaction_type) + values ($1, $2, $3, $4, $5) returning *`, - [contentId, contentType, ownerId, userId] + [contentId, contentType, ownerId, userId, reactionType] ) - await createLikeNotification(reactionRow) + if (reactionType === 'like') { + await createLikeNotification(reactionRow) + } } return { result: { success: true }, continue: async () => { if (contentType === 'comment') { - const count = await pg.one( + const likeCount = await pg.one( + `select count(*) from user_reactions + where content_id = $1 and content_type = $2 and reaction_type = $3`, + [contentId, contentType, 'like'], + (r) => r.count + ) + const dislikeCount = await pg.one( `select count(*) from user_reactions - where content_id = $1 and content_type = $2`, - [contentId, contentType], + where content_id = $1 and content_type = $2 and reaction_type = $3`, + [contentId, contentType, 'dislike'], (r) => r.count ) - log('new like count ' + count) + + log('new like count ' + likeCount) + log('new dislike count ' + dislikeCount) + await pg.none( `update contract_comments set likes = $1 where comment_id = $2`, - [count, contentId] + [likeCount, contentId] + ) + await pg.none( + `update contract_comments set dislikes = $1 where comment_id = $2`, + [dislikeCount, contentId] ) } }, diff --git a/backend/shared/src/supabase/contract-comments.ts b/backend/shared/src/supabase/contract-comments.ts index c29aca0890..7752c07ef3 100644 --- a/backend/shared/src/supabase/contract-comments.ts +++ b/backend/shared/src/supabase/contract-comments.ts @@ -37,7 +37,7 @@ export async function getCommentsDirect( const { userId, contractId, limit = 5000, page = 0 } = filters return await pg.map( ` - select cc.data, likes from contract_comments cc + select cc.data, likes, dislikes from contract_comments cc join contracts on cc.contract_id = contracts.id where contracts.visibility = 'public' and ($3 is null or contract_id = $3) diff --git a/common/src/api/schema.ts b/common/src/api/schema.ts index 993072f0a3..e63f360e9d 100644 --- a/common/src/api/schema.ts +++ b/common/src/api/schema.ts @@ -1025,6 +1025,7 @@ export const API = (_apiTypeCheck = { contentId: z.string(), contentType: z.enum(['comment', 'contract']), remove: z.boolean().optional(), + reactionType: z.enum(['like', 'dislike']).optional().default('like'), }) .strict(), returns: { success: true }, diff --git a/common/src/comment.ts b/common/src/comment.ts index b850e44948..3c26fcb99f 100644 --- a/common/src/comment.ts +++ b/common/src/comment.ts @@ -24,7 +24,7 @@ export type Comment = { userAvatarUrl?: string /** @deprecated Not actually deprecated, only in supabase column, and not in data column */ likes?: number - + dislikes?: number hidden?: boolean hiddenTime?: number hiderId?: string diff --git a/common/src/reaction.ts b/common/src/reaction.ts index 1bce38def2..fa322d3a8f 100644 --- a/common/src/reaction.ts +++ b/common/src/reaction.ts @@ -4,4 +4,6 @@ export type Reaction = Row<'user_reactions'> export type ReactionContentTypes = 'contract' | 'comment' +export type ReactionType = 'like' | 'dislike' + // export type ReactionTypes = 'like' diff --git a/web/components/comments/comment-actions.tsx b/web/components/comments/comment-actions.tsx index 767d1c0a90..1560f3bb9c 100644 --- a/web/components/comments/comment-actions.tsx +++ b/web/components/comments/comment-actions.tsx @@ -2,20 +2,21 @@ import { ReplyIcon } from '@heroicons/react/solid' import clsx from 'clsx' import { ContractComment } from 'common/comment' import { Contract } from 'common/contract' +import { TRADE_TERM } from 'common/envs/constants' import { richTextToString } from 'common/util/parse' import { useState } from 'react' -import { FaArrowTrendUp, FaArrowTrendDown } from 'react-icons/fa6' -import { useUser, usePrivateUser, isBlocked } from 'web/hooks/use-user' +import { FaArrowTrendDown, FaArrowTrendUp } from 'react-icons/fa6' +import { isBlocked, usePrivateUser, useUser } from 'web/hooks/use-user' +import { track } from 'web/lib/service/analytics' import { BuyPanel } from '../bet/bet-panel' import { IconButton } from '../buttons/button' -import { LikeButton } from '../contract/like-button' +import { AwardBountyButton } from '../contract/bountied-question' +import { ReactButton } from '../contract/react-button' import { Col } from '../layout/col' import { Modal, MODAL_CLASS } from '../layout/modal' import { Row } from '../layout/row' import { Tooltip } from '../widgets/tooltip' -import { track } from 'web/lib/service/analytics' -import { AwardBountyButton } from '../contract/bountied-question' -import { TRADE_TERM } from 'common/envs/constants' +import { PrivateUser, User } from 'common/user' export function CommentActions(props: { onReplyClick?: (comment: ContractComment) => void @@ -46,6 +47,12 @@ export function CommentActions(props: { return ( + {canGiveBounty && ( )} {user && liveContract.outcomeType === 'BINARY' && !isCashContract && ( @@ -67,12 +74,10 @@ export function CommentActions(props: { setShowBetModal(true) }} size={'xs'} + className={'min-w-[60px]'} > - {diff != 0 && ( - {Math.round(Math.abs(diff))} - )} {diff > 0 ? ( ) : diff < 0 ? ( @@ -80,6 +85,9 @@ export function CommentActions(props: { ) : ( )} + {diff != 0 && ( + {Math.round(Math.abs(diff))} + )} @@ -92,23 +100,14 @@ export function CommentActions(props: { e.stopPropagation() onReplyClick(comment) }} - className={'text-ink-500'} + className={'text-ink-500 min-w-[60px]'} > )} - + {showBetModal && ( ) } + +export function LikeAndDislikeComment(props: { + comment: ContractComment + trackingLocation: string + privateUser: PrivateUser | null | undefined + user: User | null | undefined +}) { + const { comment, trackingLocation, privateUser, user } = props + const [userReactedWith, setUserReactedWith] = useState< + 'like' | 'dislike' | 'none' + >('none') + return ( + <> + setUserReactedWith('like')} + onUnreact={() => setUserReactedWith('none')} + className={'min-w-[60px]'} + /> + setUserReactedWith('dislike')} + onUnreact={() => setUserReactedWith('none')} + className={'min-w-[60px]'} + /> + + ) +} diff --git a/web/components/comments/comment.tsx b/web/components/comments/comment.tsx index e1f97a55f0..b0899a4def 100644 --- a/web/components/comments/comment.tsx +++ b/web/components/comments/comment.tsx @@ -318,8 +318,14 @@ export const ParentFeedComment = memo(function ParentFeedComment(props: { function HideableContent(props: { comment: ContractComment }) { const { comment } = props const { text, content } = comment + //hides if enough dislikes + const dislikes = comment.dislikes ?? 0 + const likes = comment.likes ?? 0 + const majorityDislikes = dislikes > 10 && dislikes / (likes + dislikes) >= 0.8 + const initiallyHidden = majorityDislikes || comment.hidden const [showHidden, setShowHidden] = useState(false) - return comment.hidden && !showHidden ? ( + + return initiallyHidden && !showHidden ? (
{ diff --git a/web/components/contract/contract-summary-stats.tsx b/web/components/contract/contract-summary-stats.tsx index 0c4960b754..5f95ca558a 100644 --- a/web/components/contract/contract-summary-stats.tsx +++ b/web/components/contract/contract-summary-stats.tsx @@ -3,12 +3,12 @@ import { Contract } from 'common/contract' import { formatWithToken, shortFormatNumber } from 'common/util/format' import { Row } from 'web/components/layout/row' import { isBlocked, usePrivateUser, useUser } from 'web/hooks/use-user' +import { MoneyDisplay } from '../bet/money-display' import { TierTooltip } from '../tiers/tier-tooltip' import { Tooltip } from '../widgets/tooltip' import { BountyLeft } from './bountied-question' import { CloseOrResolveTime } from './contract-details' -import { LikeButton } from './like-button' -import { MoneyDisplay } from '../bet/money-display' +import { ReactButton } from './react-button' export function ContractSummaryStats(props: { contractId: string @@ -42,7 +42,7 @@ export function ContractSummaryStats(props: { {marketTier && } {!isBlocked(privateUser, contract.creatorId) && ( - -(c.betReplyAmountsByOutcome?.['YES'] ?? 0) : sort === 'No bets' diff --git a/web/components/contract/feed-contract-card.tsx b/web/components/contract/feed-contract-card.tsx index 21fc080220..a614a0fd3b 100644 --- a/web/components/contract/feed-contract-card.tsx +++ b/web/components/contract/feed-contract-card.tsx @@ -51,7 +51,7 @@ import { PollPanel } from '../poll/poll-panel' import { TierTooltip } from '../tiers/tier-tooltip' import { UserHovercard } from '../user/user-hovercard' import { ClickFrame } from '../widgets/click-frame' -import { LikeButton } from './like-button' +import { ReactButton } from './react-button' import { TradesButton } from './trades-button' const DEBUG_FEED_CARDS = @@ -456,7 +456,7 @@ const BottomActionRow = (props: { - void + onUnreact?: () => void }) { const { user, @@ -54,32 +60,54 @@ export const LikeButton = memo(function LikeButton(props: { placement = 'bottom', feedReason, size, + iconType = 'heart', contractId, commentId, heartClassName, + reactionType = 'like', + userReactedWith, } = props - const likes = useLikesOnContent(contentType, contentId) - const [liked, setLiked] = useState(false) + const allReactions = useReactionsOnContent(contentType, contentId) + const reactions = allReactions?.filter( + (reaction: Reaction) => reaction.reaction_type == reactionType + ) + + const [reacted, setReacted] = useState( + userReactedWith ? userReactedWith == reactionType : false + ) + useEffect(() => { - if (likes) setLiked(likes.some((l) => l.user_id === user?.id)) - }, [likes, user]) + setReacted(userReactedWith == reactionType) + }, [userReactedWith]) + + useEffect(() => { + if (reactions) + setReacted( + reactions.some( + (l: Reaction) => + l.user_id === user?.id && l.reaction_type == reactionType + ) + ) + }, [allReactions, user]) - const totalLikes = - (likes ? likes.filter((l) => l.user_id != user?.id).length : 0) + - (liked ? 1 : 0) + const totalReactions = + (reactions + ? reactions.filter((l: Reaction) => l.user_id != user?.id).length + : 0) + (reacted ? 1 : 0) const disabled = props.disabled || !user const isMe = contentCreatorId === user?.id const [modalOpen, setModalOpen] = useState(false) - const onLike = async (shouldLike: boolean) => { + const onReact = async (shouldReact: boolean) => { if (!user) return - setLiked(shouldLike) - if (shouldLike) { - await like(contentId, contentType) + setReacted(shouldReact) + if (shouldReact) { + if (props.onReact) props.onReact() + await react(contentId, contentType, reactionType) track( - 'like', + reactionType, removeUndefinedProps({ itemId: contentId, location: trackingLocation, @@ -91,12 +119,13 @@ export const LikeButton = memo(function LikeButton(props: { }) ) } else { - await unLike(contentId, contentType) + if (props.onUnreact) props.onUnreact() + await unreact(contentId, contentType, reactionType) } } - function handleLiked(liked: boolean) { - onLike(liked) + function handleReacted(liked: boolean) { + onReact(liked) } const likeLongPress = useLongTouch( @@ -105,32 +134,34 @@ export const LikeButton = memo(function LikeButton(props: { }, () => { if (!disabled) { - if (isMe) { + if (isMe && reactionType === 'like') { toast("Of course you'd like yourself", { icon: '🙄' }) } else { - handleLiked(!liked) + handleReacted(!reacted) } } } ) - const otherLikes = liked ? totalLikes - 1 : totalLikes + const otherLikes = reacted ? totalReactions - 1 : totalReactions const showList = otherLikes > 0 + const thumbIcon = iconType == 'thumb' || reactionType == 'dislike' return ( <> setModalOpen(true)} user={user} - userLiked={liked} + userReacted={reacted} + reactionType={reactionType} /> ) : ( - 'Like' + capitalize(reactionType) ) } placement={placement} @@ -151,16 +182,36 @@ export const LikeButton = memo(function LikeButton(props: { >
- + {thumbIcon ? ( + reactionType == 'dislike' ? ( + + ) : ( + + ) + ) : ( + + )}
- {totalLikes > 0 && ( -
{totalLikes}
+ {totalReactions > 0 && ( +
+ {totalReactions} +
)}
@@ -178,18 +229,38 @@ export const LikeButton = memo(function LikeButton(props: { >
- + {thumbIcon ? ( + reactionType == 'dislike' ? ( + + ) : ( + + ) + ) : ( + + )}
- {totalLikes > 0 && ( + {totalReactions > 0 && (
- {totalLikes} + {totalReactions}
)}
@@ -197,20 +268,21 @@ export const LikeButton = memo(function LikeButton(props: { )}
{modalOpen && ( - )} ) }) -function useLikeDisplayList( +function useReactedDisplayList( reacts: Reaction[] = [], self?: User | null, prependSelf?: boolean @@ -223,24 +295,36 @@ function useLikeDisplayList( ]) } -function UserLikedFullList(props: { +function UserReactedFullList(props: { contentType: ReactionContentTypes contentId: string user?: User | null - userLiked?: boolean + userReacted?: boolean setOpen: (isOpen: boolean) => void titleName?: string + reactionType: ReactionType }) { - const { contentType, contentId, user, userLiked, setOpen, titleName } = props - const reacts = useLikesOnContent(contentType, contentId) - const displayInfos = useLikeDisplayList(reacts, user, userLiked) + const { + contentType, + contentId, + user, + userReacted, + setOpen, + titleName, + reactionType, + } = props + const reacts = useReactionsOnContent(contentType, contentId)?.filter( + (reaction: Reaction) => reaction.reaction_type == reactionType + ) + console.log(reacts) + const displayInfos = useReactedDisplayList(reacts, user, userReacted) return ( - 💖 Liked{' '} + {capitalize(reactionType + 'd ')} {titleName ? titleName @@ -257,21 +341,32 @@ function UserLikedFullList(props: { ) } -function UserLikedPopup(props: { +function UserReactedPopup(props: { contentType: ReactionContentTypes contentId: string onRequestModal: () => void user?: User | null - userLiked?: boolean + userReacted?: boolean + reactionType: ReactionType }) { - const { contentType, contentId, onRequestModal, user, userLiked } = props - const reacts = useLikesOnContent(contentType, contentId) - const displayInfos = useLikeDisplayList(reacts, user, userLiked) + const { + contentType, + contentId, + onRequestModal, + user, + userReacted, + reactionType, + } = props + const reacts = useReactionsOnContent(contentType, contentId)?.filter( + (reaction: Reaction) => reaction.reaction_type == reactionType + ) + + const displayInfos = useReactedDisplayList(reacts, user, userReacted) if (displayInfos == null) { return ( -
Like
+
{capitalize(reactionType)}
) @@ -287,7 +382,7 @@ function UserLikedPopup(props: {
Like
{shown.map((u, i) => { - return + return })} {displayInfos.length > shown.length && (
diff --git a/web/components/feed/scored-feed-repost-item.tsx b/web/components/feed/scored-feed-repost-item.tsx index 7570993eef..187c24f15e 100644 --- a/web/components/feed/scored-feed-repost-item.tsx +++ b/web/components/feed/scored-feed-repost-item.tsx @@ -1,36 +1,36 @@ +import clsx from 'clsx' +import { Bet } from 'common/bet' import { ContractComment } from 'common/comment' import { Contract, contractPath } from 'common/contract' -import { - CommentReplyHeaderWithBet, - FeedCommentHeader, -} from '../comments/comment-header' -import { Col } from '../layout/col' -import clsx from 'clsx' -import { memo, useState } from 'react' -import { Row } from 'web/components/layout/row' -import { Avatar } from 'web/components/widgets/avatar' -import { FeedContractCard } from 'web/components/contract/feed-contract-card' -import { PrivateUser, User } from 'common/user' -import { TradesButton } from 'web/components/contract/trades-button' -import { TbDropletHeart, TbMoneybag } from 'react-icons/tb' import { ENV_CONFIG } from 'common/envs/constants' +import { Repost } from 'common/repost' +import { PrivateUser, User } from 'common/user' import { formatWithToken, shortFormatNumber } from 'common/util/format' -import { Button } from 'web/components/buttons/button' -import { Tooltip } from 'web/components/widgets/tooltip' -import { LikeButton } from 'web/components/contract/like-button' +import { removeUndefinedProps } from 'common/util/object' import { richTextToString } from 'common/util/parse' -import { isBlocked, usePrivateUser } from 'web/hooks/use-user' -import { CommentsButton } from 'web/components/comments/comments-button' import router from 'next/router' -import { ClickFrame } from 'web/components/widgets/click-frame' -import { CardReason } from 'web/components/feed/card-reason' -import { UserHovercard } from '../user/user-hovercard' -import { track } from 'web/lib/service/analytics' -import { removeUndefinedProps } from 'common/util/object' -import { Bet } from 'common/bet' +import { memo, useState } from 'react' +import { TbDropletHeart, TbMoneybag } from 'react-icons/tb' +import { Button } from 'web/components/buttons/button' +import { CommentsButton } from 'web/components/comments/comments-button' +import { FeedContractCard } from 'web/components/contract/feed-contract-card' +import { TradesButton } from 'web/components/contract/trades-button' import { FeedDropdown } from 'web/components/feed/card-dropdown' -import { Repost } from 'common/repost' +import { CardReason } from 'web/components/feed/card-reason' +import { Row } from 'web/components/layout/row' +import { Avatar } from 'web/components/widgets/avatar' +import { ClickFrame } from 'web/components/widgets/click-frame' import { CollapsibleContent } from 'web/components/widgets/collapsible-content' +import { Tooltip } from 'web/components/widgets/tooltip' +import { isBlocked, usePrivateUser } from 'web/hooks/use-user' +import { track } from 'web/lib/service/analytics' +import { + CommentReplyHeaderWithBet, + FeedCommentHeader, +} from '../comments/comment-header' +import { ReactButton } from '../contract/react-button' +import { Col } from '../layout/col' +import { UserHovercard } from '../user/user-hovercard' export const ScoredFeedRepost = memo(function (props: { contract: Contract @@ -282,7 +282,7 @@ export const BottomActionRow = (props: { /> - >[number] @@ -82,7 +82,7 @@ export const UserLikedContractsButton = memo( { - unLike(contract.id, 'contract') + unreact(contract.id, 'contract', 'like') setLikedContent( filteredLikedContent.filter((c) => c.id !== contract.id) ) diff --git a/web/hooks/use-batched-getter.ts b/web/hooks/use-batched-getter.ts index a519dec83b..5dd7043083 100644 --- a/web/hooks/use-batched-getter.ts +++ b/web/hooks/use-batched-getter.ts @@ -34,17 +34,17 @@ const executeBatchQuery = debounce(async () => { case 'markets': data = await api('markets-by-ids', { ids: Array.from(ids) }) break - case 'comment-likes': - case 'contract-likes': + case 'comment-reactions': + case 'contract-reactions': const contentType = queryType.split('-')[0] - const { data: likesData } = await run( + const { data: reactionsData } = await run( db .from('user_reactions') .select() .eq('content_type', contentType) .in('content_id', Array.from(ids)) ) - data = likesData + data = reactionsData break case 'contract-metrics': if (!userId) { @@ -80,9 +80,9 @@ const executeBatchQuery = debounce(async () => { const defaultFilters: Record> = { markets: (data: Contract[], id: string) => data.find((item) => item.id === id), - 'comment-likes': (data: Reaction[], id: string) => + 'comment-reactions': (data: Reaction[], id: string) => data.filter((item) => item.content_id === id), - 'contract-likes': (data: Reaction[], id: string) => + 'contract-reactions': (data: Reaction[], id: string) => data.filter((item) => item.content_id === id), 'contract-metrics': (data: string[], id: string) => data.includes(id), user: (data: DisplayUser[], id: string) => @@ -94,8 +94,8 @@ const defaultFilters: Record> = { export const useBatchedGetter = ( queryType: | 'markets' - | 'comment-likes' - | 'contract-likes' + | 'comment-reactions' + | 'contract-reactions' | 'contract-metrics' | 'user' | 'users', diff --git a/web/hooks/use-likes.ts b/web/hooks/use-reactions.ts similarity index 66% rename from web/hooks/use-likes.ts rename to web/hooks/use-reactions.ts index b71c23b336..17da9dd9f8 100644 --- a/web/hooks/use-likes.ts +++ b/web/hooks/use-reactions.ts @@ -1,16 +1,16 @@ import { Reaction, ReactionContentTypes } from 'common/reaction' import { useBatchedGetter } from './use-batched-getter' -export const useLikesOnContent = ( +export const useReactionsOnContent = ( contentType: ReactionContentTypes, contentId: string ) => { // ian: Batching avoids running ~40 queries on the market page with lots of comments - const [likes] = useBatchedGetter( - `${contentType}-likes`, + const [reactions] = useBatchedGetter( + `${contentType}-reactions`, contentId, undefined ) - return likes + return reactions } diff --git a/web/lib/supabase/reactions.ts b/web/lib/supabase/reactions.ts index 914e15773a..0f73fae8ee 100644 --- a/web/lib/supabase/reactions.ts +++ b/web/lib/supabase/reactions.ts @@ -1,27 +1,31 @@ import { run } from 'common/supabase/utils' import { db } from 'web/lib/supabase/db' import { api } from '../api/api' -import { ReactionContentTypes } from 'common/reaction' +import { ReactionContentTypes, ReactionType } from 'common/reaction' -export const unLike = async ( +export const unreact = async ( contentId: string, - contentType: ReactionContentTypes + contentType: ReactionContentTypes, + reactionType: ReactionType ) => { api('react', { remove: true, contentId, contentType, + reactionType, }) } -export const like = async ( +export const react = async ( contentId: string, - contentType: ReactionContentTypes + contentType: ReactionContentTypes, + reactionType: ReactionType ) => { api('react', { remove: false, contentId, contentType, + reactionType, }) }