From 386813813e1eb256ca4c333b383d66ed2f7771a6 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sat, 20 Jun 2026 14:22:51 +0000 Subject: [PATCH 1/2] fix: add likes field back to schemas and fix Map iteration for ES5 target --- apps/web/graphql/communities/helpers.ts | 187 ++++++++++++++---- apps/web/models/CommunityComment.ts | 90 ++------- apps/web/models/CommunityPost.ts | 2 + .../src/models/community-comment.ts | 9 +- .../orm-models/src/models/community-post.ts | 2 + 5 files changed, 176 insertions(+), 114 deletions(-) diff --git a/apps/web/graphql/communities/helpers.ts b/apps/web/graphql/communities/helpers.ts index 83e9d1623..47f8f981f 100644 --- a/apps/web/graphql/communities/helpers.ts +++ b/apps/web/graphql/communities/helpers.ts @@ -1,6 +1,7 @@ import { CommunityMedia, CommunityPost, + CommunityReaction, CommunityReport, CommunityReportType, Constants, @@ -25,6 +26,7 @@ import CommunityPostSubscriberModel, { } from "@models/CommunityPostSubscriber"; import { hasCommunityPermission } from "@ui-lib/utils"; import { normalizeTextEditorContent } from "@courselit/utils"; +import UserModel from "@models/User"; export type PublicPost = Omit< CommunityPost, @@ -33,54 +35,163 @@ export type PublicPost = Omit< userId: string; }; +const HEART_EMOJI = "❤️"; + +/** + * Get reactions from an entity that may have either `reactions` (Map) or legacy `likes` (string[]). + * Returns a Map. + */ +function getReactionsMap(entity: any): Map { + if (entity.reactions && typeof entity.reactions === "object") { + // New format: reactions is a Map or object + if (entity.reactions instanceof Map) { + if (entity.reactions.size > 0) { + return entity.reactions; + } + } else { + // Plain object from lean() / serialization + const entries = Object.entries(entity.reactions); + if (entries.length > 0) { + return new Map(entries); + } + } + } + // Legacy format: likes is a string[] + if (Array.isArray(entity.likes) && entity.likes.length > 0) { + return new Map([[HEART_EMOJI, [...entity.likes]]]); + } + return new Map(); +} + +/** + * Convert a Map to a CommunityReaction[] array with reactor details. + */ +export async function formatReactions( + reactionsMap: Map, + userId: string, +): Promise { + const reactions: CommunityReaction[] = []; + + const entries: [string, string[]][] = []; + reactionsMap.forEach((value, key) => { + entries.push([key, value]); + }); + + for (let i = 0; i < entries.length; i++) { + const [emoji, userIds] = entries[i]; + if (userIds.length === 0) continue; + + const reactors = await UserModel.find( + { userId: { $in: userIds } }, + { userId: 1, name: 1, avatar: 1, _id: 0 }, + ).lean(); + + reactions.push({ + emoji, + count: userIds.length, + hasReacted: userIds.includes(userId), + reactors: reactors.map((r: any) => ({ + userId: r.userId, + name: r.name, + avatar: r.avatar || {}, + })), + }); + } + + // Sort reactions: user's reactions first, then by count descending + reactions.sort((a, b) => { + if (a.hasReacted !== b.hasReacted) return a.hasReacted ? -1 : 1; + return b.count - a.count; + }); + + return reactions; +} + +/** + * Compute likesCount from reactions (sum of all reaction counts for backward compat). + */ +function computeLikesCount(reactionsMap: Map): number { + let count = 0; + reactionsMap.forEach(function (userIds: string[]) { + count += userIds.length; + }); + return count; +} + +/** + * Compute hasLiked from reactions (user is in any reaction). + */ +function computeHasLiked( + reactionsMap: Map, + userId: string, +): boolean { + let found = false; + reactionsMap.forEach(function (userIds: string[]) { + if (userIds.includes(userId)) found = true; + }); + return found; +} + export function normalizeCommunityPostContent( content: InternalCommunityPost["content"], ): TextEditorContent { return normalizeTextEditorContent(content); } -export const formatComment = (comment: any, userId: string) => ({ - communityId: comment.communityId, - postId: comment.postId, - userId: comment.userId, - commentId: comment.commentId, - content: comment.content, - hasLiked: comment.likes.includes(userId), - createdAt: comment.createdAt, - updatedAt: comment.updatedAt, - media: comment.media, - likesCount: comment.likes.length, - replies: comment.replies.map((reply) => ({ - replyId: reply.replyId, - userId: reply.userId, - content: reply.content, - media: reply.media, - parentReplyId: reply.parentReplyId, - createdAt: reply.createdAt, - updatedAt: reply.updatedAt, - likesCount: reply.likes.length, - hasLiked: reply.likes.includes(userId), - deleted: reply.deleted, - })), - deleted: comment.deleted, -}); +export const formatComment = (comment: any, userId: string) => { + const reactionsMap = getReactionsMap(comment); + return { + communityId: comment.communityId, + postId: comment.postId, + userId: comment.userId, + commentId: comment.commentId, + content: comment.content, + hasLiked: computeHasLiked(reactionsMap, userId), + createdAt: comment.createdAt, + updatedAt: comment.updatedAt, + media: comment.media, + likesCount: computeLikesCount(reactionsMap), + reactions: [], // Populated by resolver with user details + replies: comment.replies.map((reply: any) => { + const replyReactionsMap = getReactionsMap(reply); + return { + replyId: reply.replyId, + userId: reply.userId, + content: reply.content, + media: reply.media, + parentReplyId: reply.parentReplyId, + createdAt: reply.createdAt, + updatedAt: reply.updatedAt, + likesCount: computeLikesCount(replyReactionsMap), + hasLiked: computeHasLiked(replyReactionsMap, userId), + reactions: [], // Populated by resolver + deleted: reply.deleted, + }; + }), + deleted: comment.deleted, + }; +}; export const formatPost = ( post: InternalCommunityPost, userId: string, -): PublicPost => ({ - communityId: post.communityId, - postId: post.postId, - title: post.title, - content: normalizeCommunityPostContent(post.content), - category: post.category, - media: post.media, - pinned: post.pinned, - likesCount: post.likes.length, - updatedAt: post.updatedAt, - hasLiked: post.likes.includes(userId), - userId: post.userId, -}); +): PublicPost => { + const reactionsMap = getReactionsMap(post); + return { + communityId: post.communityId, + postId: post.postId, + title: post.title, + content: normalizeCommunityPostContent(post.content), + category: post.category, + media: post.media, + pinned: post.pinned, + likesCount: computeLikesCount(reactionsMap), + updatedAt: post.updatedAt, + hasLiked: computeHasLiked(reactionsMap, userId), + reactions: [], // Populated by resolver with user details + userId: post.userId, + }; +}; export async function toggleContentVisibility( contentId: string, diff --git a/apps/web/models/CommunityComment.ts b/apps/web/models/CommunityComment.ts index cd7d89d8c..3459839d9 100644 --- a/apps/web/models/CommunityComment.ts +++ b/apps/web/models/CommunityComment.ts @@ -1,78 +1,18 @@ -import { generateUniqueId } from "@courselit/utils"; -import mongoose from "mongoose"; -import CommunityMediaSchema from "./CommunityMedia"; import { - CommunityComment, - CommunityCommentReply, -} from "@courselit/common-models"; + InternalCommunityComment, + InternalReply, + CommunityCommentSchema, +} from "@courselit/orm-models"; +import mongoose, { Model } from "mongoose"; -export interface InternalCommunityComment - extends Pick< - CommunityComment, - "communityId" | "postId" | "commentId" | "content" | "media" - > { - domain: mongoose.Types.ObjectId; - userId: string; - likes: string[]; - replies: InternalReply[]; - deleted: boolean; -} +const CommunityCommentModel = + (mongoose.models.CommunityComment as + | Model + | undefined) || + mongoose.model( + "CommunityComment", + CommunityCommentSchema, + ); -export interface InternalReply - extends Omit { - userId: string; - likes: string[]; -} - -const ReplySchema = new mongoose.Schema( - { - userId: { type: String, required: true }, - content: { type: String, required: true }, - media: [CommunityMediaSchema], - replyId: { type: String, required: true, default: generateUniqueId }, - parentReplyId: { type: String, default: null }, - likes: [String], - deleted: { type: Boolean, default: false }, - }, - { - timestamps: true, - }, -); - -const CommunityCommentSchema = new mongoose.Schema( - { - domain: { type: mongoose.Schema.Types.ObjectId, required: true }, - userId: { type: String, required: true }, - communityId: { type: String, required: true }, - postId: { type: String, required: true }, - commentId: { - type: String, - required: true, - unique: true, - default: generateUniqueId, - }, - content: { type: String, required: true }, - media: [CommunityMediaSchema], - likes: [String], - replies: [ReplySchema], - deleted: { type: Boolean, required: true, default: false }, - }, - { - timestamps: true, - }, -); - -CommunityCommentSchema.statics.paginatedFind = async function ( - filter, - options, -) { - const page = options.page || 1; - const limit = options.limit || 10; - const skip = (page - 1) * limit; - - const docs = await this.find(filter).skip(skip).limit(limit).exec(); - return docs; -}; - -export default mongoose.models.CommunityComment || - mongoose.model("CommunityComment", CommunityCommentSchema); +export type { InternalCommunityComment, InternalReply }; +export default CommunityCommentModel; diff --git a/apps/web/models/CommunityPost.ts b/apps/web/models/CommunityPost.ts index 155b91232..396b14c40 100644 --- a/apps/web/models/CommunityPost.ts +++ b/apps/web/models/CommunityPost.ts @@ -22,6 +22,7 @@ export interface InternalCommunityPost domain: mongoose.Types.ObjectId; userId: string; likes: string[]; + reactions: Map; createdAt: string; updatedAt: string; } @@ -43,6 +44,7 @@ const CommunityPostSchema = new mongoose.Schema( media: [CommunityMediaSchema], pinned: { type: Boolean, default: false }, likes: [String], + reactions: { type: Map, of: [String], default: {} }, deleted: { type: Boolean, default: false }, }, { diff --git a/packages/orm-models/src/models/community-comment.ts b/packages/orm-models/src/models/community-comment.ts index f980fb0e3..13b8cce12 100644 --- a/packages/orm-models/src/models/community-comment.ts +++ b/packages/orm-models/src/models/community-comment.ts @@ -14,14 +14,19 @@ export interface InternalCommunityComment domain: mongoose.Types.ObjectId; userId: string; likes: string[]; + reactions: Map; replies: InternalReply[]; deleted: boolean; } export interface InternalReply - extends Omit { + extends Omit< + CommunityCommentReply, + "likesCount" | "hasLiked" | "reactions" + > { userId: string; likes: string[]; + reactions: Map; } export const ReplySchema = new mongoose.Schema( @@ -32,6 +37,7 @@ export const ReplySchema = new mongoose.Schema( replyId: { type: String, required: true, default: generateUniqueId }, parentReplyId: { type: String, default: null }, likes: [String], + reactions: { type: Map, of: [String], default: {} }, deleted: { type: Boolean, default: false }, }, { @@ -55,6 +61,7 @@ export const CommunityCommentSchema = content: { type: String, required: true }, media: [CommunityMediaSchema], likes: [String], + reactions: { type: Map, of: [String], default: {} }, replies: [ReplySchema], deleted: { type: Boolean, required: true, default: false }, }, diff --git a/packages/orm-models/src/models/community-post.ts b/packages/orm-models/src/models/community-post.ts index bedc6d607..99c6d04ea 100644 --- a/packages/orm-models/src/models/community-post.ts +++ b/packages/orm-models/src/models/community-post.ts @@ -22,6 +22,7 @@ export interface InternalCommunityPost userId: string; content: TextEditorContent | string; likes: string[]; + reactions: Map; createdAt: string; updatedAt: string; } @@ -43,6 +44,7 @@ export const CommunityPostSchema = new mongoose.Schema( media: [CommunityMediaSchema], pinned: { type: Boolean, default: false }, likes: [String], + reactions: { type: Map, of: [String], default: {} }, deleted: { type: Boolean, default: false }, }, { From 188ef592a006bb7cd243103619d83f1e69bcb7c1 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sat, 20 Jun 2026 14:36:29 +0000 Subject: [PATCH 2/2] feat: community post/comment/reply reactions frontend (#809) --- .../components/community/comment-section.tsx | 360 +++++------------- apps/web/components/community/comment.tsx | 34 +- .../web/components/community/emoji-picker.tsx | 49 +++ apps/web/components/community/index.tsx | 62 ++- apps/web/components/community/post-card.tsx | 20 +- .../components/community/reactions-bar.tsx | 135 +++++++ apps/web/graphql/communities/helpers.ts | 5 +- apps/web/graphql/communities/logic.ts | 327 +++++++++++++++- apps/web/graphql/communities/mutation.ts | 86 +++++ apps/web/graphql/communities/types.ts | 49 ++- .../src/community-comment-reply.ts | 2 + .../common-models/src/community-comment.ts | 2 + packages/common-models/src/community-post.ts | 2 + .../common-models/src/community-reaction.ts | 12 + packages/common-models/src/index.ts | 1 + 15 files changed, 823 insertions(+), 323 deletions(-) create mode 100644 apps/web/components/community/emoji-picker.tsx create mode 100644 apps/web/components/community/reactions-bar.tsx create mode 100644 packages/common-models/src/community-reaction.ts diff --git a/apps/web/components/community/comment-section.tsx b/apps/web/components/community/comment-section.tsx index 7ec14b2b0..60137896a 100644 --- a/apps/web/components/community/comment-section.tsx +++ b/apps/web/components/community/comment-section.tsx @@ -53,6 +53,75 @@ const focusCommentTarget = (targetId: string) => { window.dispatchEvent(new Event("community-comment-target-change")); }; +const REACTIONS_FRAGMENT = ` + reactions { + emoji + count + hasReacted + reactors { + userId + name + avatar { + mediaId + file + thumbnail + } + } + } +`; + +const REPLY_FIELDS = ` + replyId + content + user { + userId + name + avatar { + mediaId + file + thumbnail + } + } + updatedAt + likesCount + hasLiked + ${REACTIONS_FRAGMENT} + deleted +`; + +const COMMENT_FIELDS = ` + communityId + postId + commentId + content + user { + userId + name + avatar { + mediaId + file + thumbnail + } + } + media { + type + media { + mediaId + file + thumbnail + size + } + } + likesCount + ${REACTIONS_FRAGMENT} + replies { + ${REPLY_FIELDS} + } + hasLiked + updatedAt + deleted +`; + export default function CommentSection({ communityId, postId, @@ -149,49 +218,7 @@ export default function CommentSection({ const query = ` query ($communityId: String!, $postId: String!) { comments: getComments(communityId: $communityId, postId: $postId) { - communityId - postId - commentId - content - user { - userId - name - avatar { - mediaId - file - thumbnail - } - } - media { - type - media { - mediaId - file - thumbnail - size - } - } - likesCount - replies { - replyId - content - user { - userId - name - avatar { - mediaId - file - thumbnail - } - } - updatedAt - likesCount - hasLiked - deleted - } - hasLiked - updatedAt - deleted + ${COMMENT_FIELDS} } } `; @@ -226,49 +253,7 @@ export default function CommentSection({ const query = ` mutation ($communityId: String!, $postId: String!, $content: String!) { comment: postComment(communityId: $communityId, postId: $postId, content: $content) { - communityId - postId - commentId - content - user { - userId - name - avatar { - mediaId - file - thumbnail - } - } - media { - type - media { - mediaId - file - thumbnail - size - } - } - likesCount - replies { - replyId - content - user { - userId - name - avatar { - mediaId - file - thumbnail - } - } - updatedAt - likesCount - hasLiked - deleted - } - hasLiked - updatedAt - deleted + ${COMMENT_FIELDS} } } `; @@ -323,49 +308,7 @@ export default function CommentSection({ const query = ` mutation ($communityId: String!, $postId: String!, $commentId: String!, $content: String!, $parentReplyId: String, $media: [CommunityPostInputMedia]) { comment: postComment(communityId: $communityId, postId: $postId, parentCommentId: $commentId, content: $content, parentReplyId: $parentReplyId, media: $media) { - communityId - postId - commentId - content - user { - userId - name - avatar { - mediaId - file - thumbnail - } - } - media { - type - media { - mediaId - file - thumbnail - size - } - } - likesCount - replies { - replyId - content - user { - userId - name - avatar { - mediaId - file - thumbnail - } - } - updatedAt - likesCount - hasLiked - deleted - } - hasLiked - updatedAt - deleted + ${COMMENT_FIELDS} } } `; @@ -411,53 +354,11 @@ export default function CommentSection({ } }; - const handleCommentLike = async (commentId: string) => { + const handleCommentReact = async (commentId: string, emoji: string) => { const query = ` - mutation ($communityId: String!, $postId: String!, $commentId: String!) { - comment: toggleCommentLike(communityId: $communityId, postId: $postId, commentId: $commentId) { - communityId - postId - commentId - content - user { - userId - name - avatar { - mediaId - file - thumbnail - } - } - media { - type - media { - mediaId - file - thumbnail - size - } - } - likesCount - replies { - replyId - content - user { - userId - name - avatar { - mediaId - file - thumbnail - } - } - updatedAt - likesCount - hasLiked - deleted - } - hasLiked - updatedAt - deleted + mutation ($communityId: String!, $postId: String!, $commentId: String!, $emoji: String!) { + comment: toggleCommentReaction(communityId: $communityId, postId: $postId, commentId: $commentId, emoji: $emoji) { + ${COMMENT_FIELDS} } } `; @@ -469,6 +370,7 @@ export default function CommentSection({ communityId, postId, commentId, + emoji, }, }) .setIsGraphQLEndpoint(true) @@ -487,52 +389,15 @@ export default function CommentSection({ } }; - const handleReplyLike = async (commentId: string, replyId: string) => { + const handleReplyReact = async ( + commentId: string, + emoji: string, + replyId: string, + ) => { const query = ` - mutation ($communityId: String!, $postId: String!, $commentId: String!, $replyId: String!) { - comment: toggleCommentReplyLike(communityId: $communityId, postId: $postId, commentId: $commentId, replyId: $replyId) { - communityId - postId - commentId - content - user { - userId - name - avatar { - mediaId - file - thumbnail - } - } - media { - type - media { - mediaId - file - thumbnail - } - } - likesCount - replies { - replyId - content - user { - userId - name - avatar { - mediaId - file - thumbnail - } - } - updatedAt - likesCount - hasLiked - deleted - } - hasLiked - updatedAt - deleted + mutation ($communityId: String!, $postId: String!, $commentId: String!, $replyId: String!, $emoji: String!) { + comment: toggleCommentReplyReaction(communityId: $communityId, postId: $postId, commentId: $commentId, replyId: $replyId, emoji: $emoji) { + ${COMMENT_FIELDS} } } `; @@ -545,6 +410,7 @@ export default function CommentSection({ postId, commentId, replyId, + emoji, }, }) .setIsGraphQLEndpoint(true) @@ -569,49 +435,7 @@ export default function CommentSection({ const query = ` mutation ($communityId: String!, $postId: String!, $commentId: String!, $replyId: String) { comment: deleteComment(communityId: $communityId, postId: $postId, commentId: $commentId, replyId: $replyId) { - communityId - postId - commentId - content - user { - userId - name - avatar { - mediaId - file - thumbnail - } - } - media { - type - media { - mediaId - file - thumbnail - size - } - } - likesCount - replies { - replyId - content - user { - userId - name - avatar { - mediaId - file - thumbnail - } - } - updatedAt - likesCount - hasLiked - deleted - } - hasLiked - updatedAt - deleted + ${COMMENT_FIELDS} } } `; @@ -668,11 +492,15 @@ export default function CommentSection({ key={comment.commentId} membership={membership} comment={comment} - onLike={(commentId: string, replyId?: string) => { + onReact={( + commentId: string, + emoji: string, + replyId?: string, + ) => { if (replyId) { - handleReplyLike(commentId, replyId); + handleReplyReact(commentId, emoji, replyId); } else { - handleCommentLike(commentId); + handleCommentReact(commentId, emoji); } }} onReply={(commentId, content, parentReplyId?: string) => diff --git a/apps/web/components/community/comment.tsx b/apps/web/components/community/comment.tsx index a936c4b17..ec44ac72b 100644 --- a/apps/web/components/community/comment.tsx +++ b/apps/web/components/community/comment.tsx @@ -3,7 +3,6 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { - ThumbsUp, MessageSquare, MoreVertical, FlagTriangleRight, @@ -34,6 +33,7 @@ import { isCommunityComment } from "./utils"; import { DELETED_COMMENT_PLACEHOLDER } from "@ui-config/strings"; import { useToast } from "@courselit/components-library"; import { FetchBuilder } from "@courselit/utils"; +import { ReactionsBar } from "./reactions-bar"; type CommentOrReply = | CommunityComment @@ -42,7 +42,7 @@ type CommentOrReply = interface CommentProps { communityId: string; comment: CommentOrReply; - onLike: (commentId: string, replyId?: string) => void; + onReact: (commentId: string, emoji: string, replyId?: string) => void; onReply: ( commentId: string, content: string, @@ -57,7 +57,7 @@ interface CommentProps { export function Comment({ communityId, comment, - onLike, + onReact, onReply, onDelete, membership, @@ -255,15 +255,21 @@ export function Comment({ )}

- + { + if (isCommunityComment(comment)) { + onReact(comment.commentId, emoji); + } else { + onReact( + comment.commentId, + emoji, + comment.replyId, + ); + } + }} + compact + /> + )} + + +
+ {COMMON_EMOJIS.map((emoji) => ( + + ))} +
+
+ + ); +} diff --git a/apps/web/components/community/index.tsx b/apps/web/components/community/index.tsx index 84bedbef3..58bc890b9 100644 --- a/apps/web/components/community/index.tsx +++ b/apps/web/components/community/index.tsx @@ -160,6 +160,20 @@ export function CommunityForum({ } } likesCount + reactions { + emoji + count + hasReacted + reactors { + userId + name + avatar { + mediaId + file + thumbnail + } + } + } commentsCount updatedAt hasLiked @@ -256,7 +270,11 @@ export function CommunityForum({ ); }; - const handleLike = async (postId: string, e?: React.MouseEvent) => { + const handleReact = async ( + postId: string, + emoji: string, + e?: React.MouseEvent, + ) => { e?.stopPropagation(); setPosts((prevPosts) => @@ -264,19 +282,30 @@ export function CommunityForum({ post.postId === postId ? { ...post, - likesCount: post.hasLiked - ? post.likesCount - 1 - : post.likesCount + 1, - hasLiked: !post.hasLiked, + hasLiked: true, } : post, ), ); const query = ` - mutation ($communityId: String!, $postId: String!) { - togglePostLike(communityId: $communityId, postId: $postId) { + mutation ($communityId: String!, $postId: String!, $emoji: String!) { + togglePostReaction(communityId: $communityId, postId: $postId, emoji: $emoji) { postId + reactions { + emoji + count + hasReacted + reactors { + userId + name + avatar { + mediaId + file + thumbnail + } + } + } } } `; @@ -285,11 +314,24 @@ export function CommunityForum({ .setUrl(`${address.backend}/api/graph`) .setPayload({ query, - variables: { postId, communityId: id }, + variables: { postId, communityId: id, emoji }, }) .setIsGraphQLEndpoint(true) .build(); - await fetch.exec(); + const response = await fetch.exec(); + if (response.togglePostReaction) { + setPosts((prevPosts) => + prevPosts.map((post) => + post.postId === postId + ? { + ...post, + reactions: + response.togglePostReaction.reactions, + } + : post, + ), + ); + } } catch (err) { console.error(err.message); toast({ @@ -955,7 +997,7 @@ export function CommunityForum({ ) } onTogglePin={togglePin} - onLike={handleLike} + onReact={handleReact} /> )) ) : ( diff --git a/apps/web/components/community/post-card.tsx b/apps/web/components/community/post-card.tsx index 8cceb3f1e..efa5a5825 100644 --- a/apps/web/components/community/post-card.tsx +++ b/apps/web/components/community/post-card.tsx @@ -15,10 +15,11 @@ import { } from "@courselit/page-blocks"; import { CommunityMedia, CommunityPost } from "@courselit/common-models"; import { capitalize, truncate } from "@courselit/utils"; -import { MessageSquare, Pin, ThumbsUp } from "lucide-react"; +import { MessageSquare, Pin } from "lucide-react"; import Link from "next/link"; import { useContext } from "react"; import { ThemeContext } from "@components/contexts"; +import { ReactionsBar } from "./reactions-bar"; interface CommunityPostCardProps { post: CommunityPost; @@ -29,7 +30,7 @@ interface CommunityPostCardProps { renderMediaPreview: (media: CommunityMedia) => React.ReactNode; onOpen: (postId: string) => void; onTogglePin?: (postId: string, e?: React.MouseEvent) => void; - onLike?: (postId: string, e?: React.MouseEvent) => void; + onReact?: (postId: string, emoji: string, e?: React.MouseEvent) => void; } export default function CommunityPostCard({ @@ -41,7 +42,7 @@ export default function CommunityPostCard({ renderMediaPreview, onOpen, onTogglePin, - onLike, + onReact, }: CommunityPostCardProps) { const { theme } = useContext(ThemeContext); @@ -138,15 +139,10 @@ export default function CommunityPostCard({
- + onReact?.(post.postId, emoji)} + /> + ))} + { + onReact(emoji); + }} + > + + + {hoveredReaction && hoveredEmoji && tooltipPos && ( +
{ + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }} + onMouseLeave={handleMouseLeave} + > +
{hoveredEmoji}
+
+ {hoveredReaction.reactors.length > 0 + ? hoveredReaction.reactors + .map((r) => r.name || r.userId) + .join(", ") + : "..."} +
+
+ )} +
+ ); +} diff --git a/apps/web/graphql/communities/helpers.ts b/apps/web/graphql/communities/helpers.ts index 47f8f981f..1dd77a917 100644 --- a/apps/web/graphql/communities/helpers.ts +++ b/apps/web/graphql/communities/helpers.ts @@ -50,7 +50,10 @@ function getReactionsMap(entity: any): Map { } } else { // Plain object from lean() / serialization - const entries = Object.entries(entity.reactions); + const entries = Object.entries(entity.reactions) as [ + string, + string[], + ][]; if (entries.length > 0) { return new Map(entries); } diff --git a/apps/web/graphql/communities/logic.ts b/apps/web/graphql/communities/logic.ts index 0cab8c071..1cd0abeea 100644 --- a/apps/web/graphql/communities/logic.ts +++ b/apps/web/graphql/communities/logic.ts @@ -1505,6 +1505,88 @@ export async function updateMemberRole({ return targetMember; } +/** + * Get reactions for an entity (post, comment, or reply) with full reactor details. + */ +export async function getReactionsForEntity({ + entityType, + entity, + ctx, +}: { + entityType: "post" | "comment" | "reply"; + entity: any; + ctx: GQLContext; +}): Promise { + const { formatReactions } = await import("./helpers"); + let reactionsMap: Map; + + if (entityType === "reply") { + // For replies, the reactions are directly on the reply sub-document + reactionsMap = getReactionsMapFromEntity(entity); + } else if (entityType === "comment") { + reactionsMap = getReactionsMapFromEntity(entity); + } else { + reactionsMap = getReactionsMapFromEntity(entity); + } + + return formatReactions(reactionsMap, ctx.user?.userId || ""); +} + +function getReactionsMapFromEntity(entity: any): Map { + if (entity.reactions && typeof entity.reactions === "object") { + if (entity.reactions instanceof Map) { + return entity.reactions; + } + return new Map(Object.entries(entity.reactions)); + } + // Legacy: entity has likes array + if (Array.isArray(entity.likes)) { + return new Map([["❤️", [...entity.likes]]]); + } + return new Map(); +} + +function toggleReactionInMap( + reactionsMap: Map, + emoji: string, + userId: string, +): { added: boolean } { + const existing = reactionsMap.get(emoji) || []; + + if (existing.includes(userId)) { + // Remove user from this reaction + const filtered = existing.filter((id) => id !== userId); + if (filtered.length === 0) { + reactionsMap.delete(emoji); + } else { + reactionsMap.set(emoji, filtered); + } + return { added: false }; + } else { + // User might be in another reaction - remove from other reactions first + const keysToDelete: string[] = []; + const keysToUpdate: Map = new Map(); + reactionsMap.forEach(function (userIds: string[], key: string) { + if (key !== emoji && userIds.includes(userId)) { + const filtered = userIds.filter((id) => id !== userId); + if (filtered.length === 0) { + keysToDelete.push(key); + } else { + keysToUpdate.set(key, filtered); + } + } + }); + keysToDelete.forEach((key) => reactionsMap.delete(key)); + keysToUpdate.forEach(function (value, key) { + reactionsMap.set(key, value); + }); + // Add to this reaction + existing.push(userId); + reactionsMap.set(emoji, existing); + return { added: true }; + } +} + export async function togglePostLike({ ctx, communityId, @@ -1541,13 +1623,14 @@ export async function togglePostLike({ throw new Error(responses.action_not_allowed); } - let liked = false; - if (post.likes.includes(ctx.user.userId)) { - post.likes = post.likes.filter((id) => id !== ctx.user.userId); - } else { - post.likes.push(ctx.user.userId); - liked = true; - } + const reactionsMap = getReactionsMapFromEntity(post); + const { added: liked } = toggleReactionInMap( + reactionsMap, + "❤️", + ctx.user.userId, + ); + // Convert Map back to plain object for MongoDB + post.reactions = Object.fromEntries(reactionsMap); await post.save(); @@ -1567,6 +1650,70 @@ export async function togglePostLike({ return formatPost(post, ctx.user.userId); } +export async function togglePostReaction({ + ctx, + communityId, + postId, + emoji, +}: { + ctx: GQLContext; + communityId: string; + postId: string; + emoji: string; +}): Promise { + checkIfAuthenticated(ctx); + + const community = await CommunityModel.findOne( + getCommunityQuery(ctx, communityId), + ); + + if (!community) { + throw new Error(responses.item_not_found); + } + + const post = await CommunityPostModel.findOne({ + domain: ctx.subdomain._id, + communityId, + postId, + deleted: false, + }); + + if (!post) { + throw new Error(responses.item_not_found); + } + + const member = await getMembership(ctx, communityId); + + if (!member) { + throw new Error(responses.action_not_allowed); + } + + const reactionsMap = getReactionsMapFromEntity(post); + const { added: reacted } = toggleReactionInMap( + reactionsMap, + emoji, + ctx.user.userId, + ); + post.reactions = Object.fromEntries(reactionsMap); + + await post.save(); + + if (reacted && post.userId !== ctx.user.userId) { + await recordActivity({ + domain: ctx.subdomain._id, + userId: ctx.user.userId, + type: Constants.ActivityType.COMMUNITY_POST_LIKED, + entityId: post.postId, + metadata: { + communityId: community.communityId, + forUserIds: [post.userId], + }, + }); + } + + return formatPost(post, ctx.user.userId); +} + export async function togglePinned({ ctx, communityId, @@ -1829,12 +1976,10 @@ export async function toggleCommentLike({ } let liked = false; - if (comment.likes.includes(ctx.user.userId)) { - comment.likes = comment.likes.filter((id) => id !== ctx.user.userId); - } else { - comment.likes.push(ctx.user.userId); - liked = true; - } + const reactionsMap = getReactionsMapFromEntity(comment); + const result = toggleReactionInMap(reactionsMap, "❤️", ctx.user.userId); + liked = result.added; + comment.reactions = Object.fromEntries(reactionsMap); await comment.save(); @@ -1855,6 +2000,74 @@ export async function toggleCommentLike({ return formatComment(comment, ctx.user.userId); } +export async function toggleCommentReaction({ + ctx, + communityId, + postId, + commentId, + emoji, +}: { + ctx: GQLContext; + communityId: string; + postId: string; + commentId: string; + emoji: string; +}): Promise { + checkIfAuthenticated(ctx); + + const community = await CommunityModel.findOne( + getCommunityQuery(ctx, communityId), + ); + + if (!community) { + throw new Error(responses.item_not_found); + } + + const comment = await CommunityCommentModel.findOne({ + domain: ctx.subdomain._id, + communityId, + postId, + commentId, + deleted: false, + }); + + if (!comment) { + throw new Error(responses.item_not_found); + } + + const member = await getMembership(ctx, communityId); + + if (!member || !hasPermission(member, Constants.MembershipRole.COMMENT)) { + throw new Error(responses.action_not_allowed); + } + + const reactionsMap = getReactionsMapFromEntity(comment); + const { added: reacted } = toggleReactionInMap( + reactionsMap, + emoji, + ctx.user.userId, + ); + comment.reactions = Object.fromEntries(reactionsMap); + + await comment.save(); + + if (reacted && comment.userId !== ctx.user.userId) { + await recordActivity({ + domain: ctx.subdomain._id, + userId: ctx.user.userId, + type: Constants.ActivityType.COMMUNITY_COMMENT_LIKED, + entityId: comment.commentId, + metadata: { + communityId: community.communityId, + postId, + forUserIds: [comment.userId], + }, + }); + } + + return formatComment(comment, ctx.user.userId); +} + export async function toggleCommentReplyLike({ ctx, communityId, @@ -1903,12 +2116,10 @@ export async function toggleCommentReplyLike({ } let liked = false; - if (reply.likes.includes(ctx.user.userId)) { - reply.likes = reply.likes.filter((id) => id !== ctx.user.userId); - } else { - reply.likes.push(ctx.user.userId); - liked = true; - } + const reactionsMap = getReactionsMapFromEntity(reply); + const result = toggleReactionInMap(reactionsMap, "❤️", ctx.user.userId); + liked = result.added; + reply.reactions = Object.fromEntries(reactionsMap); await comment.save(); @@ -1931,6 +2142,84 @@ export async function toggleCommentReplyLike({ return formatComment(comment, ctx.user.userId); } +export async function toggleCommentReplyReaction({ + ctx, + communityId, + postId, + commentId, + replyId, + emoji, +}: { + ctx: GQLContext; + communityId: string; + postId: string; + commentId: string; + replyId: string; + emoji: string; +}): Promise { + checkIfAuthenticated(ctx); + + const community = await CommunityModel.findOne( + getCommunityQuery(ctx, communityId), + ); + + if (!community) { + throw new Error(responses.item_not_found); + } + + const comment = await CommunityCommentModel.findOne({ + domain: ctx.subdomain._id, + communityId, + postId, + commentId, + deleted: false, + }); + + if (!comment) { + throw new Error(responses.item_not_found); + } + + const member = await getMembership(ctx, communityId); + + if (!member || !hasPermission(member, Constants.MembershipRole.COMMENT)) { + throw new Error(responses.action_not_allowed); + } + + const reply = comment.replies.find((r) => r.replyId === replyId); + + if (!reply) { + throw new Error(responses.item_not_found); + } + + const reactionsMap = getReactionsMapFromEntity(reply); + const { added: reacted } = toggleReactionInMap( + reactionsMap, + emoji, + ctx.user.userId, + ); + reply.reactions = Object.fromEntries(reactionsMap); + + await comment.save(); + + if (reacted && reply.userId !== ctx.user.userId) { + await recordActivity({ + domain: ctx.subdomain._id, + userId: ctx.user.userId, + type: Constants.ActivityType.COMMUNITY_REPLY_LIKED, + entityId: reply.replyId, + metadata: { + communityId: community.communityId, + postId, + commentId: comment.commentId, + entityTargetId: comment.commentId, + forUserIds: [reply.userId], + }, + }); + } + + return formatComment(comment, ctx.user.userId); +} + export async function deleteComment({ ctx, communityId, diff --git a/apps/web/graphql/communities/mutation.ts b/apps/web/graphql/communities/mutation.ts index 3cc8ad981..10ae422ab 100644 --- a/apps/web/graphql/communities/mutation.ts +++ b/apps/web/graphql/communities/mutation.ts @@ -16,10 +16,13 @@ import { joinCommunity, updateMemberStatus, togglePostLike, + togglePostReaction, togglePinned, postComment, toggleCommentLike, + toggleCommentReaction, toggleCommentReplyLike, + toggleCommentReplyReaction, deleteComment, leaveCommunity, deleteCommunity, @@ -489,6 +492,89 @@ const mutations = { ctx, }), }, + togglePostReaction: { + type: types.communityPost, + args: { + communityId: { type: new GraphQLNonNull(GraphQLString) }, + postId: { type: new GraphQLNonNull(GraphQLString) }, + emoji: { type: new GraphQLNonNull(GraphQLString) }, + }, + resolve: async ( + _: any, + { + communityId, + postId, + emoji, + }: { communityId: string; postId: string; emoji: string }, + ctx: GQLContext, + ) => togglePostReaction({ communityId, postId, emoji, ctx }), + }, + toggleCommentReaction: { + type: types.communityComment, + args: { + communityId: { type: new GraphQLNonNull(GraphQLString) }, + postId: { type: new GraphQLNonNull(GraphQLString) }, + commentId: { type: new GraphQLNonNull(GraphQLString) }, + emoji: { type: new GraphQLNonNull(GraphQLString) }, + }, + resolve: async ( + _: any, + { + communityId, + postId, + commentId, + emoji, + }: { + communityId: string; + postId: string; + commentId: string; + emoji: string; + }, + ctx: GQLContext, + ) => + toggleCommentReaction({ + communityId, + postId, + commentId, + emoji, + ctx, + }), + }, + toggleCommentReplyReaction: { + type: types.communityComment, + args: { + communityId: { type: new GraphQLNonNull(GraphQLString) }, + postId: { type: new GraphQLNonNull(GraphQLString) }, + commentId: { type: new GraphQLNonNull(GraphQLString) }, + replyId: { type: new GraphQLNonNull(GraphQLString) }, + emoji: { type: new GraphQLNonNull(GraphQLString) }, + }, + resolve: async ( + _: any, + { + communityId, + postId, + commentId, + replyId, + emoji, + }: { + communityId: string; + postId: string; + commentId: string; + replyId: string; + emoji: string; + }, + ctx: GQLContext, + ) => + toggleCommentReplyReaction({ + communityId, + postId, + commentId, + replyId, + emoji, + ctx, + }), + }, }; export default mutations; diff --git a/apps/web/graphql/communities/types.ts b/apps/web/graphql/communities/types.ts index 149e144b0..2174233c9 100644 --- a/apps/web/graphql/communities/types.ts +++ b/apps/web/graphql/communities/types.ts @@ -10,13 +10,13 @@ import { } from "graphql"; import { GraphQLJSONObject } from "graphql-type-json"; import mediaTypes from "../media/types"; -import { Constants } from "@courselit/common-models"; +import { Constants, CommunityPost } from "@courselit/common-models"; import userTypes from "../users/types"; import { getUser } from "../users/logic"; import GQLContext from "@models/GQLContext"; import paymentPlansTypes from "../paymentplans/types"; import { getPlans } from "../paymentplans/logic"; -import { getCommentsCount } from "./logic"; +import { getCommentsCount, getReactionsForEntity } from "./logic"; const communityReportContentType = new GraphQLEnumType({ name: "CommunityReportContentType", @@ -94,6 +94,23 @@ const feedCommunity = new GraphQLObjectType({ }, }); +const communityReaction = new GraphQLObjectType({ + name: "CommunityReaction", + fields: { + emoji: { type: new GraphQLNonNull(GraphQLString) }, + count: { type: new GraphQLNonNull(GraphQLInt) }, + hasReacted: { type: new GraphQLNonNull(GraphQLBoolean) }, + reactors: { + type: new GraphQLList(userTypes.userType), + resolve: (reaction, _, ctx: GQLContext, __) => { + return reaction.reactors.map((reactor: any) => + getUser(reactor.userId, ctx), + ); + }, + }, + }, +}); + const communityPost = new GraphQLObjectType({ name: "CommunityPost", fields: { @@ -117,6 +134,15 @@ const communityPost = new GraphQLObjectType({ }, updatedAt: { type: new GraphQLNonNull(GraphQLString) }, hasLiked: { type: new GraphQLNonNull(GraphQLBoolean) }, + reactions: { + type: new GraphQLList(communityReaction), + resolve: async (post: CommunityPost, _, ctx: GQLContext, __) => + getReactionsForEntity({ + entityType: "post", + entity: post, + ctx, + }), + }, community: { type: feedCommunity }, }, }); @@ -153,6 +179,15 @@ const communityCommentReply = new GraphQLObjectType({ likesCount: { type: new GraphQLNonNull(GraphQLInt) }, updatedAt: { type: new GraphQLNonNull(GraphQLString) }, hasLiked: { type: new GraphQLNonNull(GraphQLBoolean) }, + reactions: { + type: new GraphQLList(communityReaction), + resolve: async (reply, _, ctx: GQLContext, __) => + getReactionsForEntity({ + entityType: "reply", + entity: reply, + ctx, + }), + }, deleted: { type: new GraphQLNonNull(GraphQLBoolean) }, }, }); @@ -173,6 +208,15 @@ const communityComment = new GraphQLObjectType({ likesCount: { type: new GraphQLNonNull(GraphQLInt) }, updatedAt: { type: new GraphQLNonNull(GraphQLString) }, hasLiked: { type: new GraphQLNonNull(GraphQLBoolean) }, + reactions: { + type: new GraphQLList(communityReaction), + resolve: async (comment, _, ctx: GQLContext, __) => + getReactionsForEntity({ + entityType: "comment", + entity: comment, + ctx, + }), + }, replies: { type: new GraphQLList(communityCommentReply) }, deleted: { type: new GraphQLNonNull(GraphQLBoolean) }, }, @@ -215,6 +259,7 @@ const types = { communityMemberStatus, communityPostInputMedia, communityComment, + communityReaction, communityReportContentType, communityReport, communityReportStatusType, diff --git a/packages/common-models/src/community-comment-reply.ts b/packages/common-models/src/community-comment-reply.ts index 29ff17271..762c70873 100644 --- a/packages/common-models/src/community-comment-reply.ts +++ b/packages/common-models/src/community-comment-reply.ts @@ -1,3 +1,4 @@ +import { CommunityReaction } from "./community-reaction"; import User from "./user"; export interface CommunityCommentReply { @@ -14,5 +15,6 @@ export interface CommunityCommentReply { updatedAt: string; likesCount: number; hasLiked: boolean; + reactions: CommunityReaction[]; deleted: boolean; } diff --git a/packages/common-models/src/community-comment.ts b/packages/common-models/src/community-comment.ts index 381d04045..9bacf6951 100644 --- a/packages/common-models/src/community-comment.ts +++ b/packages/common-models/src/community-comment.ts @@ -1,5 +1,6 @@ import { CommunityCommentReply } from "./community-comment-reply"; import { CommunityMedia } from "./community-media"; +import { CommunityReaction } from "./community-reaction"; import User from "./user"; export interface CommunityComment { @@ -13,6 +14,7 @@ export interface CommunityComment { updatedAt: string; createdAt: string; hasLiked: boolean; + reactions: CommunityReaction[]; replies: CommunityCommentReply[]; deleted: boolean; } diff --git a/packages/common-models/src/community-post.ts b/packages/common-models/src/community-post.ts index f1a3a74af..f590655f5 100644 --- a/packages/common-models/src/community-post.ts +++ b/packages/common-models/src/community-post.ts @@ -1,5 +1,6 @@ import { CommunityMedia } from "./community-media"; import { TextEditorContent } from "./text-editor-content"; +import { CommunityReaction } from "./community-reaction"; import User from "./user"; export interface CommunityPost { @@ -16,5 +17,6 @@ export interface CommunityPost { updatedAt: string; createdAt: string; hasLiked: boolean; + reactions: CommunityReaction[]; deleted: boolean; } diff --git a/packages/common-models/src/community-reaction.ts b/packages/common-models/src/community-reaction.ts new file mode 100644 index 000000000..9efc03a26 --- /dev/null +++ b/packages/common-models/src/community-reaction.ts @@ -0,0 +1,12 @@ +import { Media } from "./media"; + +export interface CommunityReaction { + emoji: string; + count: number; + hasReacted: boolean; + reactors: { + userId: string; + name?: string; + avatar: Media; + }[]; +} diff --git a/packages/common-models/src/index.ts b/packages/common-models/src/index.ts index dfa46d776..fe58c0d62 100644 --- a/packages/common-models/src/index.ts +++ b/packages/common-models/src/index.ts @@ -73,3 +73,4 @@ export * from "./email-event-action"; export * from "./login-provider"; export * from "./features"; export type { ScormContent } from "./scorm-content"; +export type { CommunityReaction } from "./community-reaction";