From a7d5d3f9cdce267eb20ecabcffa74d4d2bf55333 Mon Sep 17 00:00:00 2001 From: ingawei Date: Tue, 12 Nov 2024 13:26:59 -0800 Subject: [PATCH] upvote and downvote comments --- backend/api/src/reaction.ts | 72 +++++-- common/src/api/schema.ts | 1 + common/src/reaction.ts | 2 + web/components/comments/comment-actions.tsx | 28 ++- .../contract/contract-summary-stats.tsx | 6 +- .../contract/feed-contract-card.tsx | 4 +- .../{like-button.tsx => react-button.tsx} | 177 ++++++++++++------ .../feed/scored-feed-repost-item.tsx | 52 ++--- .../profile/user-liked-contracts-button.tsx | 14 +- web/hooks/{use-likes.ts => use-reactions.ts} | 27 +-- web/lib/supabase/reactions.ts | 14 +- 11 files changed, 262 insertions(+), 135 deletions(-) rename web/components/contract/{like-button.tsx => react-button.tsx} (62%) rename web/hooks/{use-likes.ts => use-reactions.ts} (71%) diff --git a/backend/api/src/reaction.ts b/backend/api/src/reaction.ts index 8fcd3a2256..c06c95eb6c 100644 --- a/backend/api/src/reaction.ts +++ b/backend/api/src/reaction.ts @@ -5,17 +5,21 @@ import { assertUnreachable } from 'common/util/types' import { log } from 'shared/utils' 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 @@ -53,37 +57,75 @@ export const addOrRemoveReaction: APIHandler<'react'> = async (props, auth) => { [contentId, contentType, userId] ) + console.log( + 'existingReactions*****************************************************************', + existingReactions + ) 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) { + log('Reaction already exists, do nothing') + return { result: { success: true }, continue: async () => {} } + } else { + console.log( + 'NOT EQUAL, DELETING***************************************************************8' + ) + // otherwise, remove the other reaction type + await deleteReaction(existingReactionType) + } } + console.log( + 'DOINT INSERT NOW ***************************************************************8' + ) + // 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) + console.log( + 'INSERT RESULT*****************************************************************', + 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/common/src/api/schema.ts b/common/src/api/schema.ts index 02ce563f12..e316cde5e2 100644 --- a/common/src/api/schema.ts +++ b/common/src/api/schema.ts @@ -1014,6 +1014,7 @@ export const API = (_apiTypeCheck = { contentId: z.string(), contentType: z.enum(['comment', 'contract']), remove: z.boolean().optional(), + reactionType: z.string().optional().default('like'), }) .strict(), returns: { success: true }, 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..e957fdc38a 100644 --- a/web/components/comments/comment-actions.tsx +++ b/web/components/comments/comment-actions.tsx @@ -2,20 +2,20 @@ 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' export function CommentActions(props: { onReplyClick?: (comment: ContractComment) => void @@ -99,7 +99,19 @@ export function CommentActions(props: { )} - + {showBetModal && ( {marketTier && } {!isBlocked(privateUser, contract.creatorId) && ( - - { - if (likes) setLiked(likes.some((l) => l.user_id === user?.id)) - }, [likes, user]) + if (reactions) setReacted(reactions.some((l) => l.user_id === user?.id)) + }, [reactions, user]) const totalLikes = - (likes ? likes.filter((l) => l.user_id != user?.id).length : 0) + - (liked ? 1 : 0) + (reactions ? reactions.filter((l) => 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) { + await react(contentId, contentType, reactionType) track( 'like', @@ -91,12 +96,12 @@ export const LikeButton = memo(function LikeButton(props: { }) ) } else { - await unLike(contentId, contentType) + await unreact(contentId, contentType, reactionType) } } function handleLiked(liked: boolean) { - onLike(liked) + onReact(liked) } const likeLongPress = useLongTouch( @@ -108,14 +113,15 @@ export const LikeButton = memo(function LikeButton(props: { if (isMe) { toast("Of course you'd like yourself", { icon: '🙄' }) } else { - handleLiked(!liked) + handleLiked(!reacted) } } } ) - const otherLikes = liked ? totalLikes - 1 : totalLikes + const otherLikes = reacted ? totalLikes - 1 : totalLikes const showList = otherLikes > 0 + const thumbIcon = iconType == 'thumb' || reactionType == 'dislike' return ( <> @@ -127,10 +133,11 @@ export const LikeButton = memo(function LikeButton(props: { contentId={contentId} onRequestModal={() => setModalOpen(true)} user={user} - userLiked={liked} + userReacted={reacted} + reactionType={reactionType} /> ) : ( - 'Like' + capitalize(reactionType) ) } placement={placement} @@ -151,13 +158,31 @@ export const LikeButton = memo(function LikeButton(props: { >
- + {thumbIcon ? ( + reactionType == 'dislike' ? ( + + ) : ( + + ) + ) : ( + + )}
{totalLikes > 0 && (
{totalLikes}
@@ -178,14 +203,34 @@ export const LikeButton = memo(function LikeButton(props: { >
- + {thumbIcon ? ( + reactionType == 'dislike' ? ( + + ) : ( + + ) + ) : ( + + )}
{totalLikes > 0 && (
@@ -197,20 +242,21 @@ export const LikeButton = memo(function LikeButton(props: { )} {modalOpen && ( - )} ) }) -function useLikeDisplayList( +function useReactedDisplayList( reacts: Reaction[] = [], self?: User | null, prependSelf?: boolean @@ -223,17 +269,26 @@ 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, reactionType) + const displayInfos = useReactedDisplayList(reacts, user, userReacted) return ( 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, reactionType) + const displayInfos = useReactedDisplayList(reacts, user, userReacted) if (displayInfos == null) { return ( 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-likes.ts b/web/hooks/use-reactions.ts similarity index 71% rename from web/hooks/use-likes.ts rename to web/hooks/use-reactions.ts index 0c4cdeeccd..f5efab50f3 100644 --- a/web/hooks/use-likes.ts +++ b/web/hooks/use-reactions.ts @@ -1,4 +1,4 @@ -import { Reaction, ReactionContentTypes } from 'common/reaction' +import { Reaction, ReactionContentTypes, ReactionType } from 'common/reaction' import { db } from 'web/lib/supabase/db' import { useEffect } from 'react' import { run } from 'common/supabase/utils' @@ -8,7 +8,7 @@ import { debounce } from 'lodash' const pendingRequests: Map> = new Map() const pendingCallbacks: Map void)[]> = new Map() -const executeBatchLikesQuery = debounce(async () => { +const executeBatchLikesQuery = debounce(async (reactionType: ReactionType) => { for (const [contentType, contentIds] of pendingRequests.entries()) { if (!contentIds.size) continue @@ -17,11 +17,12 @@ const executeBatchLikesQuery = debounce(async () => { .from('user_reactions') .select() .eq('content_type', contentType) + .eq('reaction_type', reactionType) .in('content_id', Array.from(contentIds)) ) contentIds.forEach((contentId) => { - const key = `${contentType}-${contentId}` + const key = `${contentType}-${contentId}-${reactionType}` const callbacks = pendingCallbacks.get(key) || [] const filteredData = (data || []).filter( (item) => item.content_id === contentId @@ -34,14 +35,14 @@ const executeBatchLikesQuery = debounce(async () => { } }, 50) -export const useLikesOnContent = ( +export const useReactionsOnContent = ( contentType: ReactionContentTypes, - contentId: string + contentId: string, + reactionType: ReactionType ) => { - const [likes, setLikes] = usePersistentInMemoryState( - undefined, - `${contentType}-likes-on-${contentId}` - ) + const [reactions, setReactions] = usePersistentInMemoryState< + Reaction[] | undefined + >(undefined, `${contentType}-${reactionType}-on-${contentId}`) useEffect(() => { if (!pendingRequests.has(contentType)) { @@ -53,14 +54,14 @@ export const useLikesOnContent = ( if (!pendingCallbacks.has(key)) { pendingCallbacks.set(key, []) } - pendingCallbacks.get(key)!.push(setLikes) + pendingCallbacks.get(key)!.push(setReactions) - executeBatchLikesQuery() + executeBatchLikesQuery(reactionType) return () => { const callbacks = pendingCallbacks.get(key) if (callbacks) { - const index = callbacks.indexOf(setLikes) + const index = callbacks.indexOf(setReactions) if (index > -1) { callbacks.splice(index, 1) } @@ -71,5 +72,5 @@ export const useLikesOnContent = ( } }, [contentType, contentId]) - 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, }) }