From 4f3577f009190f8895991755f1a06c7d78990704 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Mon, 2 Mar 2026 01:42:25 +0000 Subject: [PATCH] feat: enhance comments (#1980) * feat: non-inline comments support * enhance comments * fix types --- .../src/ee/comment/queries/comment-query.ts | 105 +++++++++--------- .../comment/components/comment-dialog.tsx | 7 -- .../comment/components/comment-list-item.tsx | 22 +--- .../components/comment-list-with-tabs.tsx | 14 +-- .../features/comment/queries/comment-query.ts | 105 +++++++++++++----- .../src/features/websocket/types/types.ts | 46 ++++---- .../websocket/use-query-subscription.ts | 88 ++++++++++----- apps/client/src/hooks/use-time-ago.tsx | 40 +++++-- .../src/core/comment/comment.controller.ts | 13 ++- .../src/core/comment/comment.service.ts | 21 +++- apps/server/src/ee | 2 +- apps/server/src/ws/ws.service.ts | 31 +++++- 12 files changed, 310 insertions(+), 184 deletions(-) diff --git a/apps/client/src/ee/comment/queries/comment-query.ts b/apps/client/src/ee/comment/queries/comment-query.ts index b09f4e79..ecafe029 100644 --- a/apps/client/src/ee/comment/queries/comment-query.ts +++ b/apps/client/src/ee/comment/queries/comment-query.ts @@ -1,6 +1,7 @@ import { useMutation, useQueryClient, + InfiniteData, } from "@tanstack/react-query"; import { resolveComment } from "@/features/comment/services/comment-service"; import { @@ -10,41 +11,54 @@ import { import { notifications } from "@mantine/notifications"; import { IPagination } from "@/lib/types.ts"; import { useTranslation } from "react-i18next"; -import { useQueryEmit } from "@/features/websocket/use-query-emit"; import { RQ_KEY } from "@/features/comment/queries/comment-query"; +function updateCommentInCache( + cache: InfiniteData>, + commentId: string, + updater: (comment: IComment) => IComment, +): InfiniteData> { + return { + ...cache, + pages: cache.pages.map((page) => ({ + ...page, + items: page.items.map((comment) => + comment.id === commentId ? updater(comment) : comment, + ), + })), + }; +} + export function useResolveCommentMutation() { const queryClient = useQueryClient(); const { t } = useTranslation(); - const emit = useQueryEmit(); return useMutation({ mutationFn: (data: IResolveComment) => resolveComment(data), onMutate: async (variables) => { await queryClient.cancelQueries({ queryKey: RQ_KEY(variables.pageId) }); - const previousComments = queryClient.getQueryData(RQ_KEY(variables.pageId)); - queryClient.setQueryData(RQ_KEY(variables.pageId), (old: IPagination) => { - if (!old || !old.items) return old; - const updatedItems = old.items.map((comment) => - comment.id === variables.commentId - ? { - ...comment, - resolvedAt: variables.resolved ? new Date() : null, - resolvedById: variables.resolved ? 'optimistic-user' : null, - resolvedBy: variables.resolved ? { id: 'optimistic-user', name: 'Resolving...', avatarUrl: null } : null - } - : comment, + const previousCache = queryClient.getQueryData(RQ_KEY(variables.pageId)); + + const cache = previousCache as InfiniteData> | undefined; + if (cache) { + queryClient.setQueryData( + RQ_KEY(variables.pageId), + updateCommentInCache(cache, variables.commentId, (comment) => ({ + ...comment, + resolvedAt: variables.resolved ? new Date() : null, + resolvedById: variables.resolved ? "optimistic" : null, + resolvedBy: variables.resolved + ? { id: "optimistic", name: "", avatarUrl: null } + : null, + })), ); - return { - ...old, - items: updatedItems, - }; - }); - return { previousComments }; + } + + return { previousCache }; }, - onError: (err, variables, context) => { - if (context?.previousComments) { - queryClient.setQueryData(RQ_KEY(variables.pageId), context.previousComments); + onError: (_err, variables, context) => { + if (context?.previousCache) { + queryClient.setQueryData(RQ_KEY(variables.pageId), context.previousCache); } notifications.show({ message: t("Failed to resolve comment"), @@ -52,35 +66,26 @@ export function useResolveCommentMutation() { }); }, onSuccess: (data: IComment, variables) => { - const pageId = data.pageId; - const currentComments = queryClient.getQueryData( - RQ_KEY(pageId), - ) as IPagination; - if (currentComments && currentComments.items) { - const updatedComments = currentComments.items.map((comment) => - comment.id === variables.commentId - ? { ...comment, resolvedAt: data.resolvedAt, resolvedById: data.resolvedById, resolvedBy: data.resolvedBy } - : comment, + const cache = queryClient.getQueryData( + RQ_KEY(data.pageId), + ) as InfiniteData> | undefined; + + if (cache) { + queryClient.setQueryData( + RQ_KEY(data.pageId), + updateCommentInCache(cache, variables.commentId, (comment) => ({ + ...comment, + resolvedAt: data.resolvedAt, + resolvedById: data.resolvedById, + resolvedBy: data.resolvedBy, + })), ); - queryClient.setQueryData(RQ_KEY(pageId), { - ...currentComments, - items: updatedComments, - }); } - emit({ - operation: "resolveComment", - pageId: pageId, - commentId: variables.commentId, - resolved: variables.resolved, - resolvedAt: data.resolvedAt, - resolvedById: data.resolvedById, - resolvedBy: data.resolvedBy, - }); - queryClient.invalidateQueries({ queryKey: RQ_KEY(pageId) }); - notifications.show({ - message: variables.resolved - ? t("Comment resolved successfully") - : t("Comment re-opened successfully") + + notifications.show({ + message: variables.resolved + ? t("Comment resolved successfully") + : t("Comment re-opened successfully"), }); }, }); diff --git a/apps/client/src/features/comment/components/comment-dialog.tsx b/apps/client/src/features/comment/components/comment-dialog.tsx index e36931ba..6248e913 100644 --- a/apps/client/src/features/comment/components/comment-dialog.tsx +++ b/apps/client/src/features/comment/components/comment-dialog.tsx @@ -15,7 +15,6 @@ import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar- import { useEditor } from "@tiptap/react"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { useTranslation } from "react-i18next"; -import { useQueryEmit } from "@/features/websocket/use-query-emit"; interface CommentDialogProps { editor: ReturnType; @@ -37,8 +36,6 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) { const createCommentMutation = useCreateCommentMutation(); const { isPending } = createCommentMutation; - const emit = useQueryEmit(); - const handleDialogClose = () => { setShowCommentPopup(false); editor.chain().focus().unsetCommentDecoration().run(); @@ -82,10 +79,6 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) { ); }, 400); - emit({ - operation: "invalidateComment", - pageId: pageId, - }); } finally { setShowCommentPopup(false); setDraftCommentId(""); diff --git a/apps/client/src/features/comment/components/comment-list-item.tsx b/apps/client/src/features/comment/components/comment-list-item.tsx index 738f2e4f..c391bfe6 100644 --- a/apps/client/src/features/comment/components/comment-list-item.tsx +++ b/apps/client/src/features/comment/components/comment-list-item.tsx @@ -2,7 +2,7 @@ import { Group, Text, Box, Badge } from "@mantine/core"; import React, { useEffect, useRef, useState } from "react"; import classes from "./comment.module.css"; import { useAtom, useAtomValue } from "jotai"; -import { timeAgo } from "@/lib/time"; +import { useTimeAgo } from "@/hooks/use-time-ago"; import CommentEditor from "@/features/comment/components/comment-editor"; import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms"; import CommentActions from "@/features/comment/components/comment-actions"; @@ -18,7 +18,6 @@ import { useResolveCommentMutation } from "@/ee/comment/queries/comment-query"; import { IComment } from "@/features/comment/types/comment.types"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts"; -import { useQueryEmit } from "@/features/websocket/use-query-emit"; import { useTranslation } from "react-i18next"; interface CommentListItemProps { @@ -45,8 +44,8 @@ function CommentListItem({ const deleteCommentMutation = useDeleteCommentMutation(comment.pageId); const resolveCommentMutation = useResolveCommentMutation(); const [currentUser] = useAtom(currentUserAtom); - const emit = useQueryEmit(); const isCloudEE = useIsCloudEE(); + const createdAtAgo = useTimeAgo(comment.createdAt); useEffect(() => { setContent(comment.content); @@ -65,11 +64,6 @@ function CommentListItem({ editContentRef.current = null; } setIsEditing(false); - - emit({ - operation: "invalidateComment", - pageId: pageId, - }); } catch (error) { console.error("Failed to update comment:", error); } finally { @@ -81,11 +75,6 @@ function CommentListItem({ try { await deleteCommentMutation.mutateAsync(comment.id); editor?.commands.unsetComment(comment.id); - - emit({ - operation: "invalidateComment", - pageId: pageId, - }); } catch (error) { console.error("Failed to delete comment:", error); } @@ -106,11 +95,6 @@ function CommentListItem({ if (editor) { editor.commands.setCommentResolved(comment.id, !isResolved); } - - emit({ - operation: "invalidateComment", - pageId: pageId, - }); } catch (error) { console.error("Failed to toggle resolved state:", error); } @@ -177,7 +161,7 @@ function CommentListItem({ - {timeAgo(comment.createdAt)} + {createdAtAgo} diff --git a/apps/client/src/features/comment/components/comment-list-with-tabs.tsx b/apps/client/src/features/comment/components/comment-list-with-tabs.tsx index 4eaf71c9..70d3107f 100644 --- a/apps/client/src/features/comment/components/comment-list-with-tabs.tsx +++ b/apps/client/src/features/comment/components/comment-list-with-tabs.tsx @@ -14,7 +14,6 @@ import { usePageQuery } from "@/features/page/queries/page-query.ts"; import { IPagination } from "@/lib/types.ts"; import { extractPageSlugId } from "@/lib"; import { useTranslation } from "react-i18next"; -import { useQueryEmit } from "@/features/websocket/use-query-emit"; import { useIsCloudEE } from "@/hooks/use-is-cloud-ee"; import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts"; import { IconArrowUp, IconMessageOff } from "@tabler/icons-react"; @@ -27,10 +26,9 @@ function CommentListWithTabs() { data: comments, isLoading: isCommentsLoading, isError, - } = useCommentsQuery({ pageId: page?.id, limit: 100 }); + } = useCommentsQuery({ pageId: page?.id }); const createCommentMutation = useCreateCommentMutation(); const [isLoading, setIsLoading] = useState(false); - const emit = useQueryEmit(); const isCloudEE = useIsCloudEE(); const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug); @@ -67,11 +65,6 @@ function CommentListWithTabs() { content: JSON.stringify(content), }); - emit({ - operation: "invalidateComment", - pageId: page?.id, - }); - setTimeout(() => { const selector = `div[data-comment-id="${createdComment.id}"]`; const commentElement = document.querySelector(selector); @@ -97,11 +90,6 @@ function CommentListWithTabs() { }; await createCommentMutation.mutateAsync(commentData); - - emit({ - operation: "invalidateComment", - pageId: page?.id, - }); } catch (error) { console.error("Failed to post comment:", error); } finally { diff --git a/apps/client/src/features/comment/queries/comment-query.ts b/apps/client/src/features/comment/queries/comment-query.ts index c10ca418..6c86c039 100644 --- a/apps/client/src/features/comment/queries/comment-query.ts +++ b/apps/client/src/features/comment/queries/comment-query.ts @@ -1,8 +1,8 @@ import { + useInfiniteQuery, useMutation, - useQuery, useQueryClient, - UseQueryResult, + InfiniteData, } from "@tanstack/react-query"; import { createComment, @@ -17,17 +17,40 @@ import { import { notifications } from "@mantine/notifications"; import { IPagination } from "@/lib/types.ts"; import { useTranslation } from "react-i18next"; +import { useEffect, useMemo } from "react"; export const RQ_KEY = (pageId: string) => ["comments", pageId]; -export function useCommentsQuery( - params: ICommentParams, -): UseQueryResult, Error> { - return useQuery({ +export function useCommentsQuery(params: ICommentParams) { + const query = useInfiniteQuery({ queryKey: RQ_KEY(params.pageId), - queryFn: () => getPageComments(params), + queryFn: ({ pageParam }) => + getPageComments({ pageId: params.pageId, cursor: pageParam, limit: 100 }), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => + lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined, enabled: !!params.pageId, }); + + useEffect(() => { + if (query.hasNextPage && !query.isFetchingNextPage) { + query.fetchNextPage(); + } + }, [query.hasNextPage, query.isFetchingNextPage, query.fetchNextPage]); + + const data = useMemo | undefined>(() => { + if (!query.data) return undefined; + return { + items: query.data.pages.flatMap((p) => p.items), + meta: query.data.pages[query.data.pages.length - 1].meta, + }; + }, [query.data]); + + return { + data, + isLoading: query.isLoading || query.hasNextPage, + isError: query.isError, + }; } export function useCreateCommentMutation() { @@ -36,18 +59,26 @@ export function useCreateCommentMutation() { return useMutation>({ mutationFn: (data) => createComment(data), - onSuccess: (data) => { - //const newComment = data; - // let comments = queryClient.getQueryData(RQ_KEY(data.pageId)); - // if (comments) { - //comments = prevComments => [...prevComments, newComment]; - //queryClient.setQueryData(RQ_KEY(data.pageId), comments); - //} + onSuccess: (newComment) => { + const cache = queryClient.getQueryData( + RQ_KEY(newComment.pageId), + ) as InfiniteData> | undefined; + + if (cache && cache.pages.length > 0) { + const lastIdx = cache.pages.length - 1; + queryClient.setQueryData(RQ_KEY(newComment.pageId), { + ...cache, + pages: cache.pages.map((page, i) => + i === lastIdx + ? { ...page, items: [...page.items, newComment] } + : page, + ), + }); + } - queryClient.refetchQueries({ queryKey: RQ_KEY(data.pageId) }); notifications.show({ message: t("Comment created successfully") }); }, - onError: (error) => { + onError: () => { notifications.show({ message: t("Error creating comment"), color: "red", @@ -57,14 +88,31 @@ export function useCreateCommentMutation() { } export function useUpdateCommentMutation() { + const queryClient = useQueryClient(); const { t } = useTranslation(); return useMutation>({ mutationFn: (data) => updateComment(data), - onSuccess: (data) => { + onSuccess: (updatedComment) => { + const cache = queryClient.getQueryData( + RQ_KEY(updatedComment.pageId), + ) as InfiniteData> | undefined; + + if (cache) { + queryClient.setQueryData(RQ_KEY(updatedComment.pageId), { + ...cache, + pages: cache.pages.map((page) => ({ + ...page, + items: page.items.map((comment) => + comment.id === updatedComment.id ? updatedComment : comment, + ), + })), + }); + } + notifications.show({ message: t("Comment updated successfully") }); }, - onError: (error) => { + onError: () => { notifications.show({ message: t("Failed to update comment"), color: "red", @@ -79,25 +127,24 @@ export function useDeleteCommentMutation(pageId?: string) { return useMutation({ mutationFn: (commentId: string) => deleteComment(commentId), - onSuccess: (data, variables) => { - const comments = queryClient.getQueryData( + onSuccess: (_data, commentId) => { + const cache = queryClient.getQueryData( RQ_KEY(pageId), - ) as IPagination; + ) as InfiniteData> | undefined; - if (comments && comments.items) { - const commentId = variables; - const newComments = comments.items.filter( - (comment) => comment.id !== commentId, - ); + if (cache) { queryClient.setQueryData(RQ_KEY(pageId), { - ...comments, - items: newComments, + ...cache, + pages: cache.pages.map((page) => ({ + ...page, + items: page.items.filter((comment) => comment.id !== commentId), + })), }); } notifications.show({ message: t("Comment deleted successfully") }); }, - onError: (error) => { + onError: () => { notifications.show({ message: t("Failed to delete comment"), color: "red", diff --git a/apps/client/src/features/websocket/types/types.ts b/apps/client/src/features/websocket/types/types.ts index 3bc5b941..1f20aca5 100644 --- a/apps/client/src/features/websocket/types/types.ts +++ b/apps/client/src/features/websocket/types/types.ts @@ -1,5 +1,6 @@ import { SpaceTreeNode } from "@/features/page/tree/types.ts"; import { IPage } from "@/features/page/types/page.types"; +import { IComment } from "@/features/comment/types/comment.types"; export type InvalidateEvent = { operation: "invalidate"; @@ -8,9 +9,28 @@ export type InvalidateEvent = { id?: string; }; -export type InvalidateCommentsEvent = { - operation: "invalidateComment"; +export type CommentCreatedEvent = { + operation: "commentCreated"; pageId: string; + comment: IComment; +}; + +export type CommentUpdatedEvent = { + operation: "commentUpdated"; + pageId: string; + comment: IComment; +}; + +export type CommentDeletedEvent = { + operation: "commentDeleted"; + pageId: string; + commentId: string; +}; + +export type CommentResolvedEvent = { + operation: "commentResolved"; + pageId: string; + comment: IComment; }; export type UpdateEvent = { @@ -65,27 +85,15 @@ export type RefetchRootTreeNodeEvent = { spaceId: string; }; -export type ResolveCommentEvent = { - operation: "resolveComment"; - pageId: string; - commentId: string; - resolved: boolean; - resolvedAt?: Date; - resolvedById?: string; - resolvedBy?: { - id: string; - name: string; - avatarUrl?: string | null; - }; -}; - export type WebSocketEvent = | InvalidateEvent - | InvalidateCommentsEvent + | CommentCreatedEvent + | CommentUpdatedEvent + | CommentDeletedEvent + | CommentResolvedEvent | UpdateEvent | DeleteEvent | AddTreeNodeEvent | MoveTreeNodeEvent | DeleteTreeNodeEvent - | RefetchRootTreeNodeEvent - | ResolveCommentEvent; + | RefetchRootTreeNodeEvent; diff --git a/apps/client/src/features/websocket/use-query-subscription.ts b/apps/client/src/features/websocket/use-query-subscription.ts index faa7139f..c661f953 100644 --- a/apps/client/src/features/websocket/use-query-subscription.ts +++ b/apps/client/src/features/websocket/use-query-subscription.ts @@ -12,7 +12,6 @@ import { invalidateOnUpdatePage, } from "../page/queries/page-query"; import { RQ_KEY } from "../comment/queries/comment-query"; -import { queryClient } from "@/main.tsx"; import { IComment } from "@/features/comment/types/comment.types"; export const useQuerySubscription = () => { @@ -32,11 +31,66 @@ export const useQuerySubscription = () => { queryKey: [...data.entity, data.id].filter(Boolean), }); break; - case "invalidateComment": - queryClient.invalidateQueries({ - queryKey: RQ_KEY(data.pageId), - }); + case "commentCreated": { + const createCache = queryClient.getQueryData( + RQ_KEY(data.pageId), + ) as InfiniteData> | undefined; + + if (createCache && createCache.pages.length > 0) { + const alreadyExists = createCache.pages.some((page) => + page.items.some((c) => c.id === data.comment.id), + ); + if (alreadyExists) break; + + const lastIdx = createCache.pages.length - 1; + queryClient.setQueryData(RQ_KEY(data.pageId), { + ...createCache, + pages: createCache.pages.map((page, i) => + i === lastIdx + ? { ...page, items: [...page.items, data.comment] } + : page, + ), + }); + } break; + } + case "commentUpdated": + case "commentResolved": { + const updateCache = queryClient.getQueryData( + RQ_KEY(data.pageId), + ) as InfiniteData> | undefined; + + if (updateCache) { + queryClient.setQueryData(RQ_KEY(data.pageId), { + ...updateCache, + pages: updateCache.pages.map((page) => ({ + ...page, + items: page.items.map((comment) => + comment.id === data.comment.id ? data.comment : comment, + ), + })), + }); + } + break; + } + case "commentDeleted": { + const deleteCache = queryClient.getQueryData( + RQ_KEY(data.pageId), + ) as InfiniteData> | undefined; + + if (deleteCache) { + queryClient.setQueryData(RQ_KEY(data.pageId), { + ...deleteCache, + pages: deleteCache.pages.map((page) => ({ + ...page, + items: page.items.filter( + (comment) => comment.id !== data.commentId, + ), + })), + }); + } + break; + } case "addTreeNode": invalidateOnCreatePage(data.payload.data); break; @@ -103,30 +157,6 @@ export const useQuerySubscription = () => { }); break; } - case "resolveComment": { - const currentComments = queryClient.getQueryData( - RQ_KEY(data.pageId), - ) as IPagination; - - if (currentComments && currentComments.items) { - const updatedComments = currentComments.items.map((comment) => - comment.id === data.commentId - ? { - ...comment, - resolvedAt: data.resolvedAt, - resolvedById: data.resolvedById, - resolvedBy: data.resolvedBy - } - : comment, - ); - - queryClient.setQueryData(RQ_KEY(data.pageId), { - ...currentComments, - items: updatedComments, - }); - } - break; - } } }); }, [queryClient, socket]); diff --git a/apps/client/src/hooks/use-time-ago.tsx b/apps/client/src/hooks/use-time-ago.tsx index 03fa9eda..6f2148ca 100644 --- a/apps/client/src/hooks/use-time-ago.tsx +++ b/apps/client/src/hooks/use-time-ago.tsx @@ -1,16 +1,32 @@ import { timeAgo } from "@/lib/time.ts"; -import { useEffect, useState } from "react"; +import { useMemo, useSyncExternalStore } from "react"; + +let tick = 0; +let intervalId: ReturnType | null = null; +const listeners = new Set<() => void>(); + +function subscribe(callback: () => void) { + listeners.add(callback); + if (listeners.size === 1) { + intervalId = setInterval(() => { + tick++; + listeners.forEach((cb) => cb()); + }, 60_000); + } + return () => { + listeners.delete(callback); + if (listeners.size === 0 && intervalId) { + clearInterval(intervalId); + intervalId = null; + } + }; +} + +function getSnapshot() { + return tick; +} export function useTimeAgo(date: Date | string) { - const [value, setValue] = useState(() => timeAgo(new Date(date))); - - useEffect(() => { - const interval = setInterval(() => { - setValue(timeAgo(new Date(date))); - }, 5 * 1000); - - return () => clearInterval(interval); - }, [date]); - - return value; + const currentTick = useSyncExternalStore(subscribe, getSnapshot); + return useMemo(() => timeAgo(new Date(date)), [date, currentTick]); } diff --git a/apps/server/src/core/comment/comment.controller.ts b/apps/server/src/core/comment/comment.controller.ts index 6bb23381..99e51384 100644 --- a/apps/server/src/core/comment/comment.controller.ts +++ b/apps/server/src/core/comment/comment.controller.ts @@ -31,6 +31,7 @@ import { AUDIT_SERVICE, IAuditService, } from '../../integrations/audit/audit.service'; +import { WsService } from '../../ws/ws.service'; @UseGuards(JwtAuthGuard) @Controller('comments') @@ -41,6 +42,7 @@ export class CommentController { private readonly pageRepo: PageRepo, private readonly spaceAbility: SpaceAbilityFactory, private readonly pageAccessService: PageAccessService, + private readonly wsService: WsService, @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, ) {} @@ -119,7 +121,10 @@ export class CommentController { @HttpCode(HttpStatus.OK) @Post('update') async update(@Body() dto: UpdateCommentDto, @AuthUser() user: User) { - const comment = await this.commentRepo.findById(dto.commentId); + const comment = await this.commentRepo.findById(dto.commentId, { + includeCreator: true, + includeResolvedBy: true, + }); if (!comment) { throw new NotFoundException('Comment not found'); } @@ -170,6 +175,12 @@ export class CommentController { await this.commentRepo.deleteComment(comment.id); } + this.wsService.emitCommentEvent(comment.spaceId, comment.pageId, { + operation: 'commentDeleted', + pageId: comment.pageId, + commentId: comment.id, + }); + this.auditService.log({ event: AuditEvent.COMMENT_DELETED, resourceType: AuditResource.COMMENT, diff --git a/apps/server/src/core/comment/comment.service.ts b/apps/server/src/core/comment/comment.service.ts index 84824544..9fa5e24c 100644 --- a/apps/server/src/core/comment/comment.service.ts +++ b/apps/server/src/core/comment/comment.service.ts @@ -17,6 +17,7 @@ import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination import { QueueJob, QueueName } from '../../integrations/queue/constants'; import { extractUserMentionIdsFromJson } from '../../common/helpers/prosemirror/utils'; import { ICommentNotificationJob } from '../../integrations/queue/constants/queue.interface'; +import { WsService } from '../../ws/ws.service'; @Injectable() export class CommentService { @@ -25,6 +26,7 @@ export class CommentService { constructor( private commentRepo: CommentRepo, private pageRepo: PageRepo, + private wsService: WsService, @InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue, @InjectQueue(QueueName.NOTIFICATION_QUEUE) @@ -63,7 +65,7 @@ export class CommentService { } } - const comment = await this.commentRepo.insertComment({ + const inserted = await this.commentRepo.insertComment({ pageId: page.id, content: commentContent, selection: createCommentDto?.selection?.substring(0, 250) ?? null, @@ -74,6 +76,11 @@ export class CommentService { spaceId: page.spaceId, }); + const comment = await this.commentRepo.findById(inserted.id, { + includeCreator: true, + includeResolvedBy: true, + }); + this.generalQueue .add(QueueJob.ADD_PAGE_WATCHERS, { userIds: [userId], @@ -99,6 +106,12 @@ export class CommentService { createCommentDto.parentCommentId, ); + this.wsService.emitCommentEvent(page.spaceId, page.id, { + operation: 'commentCreated', + pageId: page.id, + comment, + }); + return comment; } @@ -154,6 +167,12 @@ export class CommentService { comment.editedAt = editedAt; comment.updatedAt = editedAt; + this.wsService.emitCommentEvent(comment.spaceId, comment.pageId, { + operation: 'commentUpdated', + pageId: comment.pageId, + comment, + }); + return comment; } diff --git a/apps/server/src/ee b/apps/server/src/ee index 7ef59006..0b3a6f4a 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 7ef5900616fee3ea3b10e9dbf616b17b67eec700 +Subproject commit 0b3a6f4af08e46074d63f26b0ce52e6b4dbd6333 diff --git a/apps/server/src/ws/ws.service.ts b/apps/server/src/ws/ws.service.ts index 5476664a..3cdccf6b 100644 --- a/apps/server/src/ws/ws.service.ts +++ b/apps/server/src/ws/ws.service.ts @@ -45,7 +45,7 @@ export class WsService { return; } - await this.broadcastToAuthorizedUsers(client, room, pageId, data); + await this.broadcastToAuthorizedUsers(room, client.data.userId, pageId, data); } async invalidateSpaceRestrictionCache(spaceId: string): Promise { @@ -54,6 +54,29 @@ export class WsService { ); } + async emitCommentEvent( + spaceId: string, + pageId: string, + data: any, + ): Promise { + const room = getSpaceRoomName(spaceId); + + const hasRestrictions = await this.spaceHasRestrictions(spaceId); + if (!hasRestrictions) { + this.server.to(room).emit('message', data); + return; + } + + const isRestricted = + await this.pagePermissionRepo.hasRestrictedAncestor(pageId); + if (!isRestricted) { + this.server.to(room).emit('message', data); + return; + } + + await this.broadcastToAuthorizedUsers(room, null, pageId, data); + } + async emitToUsers(userIds: string[], data: any): Promise { if (userIds.length === 0) return; const rooms = userIds.map((id) => getUserRoomName(id)); @@ -82,14 +105,16 @@ export class WsService { } private async broadcastToAuthorizedUsers( - sender: Socket, room: string, + excludeUserId: string | null, pageId: string, data: any, ): Promise { const sockets = await this.server.in(room).fetchSockets(); - const otherSockets = sockets.filter((s) => s.id !== sender.id); + const otherSockets = excludeUserId + ? sockets.filter((s) => s.data.userId !== excludeUserId) + : sockets; if (otherSockets.length === 0) return; const userSocketMap = new Map();