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
+ />