diff --git a/apps/web/package.json b/apps/web/package.json index 5ba2b320..5a406f34 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -28,6 +28,7 @@ "@heroicons/react": "v2", "@reduxjs/toolkit": "^1.9.3", "@tailwindcss/line-clamp": "^0.4.4", + "@tailwindcss/typography": "^0.5.9", "@tanstack/react-query": "^4.20.4", "@trpc/client": "10.7.0", "@trpc/next": "10.7.0", @@ -47,8 +48,10 @@ "prettier-plugin-tailwindcss": "^0.2.1", "react": "18.2.0", "react-dom": "18.2.0", + "react-markdown": "^8.0.7", "react-redux": "^8.0.5", "react-toastify": "^9.1.1", + "remark-gfm": "^3.0.1", "siwe": "^1.1.6", "superjson": "1.12.1", "wagmi": "^0.10.2", diff --git a/apps/web/src/components/Comment/Comment.test.tsx b/apps/web/src/components/Comment/Comment.test.tsx index 481ac9c3..0c6eef60 100644 --- a/apps/web/src/components/Comment/Comment.test.tsx +++ b/apps/web/src/components/Comment/Comment.test.tsx @@ -5,21 +5,52 @@ import {Provider} from "react-redux"; import {store} from "../../store"; describe("", () => { + const upVote = jest.fn(); + const downVote = jest.fn(); + const props = { + onUpVote: upVote, + onDownVote: downVote, + comment: {id: "abc"}, + } + + beforeEach(() => jest.resetAllMocks()); + const renderComponent = (ui) => { return render({ui}); } it("should render comment component with no issues", () => { - const result = renderComponent(); + const result = renderComponent(); expect(result.container).toBeInTheDocument(); }); it("should dispatch profile id on profile icon click", async () => { - dispatchMock.mockReset(); - renderComponent(); + renderComponent(); await act(async () => { fireEvent.click(document.getElementById("profile")); }); expect(dispatchMock).toBeCalledTimes(1); }); + + it("should call upvote fn on upvote click", async () => { + renderComponent(); + upVote.mockResolvedValue({}); + expect(document.getElementById("up-vote")).toBeTruthy(); + await act(async () => { + fireEvent.click(document.getElementById("up-vote")); + }); + expect(upVote).toBeCalled(); + expect(upVote).toBeCalledWith("abc"); + }); + + it("should call downvote fn on downvote click", async () => { + renderComponent(); + downVote.mockResolvedValue({}); + expect(document.getElementById("down-vote")).toBeTruthy(); + await act(async () => { + fireEvent.click(document.getElementById("down-vote")); + }); + expect(downVote).toBeCalled(); + expect(downVote).toBeCalledWith("abc"); + }); }); diff --git a/apps/web/src/components/Comment/Comment.tsx b/apps/web/src/components/Comment/Comment.tsx index 27a8b43c..37e3a5d1 100644 --- a/apps/web/src/components/Comment/Comment.tsx +++ b/apps/web/src/components/Comment/Comment.tsx @@ -1,11 +1,36 @@ import {FlexColumn, FlexRow} from "../Flex"; import Image from "next/image"; import {showUserProfile, useAppDispatch} from "../../store"; +import {DownVote, Spinner, UpVote} from "../Icons"; +import {useState} from "react"; +import * as utils from "../../utils"; +import {Markdown} from "../Markdown"; -export const Comment = ({comment}) => { +interface CommentProps { + comment: any; + onUpVote(commentId: string): Promise; + onDownVote(commentId: string): Promise; +} + +export const Comment = (props: CommentProps) => { + const {comment, onUpVote, onDownVote} = props; const userId = comment?.user?.id; const user = comment?.user?.userPlatforms[0]; + const absVotes = utils.getAbsVotes(comment?.votes); + const dispatch = useAppDispatch(); + const [isUpVoting, setIsUpVoting] = useState(false); + const [isDownVoting, setIsDownVoting] = useState(false); + + const handleUpVote = () => { + setIsUpVoting(true); + onUpVote(comment.id).finally(() => setIsUpVoting(false)); + } + + const handleDownVote = () => { + setIsDownVoting(true); + onDownVote(comment.id).finally(() => setIsDownVoting(false)); + } return (
@@ -24,8 +49,8 @@ export const Comment = ({comment}) => { alt={`${user?.platformUsername} avatar`} />
- - + +
{user?.platformUsername}
@@ -39,9 +64,14 @@ export const Comment = ({comment}) => { )}
-
- {comment?.text} +
+
+ + {isUpVoting ? : } + {utils.formatNumber(absVotes)} + {isDownVoting ? : } +
); diff --git a/apps/web/src/components/Icons/Icons.tsx b/apps/web/src/components/Icons/Icons.tsx index 39f27184..4b01dbf4 100644 --- a/apps/web/src/components/Icons/Icons.tsx +++ b/apps/web/src/components/Icons/Icons.tsx @@ -1,12 +1,13 @@ import * as utils from "../../utils"; import {IconProps} from "./types"; +import {SVGProps} from "react"; export const Spinner = (props: IconProps) => { return ( { ); }; + +export const UpVote = (props: SVGProps) => { + return ( + + + + + + + + + + + ); +}; + +export const DownVote = (props: SVGProps) => { + return ( + + + + + + + + + + + ); +}; diff --git a/apps/web/src/components/Markdown/Markdown.tsx b/apps/web/src/components/Markdown/Markdown.tsx new file mode 100644 index 00000000..8c09f381 --- /dev/null +++ b/apps/web/src/components/Markdown/Markdown.tsx @@ -0,0 +1,13 @@ +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; + +export const Markdown = (props: {markdown: string}) => { + return ( + + {props.markdown} + + ); +} diff --git a/apps/web/src/components/Markdown/index.tsx b/apps/web/src/components/Markdown/index.tsx new file mode 100644 index 00000000..3306b090 --- /dev/null +++ b/apps/web/src/components/Markdown/index.tsx @@ -0,0 +1 @@ +export * from "./Markdown"; diff --git a/apps/web/src/components/Thread/Thread.tsx b/apps/web/src/components/Thread/Thread.tsx index 070b1fbd..83fe50ed 100644 --- a/apps/web/src/components/Thread/Thread.tsx +++ b/apps/web/src/components/Thread/Thread.tsx @@ -2,6 +2,7 @@ import {ThreadProps} from "./type"; import {FlexColumn, FlexRow} from "../Flex"; import Image from "next/image"; import {showUserProfile, useAppDispatch} from "../../store"; +import {Markdown} from "../Markdown"; export const Thread = ({thread}: ThreadProps) => { const userId = thread?.user?.id; @@ -44,7 +45,7 @@ export const Thread = ({thread}: ThreadProps) => { {thread?.title}
- {thread?.body} +
diff --git a/apps/web/src/pages/community.tsx b/apps/web/src/pages/community.tsx index a3a8d3b9..5ee42cf4 100644 --- a/apps/web/src/pages/community.tsx +++ b/apps/web/src/pages/community.tsx @@ -4,7 +4,7 @@ import {useRouter} from "next/router"; import {useEffect, useState} from "react"; import {Loader} from "../components/Loader"; import {ThreadCard} from "../components/ThreadCard"; -import {ThreadSection} from "../components/ThreadSection"; +import {ThreadSection} from "../sections"; import {trpc} from "../utils/trpc"; import {Search} from "../components/Search"; import {CreateThread} from "../components/Thread"; diff --git a/apps/web/src/pages/feed.tsx b/apps/web/src/pages/feed.tsx index 576bf25e..c723a716 100644 --- a/apps/web/src/pages/feed.tsx +++ b/apps/web/src/pages/feed.tsx @@ -3,7 +3,7 @@ import { useRouter } from "next/router"; import { useEffect, useState } from "react"; import { Loader } from "../components/Loader"; import { ThreadCard } from "../components/ThreadCard"; -import { ThreadSection } from "../components/ThreadSection"; +import { ThreadSection } from "../sections"; import { trpc } from "../utils/trpc"; import { Search } from "../components/Search"; import {useAppSelector} from "../store"; @@ -20,7 +20,7 @@ const FeedPage = () => { const threads: any[] = feedData.data && flatten(feedData.data.edges.map((relation) => { return relation.node.community.threads.edges.map((thread) => thread); })); - + useEffect(() => { if (threadId) setCurrentThread(threadId); }, [threadId]); @@ -36,7 +36,7 @@ const FeedPage = () => { if (feedData.isLoading) { return ; } - + return (
diff --git a/apps/web/src/components/ThreadSection/ThreadSection.test.tsx b/apps/web/src/sections/ThreadSection/ThreadSection.test.tsx similarity index 93% rename from apps/web/src/components/ThreadSection/ThreadSection.test.tsx rename to apps/web/src/sections/ThreadSection/ThreadSection.test.tsx index 5148000f..5af248d3 100644 --- a/apps/web/src/components/ThreadSection/ThreadSection.test.tsx +++ b/apps/web/src/sections/ThreadSection/ThreadSection.test.tsx @@ -1,3 +1,4 @@ +import "../../../test/setup"; import {ThreadSection} from "./ThreadSection"; import {Provider} from "react-redux"; import {store} from "../../store"; @@ -65,6 +66,12 @@ jest.mock('../../utils/trpc', () => ({ fetchCommentsByThreadId: { useInfiniteQuery: () => mockFetchCommentsByThreadId, }, + upVoteComment: { + useMutation: () => ({mutateAsync: mutationMock}), + }, + downVoteComment: { + useMutation: () => ({mutateAsync: mutationMock}), + } }, }, })); diff --git a/apps/web/src/components/ThreadSection/ThreadSection.tsx b/apps/web/src/sections/ThreadSection/ThreadSection.tsx similarity index 62% rename from apps/web/src/components/ThreadSection/ThreadSection.tsx rename to apps/web/src/sections/ThreadSection/ThreadSection.tsx index 9a06f1f4..3db2a5e3 100644 --- a/apps/web/src/components/ThreadSection/ThreadSection.tsx +++ b/apps/web/src/sections/ThreadSection/ThreadSection.tsx @@ -1,14 +1,15 @@ import {ThreadSectionProps} from "./types"; import {trpc} from "../../utils/trpc"; -import {Thread} from "../Thread"; -import {Comment} from "../Comment"; +import {Thread} from "../../components/Thread"; +import {Comment} from "../../components/Comment"; import {useRef, useState} from "react"; import {useAppSelector} from "../../store"; import {toast} from "react-toastify"; import {constants} from "../../config"; import {isRight} from "../../utils/fp"; -import {Loader} from "../Loader"; -import {LoadMore} from "../Button/LoadMore"; +import {Loader} from "../../components/Loader"; +import {LoadMore} from "../../components/Button/LoadMore"; +import {get, has, isNil} from "lodash"; const SendIcon = () => { return ( @@ -30,8 +31,16 @@ export const ThreadSection = (props: ThreadSectionProps) => { const currentThread = trpc.public.fetchThreadDetails.useQuery({threadId}); const createComment = trpc.comment.createComment.useMutation(); - // @ts-ignore - const {data, fetchNextPage, hasNextPage, isFetching, refetch} = trpc.comment.fetchCommentsByThreadId.useInfiniteQuery({ + const upVoteComment = trpc.comment.upVoteComment.useMutation(); + const downVoteComment = trpc.comment.downVoteComment.useMutation(); + const { + data, + fetchNextPage, + hasNextPage, + isFetching, + refetch + // @ts-ignore + } = trpc.comment.fetchCommentsByThreadId.useInfiniteQuery({ threadId: threadId, first: 20, }, @@ -49,7 +58,7 @@ export const ThreadSection = (props: ThreadSectionProps) => { } const handleOnCommentSubmit = async () => { - if (comment.length === 0) { + if (comment.trim().length === 0) { toast.warn("Comment cannot be empty"); return; } @@ -77,6 +86,42 @@ export const ThreadSection = (props: ThreadSectionProps) => { } } + const handleUpVote = async (commentId: string) => { + if (!has(user, "id") || isNil(get(user, "didSession"))) { + toast.error("Please re-connect with your wallet!"); + return; + } + const result = await upVoteComment.mutateAsync({ + session: get(user, "didSession"), + userId: get(user, "id"), + commentId: commentId, + }); + if (isRight(result)) { + toast.success("Up vote added"); + await refetch(); + } else { + toast.error("Up vote failed to add"); + } + } + + const handleDownVote = async (commentId: string) => { + if (!has(user, "id") || isNil(get(user, "didSession"))) { + toast.error("Please re-connect with your wallet!"); + return; + } + const result = await downVoteComment.mutateAsync({ + session: get(user, "didSession"), + userId: get(user, "id"), + commentId: commentId, + }); + if (isRight(result)) { + toast.success("Down vote added"); + await refetch(); + } else { + toast.error("Down vote failed to add"); + } + } + return (
@@ -84,7 +129,12 @@ export const ThreadSection = (props: ThreadSectionProps) => {
{data?.pages?.map((page) => ( page?.edges.map((item) => ( - + )) ))} {
-
+