diff --git a/apps/web/.migrations/20-06-26_00-00-convert-likes-to-reactions.js b/apps/web/.migrations/20-06-26_00-00-convert-likes-to-reactions.js new file mode 100644 index 000000000..6a4b2e816 --- /dev/null +++ b/apps/web/.migrations/20-06-26_00-00-convert-likes-to-reactions.js @@ -0,0 +1,201 @@ +/** + * Converts existing `likes: string[]` fields to `reactions: { "❤️": string[] }` format + * on CommunityPost and CommunityComment documents. + * + * This migration is idempotent - safe to re-run. + * + * Usage: + * DB_CONNECTION_STRING= node 20-06-26_00-00-convert-likes-to-reactions.js + */ +import mongoose from "mongoose"; + +const DB_CONNECTION_STRING = process.env.DB_CONNECTION_STRING; + +if (!DB_CONNECTION_STRING) { + throw new Error("DB_CONNECTION_STRING is not set"); +} + +(async () => { + try { + await mongoose.connect(DB_CONNECTION_STRING); + + const db = mongoose.connection.db; + if (!db) { + throw new Error("Could not connect to database"); + } + + const BATCH_SIZE = 500; + const HEART_EMOJI = "❤️"; + + // --- Migrate CommunityPost documents --- + console.log("Migrating CommunityPost documents..."); + let postCursor = db + .collection("communityposts") + .find({ + $or: [ + { + likes: { $exists: true }, + $expr: { $gt: [{ $size: "$likes" }, 0] }, + }, + ], + }) + .batchSize(BATCH_SIZE); + + let postBatch = []; + let postCount = 0; + + while (await postCursor.hasNext()) { + const doc = await postCursor.next(); + if (!doc) continue; + + const update = { + $unset: { likes: "" }, + $set: { + reactions: { [HEART_EMOJI]: doc.likes || [] }, + }, + }; + + postBatch.push({ + updateOne: { + filter: { _id: doc._id }, + update, + }, + }); + + if (postBatch.length >= BATCH_SIZE) { + const result = await db + .collection("communityposts") + .bulkWrite(postBatch); + postCount += result.modifiedCount; + console.log( + ` Processed ${postCount} CommunityPost documents...`, + ); + postBatch = []; + } + } + + if (postBatch.length > 0) { + const result = await db + .collection("communityposts") + .bulkWrite(postBatch); + postCount += result.modifiedCount; + } + + console.log(`✅ Migrated ${postCount} CommunityPost documents.`); + + // --- Migrate CommunityComment documents --- + console.log("Migrating CommunityComment documents..."); + let commentCursor = db + .collection("communitycomments") + .find({ + $or: [ + { + likes: { $exists: true }, + $expr: { $gt: [{ $size: "$likes" }, 0] }, + }, + ], + }) + .batchSize(BATCH_SIZE); + + let commentBatch = []; + let commentCount = 0; + + while (await commentCursor.hasNext()) { + const doc = await commentCursor.next(); + if (!doc) continue; + + const update = { + $unset: { likes: "" }, + $set: { + reactions: { [HEART_EMOJI]: doc.likes || [] }, + }, + }; + + // Also migrate nested reply likes + if (doc.replies && Array.isArray(doc.replies)) { + const hasReplyLikes = doc.replies.some( + (reply) => + reply.likes && + Array.isArray(reply.likes) && + reply.likes.length > 0, + ); + + if (hasReplyLikes) { + update.$set["replies"] = doc.replies.map((reply) => { + if ( + reply.likes && + Array.isArray(reply.likes) && + reply.likes.length > 0 + ) { + const { likes, ...rest } = reply; + return { + ...rest, + reactions: { [HEART_EMOJI]: likes }, + }; + } + return reply; + }); + } + } + + commentBatch.push({ + updateOne: { + filter: { _id: doc._id }, + update, + }, + }); + + if (commentBatch.length >= BATCH_SIZE) { + const result = await db + .collection("communitycomments") + .bulkWrite(commentBatch); + commentCount += result.modifiedCount; + console.log( + ` Processed ${commentCount} CommunityComment documents...`, + ); + commentBatch = []; + } + } + + if (commentBatch.length > 0) { + const result = await db + .collection("communitycomments") + .bulkWrite(commentBatch); + commentCount += result.modifiedCount; + } + + console.log(`✅ Migrated ${commentCount} CommunityComment documents.`); + + // --- Also migrate docs that have both likes and no reactions --- + console.log( + "Setting default reactions on documents without reactions...", + ); + const postResult = await db + .collection("communityposts") + .updateMany( + { reactions: { $exists: false } }, + { $set: { reactions: {} } }, + ); + console.log( + ` Set default reactions on ${postResult.modifiedCount} CommunityPost documents.`, + ); + + const commentResult = db + .collection("communitycomments") + .updateMany( + { reactions: { $exists: false } }, + { $set: { reactions: {} } }, + ); + const commentResult2 = await commentResult; + console.log( + ` Set default reactions on ${commentResult2.modifiedCount} CommunityComment documents.`, + ); + + console.log("✅ Migration complete!"); + } catch (err) { + console.error("Migration failed:", err); + process.exit(1); + } finally { + await mongoose.connection.close(); + } +})(); diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/community/[id]/[postId]/community-post-page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/community/[id]/[postId]/community-post-page.tsx index 821384f7b..f03388cd6 100644 --- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/community/[id]/[postId]/community-post-page.tsx +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/community/[id]/[postId]/community-post-page.tsx @@ -21,6 +21,7 @@ import NotFound from "@components/admin/not-found"; import { CommunityInfo } from "@components/community/info"; import MembershipStatus from "@components/community/membership-status"; import CommentSection from "@components/community/comment-section"; +import { ReactionsBar } from "@components/community/reactions-bar"; import dynamic from "next/dynamic"; import { useMediaLit, useToast } from "@courselit/components-library"; import { @@ -35,7 +36,6 @@ import { Trash, FlagTriangleRight, MessageSquare, - ThumbsUp, } from "lucide-react"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { @@ -125,6 +125,20 @@ export default function CommunityPostPage({ } } likesCount + reactions { + emoji + count + hasReacted + reactors { + userId + name + avatar { + mediaId + file + thumbnail + } + } + } commentsCount updatedAt hasLiked @@ -210,6 +224,58 @@ export default function CommunityPostPage({ } }; + const handleReact = async (targetPostId: string, emoji: string) => { + const query = ` + 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 + } + } + } + } + } + `; + + try { + const fetch = new FetchBuilder() + .setUrl(`${address.backend}/api/graph`) + .setPayload({ + query, + variables: { communityId, postId: targetPostId, emoji }, + }) + .setIsGraphQLEndpoint(true) + .build(); + const response = await fetch.exec(); + if (response.togglePostReaction) { + setPost((prev) => + prev && prev.postId === targetPostId + ? { + ...prev, + reactions: response.togglePostReaction.reactions, + } + : prev, + ); + } + } catch (err: any) { + toast({ + title: TOAST_TITLE_ERROR, + description: err.message, + variant: "destructive", + }); + } + }; + const uploadAttachments = useCallback( async (media: MediaItem[]) => { for (const i in media) { @@ -644,15 +710,12 @@ export default function CommunityPostPage({ )}
- + + handleReact(currentPost.postId, emoji) + } + /> - +
+ { + if (isCommunityComment(comment)) { + onReact(comment.commentId, emoji); + } else { + onReact( + comment.commentId, + emoji, + comment.replyId, + ); + } + }} + showReplyButton + onReply={() => setIsReplying(!isReplying)} + />
@@ -325,7 +324,9 @@ export function Comment({ ...reply, commentId: comment.commentId, }} - onLike={() => onLike(comment.commentId, reply.replyId)} + onReact={(commentId, emoji, replyId) => + onReact(commentId, emoji, reply.replyId) + } onReply={onReply} onDelete={onDelete} membership={membership} diff --git a/apps/web/components/community/emoji-picker.tsx b/apps/web/components/community/emoji-picker.tsx new file mode 100644 index 000000000..6dc0d9dc4 --- /dev/null +++ b/apps/web/components/community/emoji-picker.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; + +const COMMON_EMOJIS = ["👍", "❤️", "😄", "🎉", "😢", "😮"]; + +interface EmojiPickerProps { + onEmojiSelect: (emoji: string) => void; + children?: React.ReactNode; +} + +export function EmojiPicker({ onEmojiSelect, children }: EmojiPickerProps) { + const [open, setOpen] = useState(false); + + return ( + + + {children || ( + + )} + + +
+ {COMMON_EMOJIS.map((emoji) => ( + + ))} +
+
+
+ ); +} diff --git a/apps/web/components/community/index.tsx b/apps/web/components/community/index.tsx index 57d725480..54fafe8c9 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,27 +270,31 @@ 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) => - prevPosts.map((post) => - post.postId === postId - ? { - ...post, - likesCount: post.hasLiked - ? post.likesCount - 1 - : post.likesCount + 1, - hasLiked: !post.hasLiked, - } - : 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 +303,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 +986,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..a31361db1 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); @@ -137,25 +138,14 @@ export default function CommunityPostCard({ )} -
- - -
+ onReact?.(post.postId, emoji)} + compact + showReplyButton + onReply={() => onOpen(post.postId)} + repliesCount={post.commentsCount} + />
); diff --git a/apps/web/components/community/reactions-bar.tsx b/apps/web/components/community/reactions-bar.tsx new file mode 100644 index 000000000..060e43e96 --- /dev/null +++ b/apps/web/components/community/reactions-bar.tsx @@ -0,0 +1,179 @@ +"use client"; + +import { useState, useRef, useEffect } from "react"; +import { CommunityReaction } from "@courselit/common-models"; +import { SmilePlus, Reply } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { EmojiPicker } from "./emoji-picker"; + +interface ReactionsBarProps { + reactions: CommunityReaction[]; + onReact: (emoji: string) => void; + /** + * Optional flag to show reactions in a compact layout (for comments/replies). + * Defaults to false (full layout for post cards). + */ + compact?: boolean; + /** + * Optional reply button rendered at the end of the bar (after reactions). + */ + onReply?: () => void; + /** + * Whether to show the reply button. Defaults to false. + */ + showReplyButton?: boolean; + /** + * Number of replies to show on the reply button (post only). + */ + repliesCount?: number; +} + +export function ReactionsBar({ + reactions, + onReact, + compact = false, + onReply, + showReplyButton = false, + repliesCount, +}: ReactionsBarProps) { + const [hoveredEmoji, setHoveredEmoji] = useState(null); + const [tooltipPos, setTooltipPos] = useState<{ + top: number; + left: number; + } | null>(null); + const tooltipRef = useRef(null); + const timeoutRef = useRef | null>(null); + + const handleMouseEnter = ( + emoji: string, + e: React.MouseEvent, + ) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + const rect = e.currentTarget.getBoundingClientRect(); + setTooltipPos({ + top: rect.top - 8, + left: rect.left + rect.width / 2, + }); + setHoveredEmoji(emoji); + }; + + const handleMouseLeave = () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = setTimeout(() => { + setHoveredEmoji(null); + setTooltipPos(null); + }, 200); + }; + + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + const activeReactions = reactions.filter((r) => r.count > 0); + const hoveredReaction = reactions.find((r) => r.emoji === hoveredEmoji); + + return ( + <> +
+ {/* Emoji picker — always first */} + { + onReact(emoji); + }} + > + + + + {/* Active reaction pills — wrap as they accumulate */} + {activeReactions.map((reaction) => ( + + ))} + {showReplyButton && ( + + )} +
+ + {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 326341260..7f71430cf 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, @@ -28,6 +29,7 @@ import { extractTextFromTextEditorContent, normalizeTextEditorContent, } from "@courselit/utils"; +import UserModel from "@models/User"; export type PublicPost = Omit< CommunityPost, @@ -36,54 +38,175 @@ 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) as [ + string, + string[], + ][]; + 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: Object.fromEntries( + reactionsMap, + ) as unknown as CommunityReaction[], + 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: Object.fromEntries( + replyReactionsMap, + ) as unknown as CommunityReaction[], + 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), + // Store raw reactions map so the GraphQL field resolver can + // access reactor details. Field resolver calls + // getReactionsForEntity which reads entity.reactions. + reactions: Object.fromEntries( + reactionsMap, + ) as unknown as CommunityReaction[], + userId: post.userId, + }; +}; export async function toggleContentVisibility( contentId: string, diff --git a/apps/web/graphql/communities/logic.ts b/apps/web/graphql/communities/logic.ts index 0cab8c071..51b3a6b04 100644 --- a/apps/web/graphql/communities/logic.ts +++ b/apps/web/graphql/communities/logic.ts @@ -1505,6 +1505,71 @@ 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 (toggle off) + const filtered = existing.filter((id) => id !== userId); + if (filtered.length === 0) { + reactionsMap.delete(emoji); + } else { + reactionsMap.set(emoji, filtered); + } + return { added: false }; + } else { + // Add user to this reaction — allow multiple different emoji reactions per user + existing.push(userId); + reactionsMap.set(emoji, existing); + return { added: true }; + } +} + export async function togglePostLike({ ctx, communityId, @@ -1541,13 +1606,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 +1633,71 @@ 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], + emoji, + }, + }); + } + + return formatPost(post, ctx.user.userId); +} + export async function togglePinned({ ctx, communityId, @@ -1829,12 +1960,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 +1984,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 +2100,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 +2126,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/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/apps/web/next.config.js b/apps/web/next.config.js index 139057198..57ef89a3a 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -31,6 +31,7 @@ const nextConfig = { "jsonwebtoken", ], experimental: {}, + allowedDevOrigins: ["clcomp.taile2f1.ts.net"], }; module.exports = nextConfig; diff --git a/apps/web/proxy.ts b/apps/web/proxy.ts index 6a034470f..2370250e5 100644 --- a/apps/web/proxy.ts +++ b/apps/web/proxy.ts @@ -1,6 +1,6 @@ import { NextResponse, type NextRequest } from "next/server"; import { getBackendAddress } from "@/app/actions"; -import { auth } from "./auth"; +import { getAuth } from "./auth"; import { COURSE_VIEWER_CURRENT_URL_HEADER } from "./lib/course-viewer-session-params"; export async function proxy(request: NextRequest) { @@ -77,7 +77,7 @@ export async function proxy(request: NextRequest) { } if (request.nextUrl.pathname.startsWith("/dashboard")) { - const session = await auth.api.getSession({ + const session = await getAuth(backend).api.getSession({ headers: requestHeaders, }); if (!session) { diff --git a/packages/common-logic/src/utils/get-notification-message-and-href.ts b/packages/common-logic/src/utils/get-notification-message-and-href.ts index 0c6b1ffc8..94678cf6f 100644 --- a/packages/common-logic/src/utils/get-notification-message-and-href.ts +++ b/packages/common-logic/src/utils/get-notification-message-and-href.ts @@ -200,8 +200,10 @@ export async function getNotificationMessageAndHref({ return { message: "", href: "" }; } + const emoji = (metadata?.emoji as string) || "👍"; + return { - message: `${actorName} liked your post '${truncate(post.title, 20).trim()}' in ${community.name}`, + message: `${actorName} reacted ${emoji} to your post '${truncate(post.title, 20).trim()}' in ${community.name}`, href: toHref( `/dashboard/community/${community.communityId}/${post.postId}`, hrefPrefix, 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 6ed6f778f..de49645f7 100644 --- a/packages/common-models/src/index.ts +++ b/packages/common-models/src/index.ts @@ -74,3 +74,4 @@ export * from "./login-provider"; export * from "./features"; export * from "./product-discussion"; export type { ScormContent } from "./scorm-content"; +export type { CommunityReaction } from "./community-reaction"; 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 }, }, {