From 151c8398caac3155d50ccd7cd416306043069622 Mon Sep 17 00:00:00 2001 From: ap-atul Date: Wed, 12 Apr 2023 16:59:08 +0530 Subject: [PATCH 1/6] added mutation and query for votes --- packages/composedb/Query/index.ts | 4 +-- packages/composedb/Query/mutation.ts | 52 ++++++++++++++++++++++++++++ packages/composedb/Query/query.ts | 31 +++++++++++++++++ packages/composedb/Query/type.ts | 15 +++++++- 4 files changed, 99 insertions(+), 3 deletions(-) diff --git a/packages/composedb/Query/index.ts b/packages/composedb/Query/index.ts index 05f277a5..250a75d7 100644 --- a/packages/composedb/Query/index.ts +++ b/packages/composedb/Query/index.ts @@ -1,3 +1,3 @@ -export {composeMutationHandler} from "./mutation" -export {composeQueryHandler} from "./query" +export * from "./mutation" +export * from "./query" export * from "./type" diff --git a/packages/composedb/Query/mutation.ts b/packages/composedb/Query/mutation.ts index fa51dae4..99e0186d 100644 --- a/packages/composedb/Query/mutation.ts +++ b/packages/composedb/Query/mutation.ts @@ -2,6 +2,7 @@ import { ComposeClient } from "@composedb/client"; import { CommentInput, CommunityDetails, + CreateVoteInput, SocialCommentId, SocialPlatformInput, SocialThreadId, @@ -330,3 +331,54 @@ export const composeMutationHandler = async (compose: ComposeClient) => { }, }; }; + +const createVoteComment = async (compose: ComposeClient, input: CreateVoteInput) => { + const query = gql` + mutation UpVoteComment($input: CreateVoteInput!) { + createVote(input: $input) { + document { + id + userId + commentId + vote + } + } + } + `; + return await compose.executeQuery(query, { + input: { + content: input, + } + }); +} + +export const updateVoteComment = async (compose: ComposeClient, voteId: string, vote: boolean) => { + const query = gql` + mutation UpdateVoteComment($input: UpdateVoteInput!) { + updateVote(input: $input) { + document { + id + vote + } + } + } + `; + return await compose.executeQuery(query, { + input: { + id: voteId, + content: {vote} + }, + }); +} + +export const upVoteComment = async (compose: ComposeClient, commentId: string, userId: string) => { + return createVoteComment(compose, { + vote: true, commentId, userId, + }); +} + +export const downVoteComment = async (compose: ComposeClient, commentId: string, userId: string) => { + return createVoteComment(compose, { + vote: false, commentId, userId, + }); +} diff --git a/packages/composedb/Query/query.ts b/packages/composedb/Query/query.ts index 25edb00f..7f1ea2aa 100644 --- a/packages/composedb/Query/query.ts +++ b/packages/composedb/Query/query.ts @@ -9,6 +9,7 @@ import { User, UserCommunities, UserFeedResponse, + Vote, } from "./type"; const client = new GraphQLClient(String(process.env.CERAMIC_GRAPH), {}); @@ -662,6 +663,14 @@ export const composeQueryHandler = () => { platformUsername } } + votes(first: 100) { + edges { + node { + id + vote + } + } + } } } } @@ -840,3 +849,25 @@ export const composeQueryHandler = () => { }, }; }; + +// TODO: use where clause when available +export const getUserVoteOnComment = async (userId: string, commentId: string) => { + const query = gql` + { + voteIndex(first: 500) { + edges { + node { + id + userId + commentId + vote + } + } + } + } + `; + const response = await client.request(query); + return response.voteIndex.edges.find((vote: Node) => ( + vote.node.userId === userId && vote.node.commentId === commentId + )); +} diff --git a/packages/composedb/Query/type.ts b/packages/composedb/Query/type.ts index bd6104f5..259bbfdd 100644 --- a/packages/composedb/Query/type.ts +++ b/packages/composedb/Query/type.ts @@ -175,4 +175,17 @@ export interface UserCommunities { export interface CommunityExt extends Community{ description: string, communityName: string -} \ No newline at end of file +} + +export interface Vote { + id: string; + userId: string; + commentId: string; + vote: boolean; +} + +export interface CreateVoteInput { + vote: boolean; + userId: string; + commentId: string; +} From 78519c6e6e55bd47741b5c027a09a6cf34db5cb0 Mon Sep 17 00:00:00 2001 From: ap-atul Date: Wed, 12 Apr 2023 16:59:36 +0530 Subject: [PATCH 2/6] added apis for router --- apps/web/src/server/trpc/router/comment.ts | 69 +++++++++++++++++++++- 1 file changed, 67 insertions(+), 2 deletions(-) diff --git a/apps/web/src/server/trpc/router/comment.ts b/apps/web/src/server/trpc/router/comment.ts index 15c8480a..ef63cb4b 100644 --- a/apps/web/src/server/trpc/router/comment.ts +++ b/apps/web/src/server/trpc/router/comment.ts @@ -1,12 +1,19 @@ import {publicProcedure, router} from "../trpc"; import {z} from "zod"; -import {definition, composeMutationHandler, composeQueryHandler } from "@devnode/composedb"; +import { + definition, + composeMutationHandler, + composeQueryHandler, + updateVoteComment, + downVoteComment +} from "@devnode/composedb"; import {ComposeClient} from "@composedb/client"; import {config} from "../../../config"; import {left, right} from "../../../utils/fp"; -import {has, omit, get} from "lodash"; +import {has, omit, get, isNil} from "lodash"; import {DIDSession} from "did-session"; import {SocialCommentId} from "../../types"; +import {upVoteComment, getUserVoteOnComment} from "@devnode/composedb"; export const compose = new ComposeClient({ ceramic: config.ceramic.nodeUrl, @@ -23,12 +30,24 @@ const createCommentSchema = z.object({ createdAt: z.string(), }); +const voteCommentSchema = z.object({ + session: z.string(), + commentId: z.string(), + userId: z.string(), +}); + const getHandler = async (didSession: string) => { const session = await DIDSession.fromSession(didSession); compose.setDID(session.did); return await composeMutationHandler(compose); } +const getCompose = async (didSession: string) => { + const session = await DIDSession.fromSession(didSession); + compose.setDID(session.did); + return compose; +} + export const commentRouter = router({ createComment: publicProcedure .input(createCommentSchema) @@ -68,6 +87,52 @@ export const commentRouter = router({ const {threadId, first, cursor} = input; return await queryHandler.fetchCommentsByThreadId(threadId, first, cursor); }), + + upVoteComment: publicProcedure + .input(voteCommentSchema) + .mutation(async ({input}) => { + try { + const {session, userId, commentId} = input; + const compose = await getCompose(session); + const existing = await getUserVoteOnComment(userId, commentId); + if (!isNil(existing)) { + const response = await updateVoteComment(compose, existing.node.id, true); + return (response.errors && response.errors.length > 0) + ? left(response.errors) + : right(response.data); + } else { + const response = await upVoteComment(compose, commentId, userId); + return (response.errors && response.errors.length > 0) + ? left(response.errors) + : right(response.data); + } + } catch (e) { + return left(e); + } + }), + + downVoteComment: publicProcedure + .input(voteCommentSchema) + .mutation(async ({input}) => { + try { + const {session, userId, commentId} = input; + const compose = await getCompose(session); + const existing = await getUserVoteOnComment(userId, commentId); + if (!isNil(existing)) { + const response = await updateVoteComment(compose, existing.node.id, false); + return (response.errors && response.errors.length > 0) + ? left(response.errors) + : right(response.data); + } else { + const response = await downVoteComment(compose, commentId, userId); + return (response.errors && response.errors.length > 0) + ? left(response.errors) + : right(response.data); + } + } catch (e) { + return left(e); + } + }), }); const updateComment = async (handler, streamId, social: SocialCommentId) => { From 3ad0a5086138ec52efc563399a9b5110d260baf3 Mon Sep 17 00:00:00 2001 From: ap-atul Date: Wed, 12 Apr 2023 17:00:34 +0530 Subject: [PATCH 3/6] updated comment component and added icons --- .../src/components/Comment/Comment.test.tsx | 37 ++++++++++++-- apps/web/src/components/Comment/Comment.tsx | 37 ++++++++++++-- apps/web/src/components/Icons/Icons.tsx | 49 ++++++++++++++++++- apps/web/src/utils/data.ts | 29 +++++++++++ apps/web/src/utils/index.ts | 1 + apps/web/src/utils/utils.test.ts | 30 ++++++++++++ 6 files changed, 175 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/utils/data.ts 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..2fb7e5a5 100644 --- a/apps/web/src/components/Comment/Comment.tsx +++ b/apps/web/src/components/Comment/Comment.tsx @@ -1,11 +1,35 @@ 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"; -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 +48,8 @@ export const Comment = ({comment}) => { alt={`${user?.platformUsername} avatar`} />
- - + +
{user?.platformUsername}
@@ -39,9 +63,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/utils/data.ts b/apps/web/src/utils/data.ts new file mode 100644 index 00000000..a182b98a --- /dev/null +++ b/apps/web/src/utils/data.ts @@ -0,0 +1,29 @@ +import {Edges, Vote, Node} from "@devnode/composedb"; +import {isEmpty, isNil} from "lodash"; + +export const getAbsVotes = (votes: Edges | undefined): number => { + if (isNil(votes) || isEmpty(votes.edges)) return 0; + const upVotesCount = getUpVotes(votes.edges); + const downVotesCount = getDownVotes(votes.edges); + return upVotesCount - downVotesCount; +} + +const getUpVotes = (votes: Node[]): number => { + let count = 0; + for (let i = 0, len = votes.length; i < len; i++) { + if (votes[i].node.vote) { + count++; + } + } + return count; +} + +const getDownVotes = (votes: Node[]): number => { + let count = 0; + for (let i = 0, len = votes.length; i < len; i++) { + if (!votes[i].node.vote) { + count++; + } + } + return count; +} diff --git a/apps/web/src/utils/index.ts b/apps/web/src/utils/index.ts index f80161b6..dfaca794 100644 --- a/apps/web/src/utils/index.ts +++ b/apps/web/src/utils/index.ts @@ -1,3 +1,4 @@ export * from "./text"; export * from "./number"; export * from "./discord"; +export * from "./data"; diff --git a/apps/web/src/utils/utils.test.ts b/apps/web/src/utils/utils.test.ts index 037c51a4..1bd98dda 100644 --- a/apps/web/src/utils/utils.test.ts +++ b/apps/web/src/utils/utils.test.ts @@ -83,3 +83,33 @@ describe("utils.number", () => { expect(utils.convertToNumber('NaN')).toEqual(0); }); }); + +describe("utils.data", () => { + it("should return 0 if data is undefined or null", () => { + expect(utils.getAbsVotes(undefined)).toEqual(0); + expect(utils.getAbsVotes(null)).toEqual(0); + expect(utils.getAbsVotes({edges: []})).toEqual(0); + }); + + it("should return positive for more upvotes and less downvotes", () => { + const data = { + edges: [ + {node: {vote: true}}, + {node: {vote: true}}, + {node: {vote: false}}, + ] + } + expect(utils.getAbsVotes(data as any)).toEqual(1); + }); + + it("should return negative for less upvotes and more downvotes", () => { + const data = { + edges: [ + {node: {vote: true}}, + {node: {vote: false}}, + {node: {vote: false}}, + ] + } + expect(utils.getAbsVotes(data as any)).toEqual(-1); + }); +}); From a6e58694f236c77f5535c07baa15c13e44e18c68 Mon Sep 17 00:00:00 2001 From: ap-atul Date: Wed, 12 Apr 2023 17:01:16 +0530 Subject: [PATCH 4/6] moved thread section under sections | minor refactors --- apps/web/src/pages/community.tsx | 2 +- apps/web/src/pages/feed.tsx | 6 +- .../ThreadSection/ThreadSection.test.tsx | 6 ++ .../ThreadSection/ThreadSection.tsx | 80 +++++++++++++++---- .../ThreadSection/index.tsx | 0 .../ThreadSection/types.ts | 0 apps/web/src/sections/index.tsx | 1 + 7 files changed, 76 insertions(+), 19 deletions(-) rename apps/web/src/{components => sections}/ThreadSection/ThreadSection.test.tsx (94%) rename apps/web/src/{components => sections}/ThreadSection/ThreadSection.tsx (62%) rename apps/web/src/{components => sections}/ThreadSection/index.tsx (100%) rename apps/web/src/{components => sections}/ThreadSection/types.ts (100%) 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 94% rename from apps/web/src/components/ThreadSection/ThreadSection.test.tsx rename to apps/web/src/sections/ThreadSection/ThreadSection.test.tsx index 5148000f..a17e62d0 100644 --- a/apps/web/src/components/ThreadSection/ThreadSection.test.tsx +++ b/apps/web/src/sections/ThreadSection/ThreadSection.test.tsx @@ -65,6 +65,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..48710d7d 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, }, @@ -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) => ( - + )) ))} {
-
+