diff --git a/packages/shared/src/features/profile/components/hotTakes/HotTakeItem.tsx b/packages/shared/src/features/profile/components/hotTakes/HotTakeItem.tsx index 2da789b0d9..0b1b193a94 100644 --- a/packages/shared/src/features/profile/components/hotTakes/HotTakeItem.tsx +++ b/packages/shared/src/features/profile/components/hotTakes/HotTakeItem.tsx @@ -1,7 +1,7 @@ import type { ReactElement } from 'react'; import React from 'react'; import classNames from 'classnames'; -import type { UserHotTake } from '../../../../graphql/user/userHotTake'; +import type { HotTake } from '../../../../graphql/user/userHotTake'; import { Typography, TypographyType, @@ -11,14 +11,20 @@ import { Button, ButtonSize, ButtonVariant, + ButtonColor, } from '../../../../components/buttons/Button'; -import { EditIcon, TrashIcon } from '../../../../components/icons'; +import { EditIcon, TrashIcon, UpvoteIcon } from '../../../../components/icons'; +import InteractionCounter from '../../../../components/InteractionCounter'; +import { IconSize } from '../../../../components/Icon'; +import { QuaternaryButton } from '../../../../components/buttons/QuaternaryButton'; +import { Tooltip } from '../../../../components/tooltip/Tooltip'; interface HotTakeItemProps { - item: UserHotTake; + item: HotTake; isOwner: boolean; - onEdit?: (item: UserHotTake) => void; - onDelete?: (item: UserHotTake) => void; + onEdit?: (item: HotTake) => void; + onDelete?: (item: HotTake) => void; + onUpvoteClick?: (item: HotTake) => void; } export function HotTakeItem({ @@ -26,8 +32,10 @@ export function HotTakeItem({ isOwner, onEdit, onDelete, + onUpvoteClick, }: HotTakeItemProps): ReactElement { const { emoji, title, subtitle } = item; + const isUpvoteActive = item.upvoted; return (
- “{title}” + {title} {subtitle && ( )}
- {isOwner && ( -
- {onEdit && ( -
+ )} + {onUpvoteClick && ( + + onUpvoteClick(item)} variant={ButtonVariant.Tertiary} size={ButtonSize.XSmall} - icon={} - onClick={() => onDelete(item)} - aria-label="Delete hot take" - /> - )} - - )} + icon={ + + } + > + {item.upvotes > 0 && ( + + )} + + + )} + ); } diff --git a/packages/shared/src/features/profile/components/hotTakes/HotTakeModal.tsx b/packages/shared/src/features/profile/components/hotTakes/HotTakeModal.tsx index 9b95278c38..aef305ff97 100644 --- a/packages/shared/src/features/profile/components/hotTakes/HotTakeModal.tsx +++ b/packages/shared/src/features/profile/components/hotTakes/HotTakeModal.tsx @@ -11,8 +11,8 @@ import { Button, ButtonVariant } from '../../../../components/buttons/Button'; import { ModalHeader } from '../../../../components/modals/common/ModalHeader'; import { useViewSize, ViewSize } from '../../../../hooks'; import type { - UserHotTake, - AddUserHotTakeInput, + HotTake, + AddHotTakeInput, } from '../../../../graphql/user/userHotTake'; const EmojiPicker = dynamic( @@ -32,8 +32,8 @@ const hotTakeFormSchema = z.object({ type HotTakeFormData = z.infer; type HotTakeModalProps = Omit & { - onSubmit: (input: AddUserHotTakeInput) => Promise; - existingItem?: UserHotTake; + onSubmit: (input: AddHotTakeInput) => Promise; + existingItem?: HotTake; }; export function HotTakeModal({ diff --git a/packages/shared/src/features/profile/components/hotTakes/ProfileUserHotTakes.tsx b/packages/shared/src/features/profile/components/hotTakes/ProfileUserHotTakes.tsx index 8528f82b16..6a2e56e170 100644 --- a/packages/shared/src/features/profile/components/hotTakes/ProfileUserHotTakes.tsx +++ b/packages/shared/src/features/profile/components/hotTakes/ProfileUserHotTakes.tsx @@ -1,7 +1,7 @@ import type { ReactElement } from 'react'; import React, { useState, useCallback } from 'react'; import type { PublicProfile } from '../../../../lib/user'; -import { useUserHotTakes, MAX_HOT_TAKES } from '../../hooks/useUserHotTakes'; +import { useHotTakes, MAX_HOT_TAKES } from '../../hooks/useHotTakes'; import { Typography, TypographyType, @@ -16,11 +16,13 @@ import { PlusIcon } from '../../../../components/icons'; import { HotTakeItem } from './HotTakeItem'; import { HotTakeModal } from './HotTakeModal'; import type { - UserHotTake, - AddUserHotTakeInput, + HotTake, + AddHotTakeInput, } from '../../../../graphql/user/userHotTake'; import { useToastNotification } from '../../../../hooks/useToastNotification'; import { usePrompt } from '../../../../hooks/usePrompt'; +import { useVoteHotTake } from '../../../../hooks/vote/useVoteHotTake'; +import { Origin } from '../../../../lib/log'; interface ProfileUserHotTakesProps { user: PublicProfile; @@ -30,15 +32,16 @@ export function ProfileUserHotTakes({ user, }: ProfileUserHotTakesProps): ReactElement | null { const { hotTakes, isOwner, canAddMore, add, update, remove } = - useUserHotTakes(user); + useHotTakes(user); const { displayToast } = useToastNotification(); const { showPrompt } = usePrompt(); + const { toggleUpvote } = useVoteHotTake(); const [isModalOpen, setIsModalOpen] = useState(false); - const [editingItem, setEditingItem] = useState(null); + const [editingItem, setEditingItem] = useState(null); const handleAdd = useCallback( - async (input: AddUserHotTakeInput) => { + async (input: AddHotTakeInput) => { try { await add(input); displayToast('Hot take added'); @@ -50,13 +53,13 @@ export function ProfileUserHotTakes({ [add, displayToast], ); - const handleEdit = useCallback((item: UserHotTake) => { + const handleEdit = useCallback((item: HotTake) => { setEditingItem(item); setIsModalOpen(true); }, []); const handleUpdate = useCallback( - async (input: AddUserHotTakeInput) => { + async (input: AddHotTakeInput) => { if (!editingItem) { return; } @@ -79,7 +82,7 @@ export function ProfileUserHotTakes({ ); const handleDelete = useCallback( - async (item: UserHotTake) => { + async (item: HotTake) => { const confirmed = await showPrompt({ title: 'Remove hot take?', description: `Are you sure you want to remove "${item.title}"?`, @@ -112,6 +115,13 @@ export function ProfileUserHotTakes({ setIsModalOpen(true); }, [canAddMore, displayToast]); + const handleUpvote = useCallback( + async (item: HotTake) => { + await toggleUpvote({ payload: item, origin: Origin.HotTakeList }); + }, + [toggleUpvote], + ); + const hasItems = hotTakes.length > 0; if (!hasItems && !isOwner) { @@ -149,6 +159,7 @@ export function ProfileUserHotTakes({ isOwner={isOwner} onEdit={handleEdit} onDelete={handleDelete} + onUpvoteClick={handleUpvote} /> ))} diff --git a/packages/shared/src/features/profile/hooks/useUserHotTakes.ts b/packages/shared/src/features/profile/hooks/useHotTakes.ts similarity index 73% rename from packages/shared/src/features/profile/hooks/useUserHotTakes.ts rename to packages/shared/src/features/profile/hooks/useHotTakes.ts index 8d4285ebf9..4d6b066f61 100644 --- a/packages/shared/src/features/profile/hooks/useUserHotTakes.ts +++ b/packages/shared/src/features/profile/hooks/useHotTakes.ts @@ -2,23 +2,23 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMemo, useCallback } from 'react'; import type { PublicProfile } from '../../../lib/user'; import type { - AddUserHotTakeInput, - UpdateUserHotTakeInput, - ReorderUserHotTakeInput, + AddHotTakeInput, + UpdateHotTakeInput, + ReorderHotTakeInput, } from '../../../graphql/user/userHotTake'; import { - getUserHotTakes, - addUserHotTake, - updateUserHotTake, - deleteUserHotTake, - reorderUserHotTakes, + getHotTakes, + addHotTake, + updateHotTake, + deleteHotTake, + reorderHotTakes, } from '../../../graphql/user/userHotTake'; import { generateQueryKey, RequestKey, StaleTime } from '../../../lib/query'; import { useAuthContext } from '../../../contexts/AuthContext'; export const MAX_HOT_TAKES = 5; -export function useUserHotTakes(user: PublicProfile | null) { +export const useHotTakes = (user: PublicProfile | null) => { const queryClient = useQueryClient(); const { user: loggedUser } = useAuthContext(); const isOwner = loggedUser?.id === user?.id; @@ -27,7 +27,7 @@ export function useUserHotTakes(user: PublicProfile | null) { const query = useQuery({ queryKey, - queryFn: () => getUserHotTakes(user?.id as string), + queryFn: () => getHotTakes(user?.id as string), staleTime: StaleTime.Default, enabled: !!user?.id, }); @@ -44,29 +44,23 @@ export function useUserHotTakes(user: PublicProfile | null) { }, [queryClient, queryKey]); const addMutation = useMutation({ - mutationFn: (input: AddUserHotTakeInput) => addUserHotTake(input), + mutationFn: (input: AddHotTakeInput) => addHotTake(input), onSuccess: invalidateQuery, }); const updateMutation = useMutation({ - mutationFn: ({ - id, - input, - }: { - id: string; - input: UpdateUserHotTakeInput; - }) => updateUserHotTake(id, input), + mutationFn: ({ id, input }: { id: string; input: UpdateHotTakeInput }) => + updateHotTake(id, input), onSuccess: invalidateQuery, }); const deleteMutation = useMutation({ - mutationFn: (id: string) => deleteUserHotTake(id), + mutationFn: (id: string) => deleteHotTake(id), onSuccess: invalidateQuery, }); const reorderMutation = useMutation({ - mutationFn: (items: ReorderUserHotTakeInput[]) => - reorderUserHotTakes(items), + mutationFn: (items: ReorderHotTakeInput[]) => reorderHotTakes(items), onSuccess: invalidateQuery, }); @@ -85,4 +79,4 @@ export function useUserHotTakes(user: PublicProfile | null) { isDeleting: deleteMutation.isPending, isReordering: reorderMutation.isPending, }; -} +}; diff --git a/packages/shared/src/graphql/user/userHotTake.ts b/packages/shared/src/graphql/user/userHotTake.ts index cf3b2ae160..07adfa22f0 100644 --- a/packages/shared/src/graphql/user/userHotTake.ts +++ b/packages/shared/src/graphql/user/userHotTake.ts @@ -2,49 +2,53 @@ import { gql } from 'graphql-request'; import type { Connection } from '../common'; import { gqlClient } from '../common'; -export interface UserHotTake { +export interface HotTake { id: string; emoji: string; title: string; subtitle: string | null; position: number; createdAt: string; + upvotes: number; + upvoted?: boolean; } -export interface AddUserHotTakeInput { +export interface AddHotTakeInput { emoji: string; title: string; subtitle?: string; } -export interface UpdateUserHotTakeInput { +export interface UpdateHotTakeInput { emoji?: string; title?: string; subtitle?: string | null; } -export interface ReorderUserHotTakeInput { +export interface ReorderHotTakeInput { id: string; position: number; } -const USER_HOT_TAKE_FRAGMENT = gql` - fragment UserHotTakeFragment on UserHotTake { +const HOT_TAKE_FRAGMENT = gql` + fragment HotTakeFragment on HotTake { id emoji title subtitle position createdAt + upvotes + upvoted } `; -const USER_HOT_TAKES_QUERY = gql` - query UserHotTakes($userId: ID!, $first: Int, $after: String) { - userHotTakes(userId: $userId, first: $first, after: $after) { +const HOT_TAKES_QUERY = gql` + query HotTakes($userId: ID!, $first: Int, $after: String) { + hotTakes(userId: $userId, first: $first, after: $after) { edges { node { - ...UserHotTakeFragment + ...HotTakeFragment } } pageInfo { @@ -53,82 +57,80 @@ const USER_HOT_TAKES_QUERY = gql` } } } - ${USER_HOT_TAKE_FRAGMENT} + ${HOT_TAKE_FRAGMENT} `; -const ADD_USER_HOT_TAKE_MUTATION = gql` - mutation AddUserHotTake($input: AddUserHotTakeInput!) { - addUserHotTake(input: $input) { - ...UserHotTakeFragment +const ADD_HOT_TAKE_MUTATION = gql` + mutation AddHotTake($input: AddHotTakeInput!) { + addHotTake(input: $input) { + ...HotTakeFragment } } - ${USER_HOT_TAKE_FRAGMENT} + ${HOT_TAKE_FRAGMENT} `; -const UPDATE_USER_HOT_TAKE_MUTATION = gql` - mutation UpdateUserHotTake($id: ID!, $input: UpdateUserHotTakeInput!) { - updateUserHotTake(id: $id, input: $input) { - ...UserHotTakeFragment +const UPDATE_HOT_TAKE_MUTATION = gql` + mutation UpdateHotTake($id: ID!, $input: UpdateHotTakeInput!) { + updateHotTake(id: $id, input: $input) { + ...HotTakeFragment } } - ${USER_HOT_TAKE_FRAGMENT} + ${HOT_TAKE_FRAGMENT} `; -const DELETE_USER_HOT_TAKE_MUTATION = gql` - mutation DeleteUserHotTake($id: ID!) { - deleteUserHotTake(id: $id) { +const DELETE_HOT_TAKE_MUTATION = gql` + mutation DeleteHotTake($id: ID!) { + deleteHotTake(id: $id) { _ } } `; -const REORDER_USER_HOT_TAKES_MUTATION = gql` - mutation ReorderUserHotTakes($items: [ReorderUserHotTakeInput!]!) { - reorderUserHotTakes(items: $items) { - ...UserHotTakeFragment +const REORDER_HOT_TAKES_MUTATION = gql` + mutation ReorderHotTakes($items: [ReorderHotTakeInput!]!) { + reorderHotTakes(items: $items) { + ...HotTakeFragment } } - ${USER_HOT_TAKE_FRAGMENT} + ${HOT_TAKE_FRAGMENT} `; -export const getUserHotTakes = async ( +export const getHotTakes = async ( userId: string, first = 50, -): Promise> => { +): Promise> => { const result = await gqlClient.request<{ - userHotTakes: Connection; - }>(USER_HOT_TAKES_QUERY, { userId, first }); - return result.userHotTakes; + hotTakes: Connection; + }>(HOT_TAKES_QUERY, { userId, first }); + return result.hotTakes; }; -export const addUserHotTake = async ( - input: AddUserHotTakeInput, -): Promise => { +export const addHotTake = async (input: AddHotTakeInput): Promise => { const result = await gqlClient.request<{ - addUserHotTake: UserHotTake; - }>(ADD_USER_HOT_TAKE_MUTATION, { input }); - return result.addUserHotTake; + addHotTake: HotTake; + }>(ADD_HOT_TAKE_MUTATION, { input }); + return result.addHotTake; }; -export const updateUserHotTake = async ( +export const updateHotTake = async ( id: string, - input: UpdateUserHotTakeInput, -): Promise => { + input: UpdateHotTakeInput, +): Promise => { const result = await gqlClient.request<{ - updateUserHotTake: UserHotTake; - }>(UPDATE_USER_HOT_TAKE_MUTATION, { id, input }); - return result.updateUserHotTake; + updateHotTake: HotTake; + }>(UPDATE_HOT_TAKE_MUTATION, { id, input }); + return result.updateHotTake; }; -export const deleteUserHotTake = async (id: string): Promise => { - await gqlClient.request(DELETE_USER_HOT_TAKE_MUTATION, { id }); +export const deleteHotTake = async (id: string): Promise => { + await gqlClient.request(DELETE_HOT_TAKE_MUTATION, { id }); }; -export const reorderUserHotTakes = async ( - items: ReorderUserHotTakeInput[], -): Promise => { +export const reorderHotTakes = async ( + items: ReorderHotTakeInput[], +): Promise => { const result = await gqlClient.request<{ - reorderUserHotTakes: UserHotTake[]; - }>(REORDER_USER_HOT_TAKES_MUTATION, { items }); - return result.reorderUserHotTakes; + reorderHotTakes: HotTake[]; + }>(REORDER_HOT_TAKES_MUTATION, { items }); + return result.reorderHotTakes; }; diff --git a/packages/shared/src/hooks/vote/index.ts b/packages/shared/src/hooks/vote/index.ts index 01a022d83b..129b2ab469 100644 --- a/packages/shared/src/hooks/vote/index.ts +++ b/packages/shared/src/hooks/vote/index.ts @@ -3,3 +3,4 @@ export * from './useVotePost'; export * from './useFeedVotePost'; export * from './useReadHistoryVotePost'; export * from './useVoteComment'; +export * from './useVoteHotTake'; diff --git a/packages/shared/src/hooks/vote/types.ts b/packages/shared/src/hooks/vote/types.ts index de70ca16d9..36ec03658d 100644 --- a/packages/shared/src/hooks/vote/types.ts +++ b/packages/shared/src/hooks/vote/types.ts @@ -46,6 +46,7 @@ export type UseVotePost = { export enum UserVoteEntity { Comment = 'comment', Post = 'post', + HotTake = 'hot_take', } export type UseVoteMutationProps = { diff --git a/packages/shared/src/hooks/vote/useVoteHotTake.ts b/packages/shared/src/hooks/vote/useVoteHotTake.ts new file mode 100644 index 0000000000..613e056ca8 --- /dev/null +++ b/packages/shared/src/hooks/vote/useVoteHotTake.ts @@ -0,0 +1,183 @@ +import { useContext, useCallback } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import AuthContext from '../../contexts/AuthContext'; +import { UserVote } from '../../graphql/posts'; +import { AuthTriggers } from '../../lib/auth'; +import type { HotTake } from '../../graphql/user/userHotTake'; +import type { Connection } from '../../graphql/common'; +import type { UseVoteProps, ToggleVoteProps } from './types'; +import { UserVoteEntity } from './types'; +import { useVote } from './useVote'; + +const hotTakeMutationHandlers: Record< + UserVote, + (hotTake: HotTake) => Partial +> = { + [UserVote.Up]: (hotTake) => ({ + upvotes: hotTake.upvotes + 1, + upvoted: true, + }), + [UserVote.Down]: (hotTake) => ({ + upvotes: hotTake.upvoted ? hotTake.upvotes - 1 : hotTake.upvotes, + upvoted: false, + }), + [UserVote.None]: (hotTake) => ({ + upvotes: hotTake.upvoted ? hotTake.upvotes - 1 : hotTake.upvotes, + upvoted: false, + }), +}; + +export interface UseVoteHotTakeProps extends Pick { + variables?: unknown; +} + +export interface UseVoteHotTake { + upvoteHotTake: (props: { id: string }) => Promise; + downvoteHotTake: (props: { id: string }) => Promise; + cancelHotTakeVote: (props: { id: string }) => Promise; + toggleUpvote: ( + props: Omit, 'entity'>, + ) => Promise; + toggleDownvote: ( + props: Omit, 'entity'>, + ) => Promise; +} + +const useVoteHotTake = ({ + onMutate, + variables, +}: UseVoteHotTakeProps = {}): UseVoteHotTake => { + const client = useQueryClient(); + const { user, showLogin } = useContext(AuthContext); + + const defaultOnMutate = ({ id, vote }) => { + const mutationHandler = hotTakeMutationHandlers[vote]; + + if (!mutationHandler) { + return undefined; + } + + // Find and update the hot take in cache + const queryKeys = client + .getQueryCache() + .findAll({ queryKey: ['user_hot_takes'] }); + + let previousVote: UserVote | undefined; + + queryKeys.forEach((query) => { + const data = query.state.data as Connection; + if (!data?.edges) { + return; + } + + const hotTakeEdge = data.edges.find((edge) => edge.node.id === id); + + if (hotTakeEdge) { + previousVote = hotTakeEdge.node.upvoted ? UserVote.Up : UserVote.None; + + client.setQueryData(query.queryKey, { + ...data, + edges: data.edges.map((edge) => + edge.node.id === id + ? { + ...edge, + node: { + ...edge.node, + ...mutationHandler(edge.node), + }, + } + : edge, + ), + }); + } + }); + + return () => { + const rollbackMutationHandler = hotTakeMutationHandlers[previousVote]; + + if (!rollbackMutationHandler) { + return; + } + + queryKeys.forEach((query) => { + const data = query.state.data as Connection; + if (!data?.edges) { + return; + } + + client.setQueryData(query.queryKey, { + ...data, + edges: data.edges.map((edge) => + edge.node.id === id + ? { + ...edge, + node: { + ...edge.node, + ...rollbackMutationHandler(edge.node), + }, + } + : edge, + ), + }); + }); + }; + }; + + const { + upvote: upvoteHotTake, + downvote: downvoteHotTake, + cancelVote: cancelHotTakeVote, + } = useVote({ + onMutate: onMutate || defaultOnMutate, + entity: UserVoteEntity.HotTake, + variables, + }); + + const toggleUpvote: UseVoteHotTake['toggleUpvote'] = useCallback( + async ({ payload: hotTake }) => { + if (!hotTake) { + return; + } + + if (!user) { + showLogin({ trigger: AuthTriggers.Upvote }); + return; + } + + if (hotTake?.upvoted) { + await cancelHotTakeVote({ id: hotTake.id }); + return; + } + + await upvoteHotTake({ id: hotTake.id }); + }, + [cancelHotTakeVote, showLogin, upvoteHotTake, user], + ); + + const toggleDownvote: UseVoteHotTake['toggleDownvote'] = useCallback( + async ({ payload: hotTake }) => { + if (!hotTake) { + return; + } + + if (!user) { + showLogin({ trigger: AuthTriggers.Downvote }); + return; + } + + // Hot takes don't support downvotes, just cancel the vote + await cancelHotTakeVote({ id: hotTake.id }); + }, + [cancelHotTakeVote, showLogin, user], + ); + + return { + upvoteHotTake, + downvoteHotTake, + cancelHotTakeVote, + toggleUpvote, + toggleDownvote, + }; +}; + +export { useVoteHotTake }; diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index a7cd174998..4782aaf0c0 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -78,6 +78,7 @@ export enum Origin { BriefModal = 'brief modal', BriefPage = 'brief page', SquadBoost = 'squad boost', + HotTakeList = 'hot take list', } export enum LogEvent {