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 && (
-
}
- onClick={() => onEdit(item)}
- aria-label="Edit hot take"
- />
- )}
- {onDelete && (
-
- )}
+ 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 {