From 386813813e1eb256ca4c333b383d66ed2f7771a6 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sat, 20 Jun 2026 14:22:51 +0000 Subject: [PATCH 1/9] 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/9] 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({ )}