diff --git a/apps/web/ce/components/comments/comment-block.tsx b/apps/web/ce/components/comments/comment-block.tsx index becc8acb45..b20d8fc7a4 100644 --- a/apps/web/ce/components/comments/comment-block.tsx +++ b/apps/web/ce/components/comments/comment-block.tsx @@ -41,7 +41,7 @@ export const CommentBlock = observer(function CommentBlock(props: TCommentBlock) ref={commentBlockRef} >
void; + renderFooter?: (ReactionsComponent: ReactNode) => ReactNode; }; export const CommentCardDisplay = observer(function CommentCardDisplay(props: TCommentCardDisplayProps) { @@ -40,6 +42,7 @@ export const CommentCardDisplay = observer(function CommentCardDisplay(props: TC workspaceSlug, isEditing = false, setIsEditing, + renderFooter, } = props; // states const [highlightClassName, setHighlightClassName] = useState(""); @@ -99,11 +102,17 @@ export const CommentCardDisplay = observer(function CommentCardDisplay(props: TC displayConfig={{ fontSize: "small-font", }} - parentClassName="border-none" + parentClassName="border-none pl-1" /> -
- -
+ {renderFooter ? ( + renderFooter( + + ) + ) : ( +
+ +
+ )} )}
diff --git a/apps/web/core/components/comments/card/edit-form.tsx b/apps/web/core/components/comments/card/edit-form.tsx index 4be5b827b5..7b8b14a0cc 100644 --- a/apps/web/core/components/comments/card/edit-form.tsx +++ b/apps/web/core/components/comments/card/edit-form.tsx @@ -6,7 +6,7 @@ import type { EditorRefApi } from "@plane/editor"; import { CloseIcon } from "@plane/propel/icons"; // plane imports import type { TCommentsOperations, TIssueComment } from "@plane/types"; -import { isCommentEmpty } from "@plane/utils"; +import { cn, isCommentEmpty } from "@plane/utils"; // components import { LiteTextEditor } from "@/components/editor/lite-text"; @@ -99,7 +99,7 @@ export const CommentCardEditForm = observer(function CommentCardEditForm(props: return asset_id; }} projectId={projectId} - parentClassName="p-2" + parentClassName="p-2 bg-custom-background-100" displayConfig={{ fontSize: "small-font", }} @@ -111,24 +111,41 @@ export const CommentCardEditForm = observer(function CommentCardEditForm(props: type="button" onClick={handleSubmit(onEnter)} disabled={isDisabled} - className={`group rounded border border-green-500 bg-green-500/20 p-2 shadow-md duration-300 ${ - isEmpty ? "cursor-not-allowed bg-gray-200" : "hover:bg-green-500" - }`} + className={cn( + "group rounded border p-2 shadow-md duration-300", + isDisabled + ? "cursor-not-allowed border-green-500/50 bg-green-500/10" + : "border-green-500 bg-green-500/20 hover:bg-green-500" + )} > )}
diff --git a/apps/web/core/components/comments/card/root.tsx b/apps/web/core/components/comments/card/root.tsx index a2c8b3418d..4f1f0342cf 100644 --- a/apps/web/core/components/comments/card/root.tsx +++ b/apps/web/core/components/comments/card/root.tsx @@ -57,15 +57,13 @@ export const CommentCard = observer(function CommentCard(props: TCommentCard) { workspaceSlug={workspaceSlug} isEditing={isEditing} setIsEditing={setIsEditing} - renderQuickActions={(handleReply) => ( + renderQuickActions={() => ( setIsEditing(true)} showAccessSpecifier={showAccessSpecifier} showCopyLinkOption={showCopyLinkOption} - showReplyOption={enableReplies} - handleReply={handleReply} /> )} enableReplies={enableReplies} diff --git a/apps/web/core/components/comments/quick-actions.tsx b/apps/web/core/components/comments/quick-actions.tsx index 0d418e3a7b..95861fa610 100644 --- a/apps/web/core/components/comments/quick-actions.tsx +++ b/apps/web/core/components/comments/quick-actions.tsx @@ -16,20 +16,10 @@ type TCommentCard = { setEditMode: () => void; showAccessSpecifier: boolean; showCopyLinkOption: boolean; - showReplyOption: boolean; - handleReply?: () => void; }; export const CommentQuickActions: FC = observer((props) => { - const { - activityOperations, - comment, - setEditMode, - showAccessSpecifier, - showCopyLinkOption, - showReplyOption, - handleReply, - } = props; + const { activityOperations, comment, setEditMode, showAccessSpecifier, showCopyLinkOption } = props; // store hooks const { data: currentUser } = useUser(); // derived values @@ -54,10 +44,10 @@ export const CommentQuickActions: FC = observer((props) => { : EIssueCommentAccessSpecifier.INTERNAL, }), handleDelete: () => activityOperations.removeComment(comment.id), - handleReply, - showReplyOption, }); + if(MENU_ITEMS.length === 0) return null; + return ( {MENU_ITEMS.map((item) => { diff --git a/apps/web/core/components/common/quick-actions-helper.tsx b/apps/web/core/components/common/quick-actions-helper.tsx index 6bd1484aed..992310e9e8 100644 --- a/apps/web/core/components/common/quick-actions-helper.tsx +++ b/apps/web/core/components/common/quick-actions-helper.tsx @@ -239,12 +239,10 @@ interface UseCommentMenuItemsProps { isAuthor: boolean; showAccessSpecifier: boolean; showCopyLinkOption: boolean; - showReplyOption: boolean; handleEdit: () => void; handleCopyLink: () => void; handleToggleAccess: () => void; handleDelete: () => void; - handleReply?: () => void; } export const useCommentMenuItems = (props: UseCommentMenuItemsProps): TContextMenuItem[] => { @@ -254,27 +252,18 @@ export const useCommentMenuItems = (props: UseCommentMenuItemsProps): TContextMe isAuthor, showAccessSpecifier, showCopyLinkOption, - showReplyOption, handleEdit, handleCopyLink, handleToggleAccess, handleDelete, - handleReply, } = props; - const replyFeature = factory.useCommentReplyFeature?.({ - commentId: comment.id, - handleReply, - shouldRender: showReplyOption, - }); - // Check if access is INTERNAL (0 or "INTERNAL") const isInternal = comment.access === 0 || comment.access === "INTERNAL" || comment.access === "0"; // Assemble final menu items - order defined here const items = [ factory.createCommentEditMenuItem(handleEdit, isAuthor), - ...(replyFeature?.items ?? []), factory.createCommentCopyLinkMenuItem(handleCopyLink, showCopyLinkOption), factory.createCommentAccessSpecifierMenuItem(handleToggleAccess, isInternal, showAccessSpecifier), factory.createCommentDeleteMenuItem(handleDelete, isAuthor), diff --git a/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/helpers/activity-block.tsx b/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/helpers/activity-block.tsx index b37119dd0b..c4adaf463b 100644 --- a/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/helpers/activity-block.tsx +++ b/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/helpers/activity-block.tsx @@ -56,7 +56,7 @@ export const IssueActivityBlockComponent = observer(function IssueActivityBlockC }`} ref={activityBlockRef} > -
+
void) => ReactNode; + renderQuickActions: () => ReactNode; enableReplies: boolean; isReply?: boolean; }; @@ -38,6 +41,8 @@ export const CommentCardDisplay = observer(function CommentCardDisplay(props: Pr // refs const repliesRootRef = useRef(null); const editorRef = useRef(null); + // hooks + const { t } = useTranslation(); // store hooks const { getUserDetails } = useMember(); // derived values @@ -48,27 +53,27 @@ export const CommentCardDisplay = observer(function CommentCardDisplay(props: Pr const avatarUrl = userDetails?.avatar_url ?? comment?.actor_detail?.avatar_url; const handleReply = useCallback(() => { - editorRef.current?.setFocusAtPosition(0); repliesRootRef.current?.showReplyEditor(); }, []); const areRepliesAvailable = comment.reply_count !== undefined && comment.reply_count > 0; const shouldShowIndicator = isReply || areRepliesAvailable; + const shouldShowReplyButton = enableReplies && !isReply && !disabled; return ( <> -
+
{shouldShowIndicator && (
)} -
+
{displayName}
- commented{" "} + {isReply ? "replied " : "commented "}
- {!disabled &&
{renderQuickActions(handleReply)}
} + {!disabled &&
{renderQuickActions()}
}
{/* Core: Comment content */} -
+
( +
+ {shouldShowReplyButton && ( + <> + + + + )} +
{ReactionsComponent}
+
+ )} />
@@ -102,12 +125,12 @@ export const CommentCardDisplay = observer(function CommentCardDisplay(props: Pr workspaceSlug={workspaceSlug} projectId={projectId} entityId={entityId} + activityOperations={activityOperations} commentId={comment.id} repliesCount={comment.reply_count || 0} repliedUserIds={comment.replied_user_ids || []} lastReplyAt={comment.last_reply_at || null} showAccessSpecifier={showAccessSpecifier} - activityOperations={activityOperations} /> )} diff --git a/apps/web/ee/components/comments/comment-block.tsx b/apps/web/ee/components/comments/comment-block.tsx index c50c9dbaa5..bf6a95128f 100644 --- a/apps/web/ee/components/comments/comment-block.tsx +++ b/apps/web/ee/components/comments/comment-block.tsx @@ -41,7 +41,7 @@ export const CommentBlock: FC = observer((props) => { ref={commentBlockRef} >
{ + try { + if (!workspaceSlug || !projectId || !issueId || !replyId) throw new Error("Missing fields"); + await repliesStore.updateReply(workspaceSlug, projectId, issueId, replyId, data); + setToast({ + title: t("common.success"), + type: TOAST_TYPE.SUCCESS, + message: t("issue.comments.replies.toast.update.success.message"), + }); + } catch (error) { + console.error("Error updating reply:", error); + setToast({ + title: t("common.error.label"), + type: TOAST_TYPE.ERROR, + message: t("issue.comments.replies.toast.update.error.message"), + }); + } + }, deleteReply: async (replyId) => { try { if (!workspaceSlug || !projectId || !issueId || !replyId) throw new Error("Missing fields"); diff --git a/apps/web/ee/components/comments/replies/reply-card.tsx b/apps/web/ee/components/comments/replies/reply-card.tsx index 4d95d651c3..b20039efe2 100644 --- a/apps/web/ee/components/comments/replies/reply-card.tsx +++ b/apps/web/ee/components/comments/replies/reply-card.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { observer } from "mobx-react"; // plane imports import type { EditorRefApi } from "@plane/editor"; @@ -30,11 +30,25 @@ export const ReplyCard = observer(function ReplyCard(props: Props) { // derived values const reply = getReply(); + // Create wrapper operations that route updateComment to updateReply for replies + const replyActivityOperations: TCommentsOperations = useMemo( + () => ({ + ...activityOperations, + updateComment: async (commentId: string, data: Partial) => { + // Route to reply update operation + if (activityOperations.replyOperations?.updateReply) { + await activityOperations.replyOperations.updateReply(commentId, data); + } + }, + }), + [activityOperations] + ); + if (!reply) return null; return (

"} containerClassName="min-h-min" onChange={(comment_json, comment_html) => onChange(comment_html)} @@ -130,7 +131,7 @@ export const ReplyCreate = observer(function ReplyCreate(props: Props) { return asset_id; }} showToolbarInitially={false} - parentClassName="p-2" + parentClassName="p-2 bg-custom-background-100" placeholder={t("issue.comments.replies.create.placeholder")} displayConfig={{ fontSize: "small-font", diff --git a/apps/web/ee/components/comments/replies/root.tsx b/apps/web/ee/components/comments/replies/root.tsx index 717066baf0..0faef4bae4 100644 --- a/apps/web/ee/components/comments/replies/root.tsx +++ b/apps/web/ee/components/comments/replies/root.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useImperativeHandle, forwardRef, useRef } from "react"; +import { useEffect, useState, useImperativeHandle, forwardRef } from "react"; import { uniq } from "lodash-es"; import { observer } from "mobx-react"; // plane imports @@ -46,29 +46,17 @@ export const CommentRepliesRoot = observer( } = props; // states const [isExpanded, setIsExpanded] = useState(false); - // refs - const previousRepliesCountRef = useRef(repliesCount); // store hooks const { getUserDetails } = useMember(); // Expose method to show editor - expands and shows editor useImperativeHandle(ref, () => ({ showReplyEditor: () => { - setIsExpanded(true); + if (!isExpanded) { + setIsExpanded(true); + } + editorRef.current?.focus("end"); }, })); - // Auto-expand only when a new reply is added (repliesCount increases) - useEffect(() => { - const previousCount = previousRepliesCountRef.current; - const currentCount = repliesCount; - - // Only auto-expand if replies count increased (new reply added) and not already expanded - if (currentCount > previousCount && currentCount > 0 && !isExpanded) { - setIsExpanded(true); - } - - // Update ref for next render - previousRepliesCountRef.current = currentCount; - }, [repliesCount, isExpanded]); // Fetch replies when expanded and has replies useEffect(() => { if (isExpanded && repliesCount > 0) { @@ -79,53 +67,63 @@ export const CommentRepliesRoot = observer( }, [isExpanded, repliesCount, commentId, activityOperations.replyOperations]); return ( -
- {repliesCount > 0 && !isExpanded && ( - )} - - {repliesCount} {repliesCount === 1 ? "reply" : "replies"} - -
- {lastReplyAt && ( - <> - Last reply - {calculateTimeAgo(lastReplyAt)} - - )} -
- - )} - - {isExpanded && repliesCount > 0 && ( - +
+
+ +
+
+ )} {
= (pr }`} ref={activityBlockRef} > -
+
= observer((props)
-
+
{currentUser?.member?.avatar_url && currentUser?.member?.avatar_url !== "" ? ( ) => Promise; + updateReply: ( + workspaceSlug: string, + projectId: string, + issueId: string, + replyId: string, + data: Partial + ) => Promise; deleteReply: (workspaceSlug: string, projectId: string, issueId: string, replyId: string) => Promise; } @@ -42,10 +49,10 @@ export class RepliesStore implements IRepliesStore { makeObservable(this, { // observables repliesMap: observable, - // actions fetchReplies: action, createReply: action, + updateReply: action, deleteReply: action, }); @@ -59,10 +66,17 @@ export class RepliesStore implements IRepliesStore { const commentMap = this.repliesMap.get(commentId); if (!commentMap) return undefined; - return Array.from(commentMap.keys()); + // order ids by created_at + const orderedIds = Array.from(commentMap.keys()).sort((a, b) => { + const aCreatedAt = commentMap.get(a)?.created_at; + const bCreatedAt = commentMap.get(b)?.created_at; + if (!aCreatedAt || !bCreatedAt) return 0; + return new Date(aCreatedAt).getTime() - new Date(bCreatedAt).getTime(); + }); + + return orderedIds; }); - // actions fetchReplies = async ( workspaceSlug: string, projectId: string, @@ -151,6 +165,7 @@ export class RepliesStore implements IRepliesStore { const oldValues = instance.asJSON; + // Optimistic update with the data being sent runInAction(() => { instance.update(data); }); @@ -164,6 +179,13 @@ export class RepliesStore implements IRepliesStore { data ); + runInAction(() => { + instance.update({ + updated_at: updatedReply.updated_at, + edited_at: updatedReply.edited_at, + }); + }); + return updatedReply; } catch (error) { runInAction(() => { diff --git a/packages/propel/package.json b/packages/propel/package.json index 1d8ddd4e4b..b83168bd96 100644 --- a/packages/propel/package.json +++ b/packages/propel/package.json @@ -47,6 +47,7 @@ "./popover": "./dist/popover/index.js", "./portal": "./dist/portal/index.js", "./scrollarea": "./dist/scrollarea/index.js", + "./separator": "./dist/separator/index.js", "./skeleton": "./dist/skeleton/index.js", "./switch": "./dist/switch/index.js", "./tab-navigation": "./dist/tab-navigation/index.js", diff --git a/packages/propel/src/domain/work-items/helpers.tsx b/packages/propel/src/domain/work-items/helpers.tsx index 0eb8b5c7b3..571b667ac1 100644 --- a/packages/propel/src/domain/work-items/helpers.tsx +++ b/packages/propel/src/domain/work-items/helpers.tsx @@ -1,4 +1,4 @@ -import type {FC} from "react"; +import type { FC } from "react"; import { AudioFileIcon, CodeFileIcon, DocumentFileIcon, ImageFileIcon, VideoFileIcon } from "../../icons/attachments"; const extensionToIconMap: Record> = { diff --git a/packages/propel/src/icons/properties/comment-reply-icon.tsx b/packages/propel/src/icons/properties/comment-reply-icon.tsx index d0057320a6..3ea79ac39d 100644 --- a/packages/propel/src/icons/properties/comment-reply-icon.tsx +++ b/packages/propel/src/icons/properties/comment-reply-icon.tsx @@ -6,7 +6,7 @@ import type { ISvgIcons } from "../type"; export const CommentReplyIcon: React.FC = ({ color = "currentColor", ...rest }) => ( diff --git a/packages/propel/src/separator/index.ts b/packages/propel/src/separator/index.ts new file mode 100644 index 0000000000..8cb65e02a5 --- /dev/null +++ b/packages/propel/src/separator/index.ts @@ -0,0 +1 @@ +export * from "./separator"; diff --git a/packages/propel/src/separator/separator.tsx b/packages/propel/src/separator/separator.tsx index 08791679e8..ca21925663 100644 --- a/packages/propel/src/separator/separator.tsx +++ b/packages/propel/src/separator/separator.tsx @@ -11,7 +11,7 @@ interface SeparatorProps extends React.ComponentProps } const Separator = React.forwardRef(function Separator( - { orientation = "horizontal", ...props }: SeparatorProps, + { orientation = "horizontal", className, ...props }: SeparatorProps, ref: React.ForwardedRef> ) { return ( @@ -21,7 +21,12 @@ const Separator = React.forwardRef(function Separator( data-slot="separator" data-orientation={orientation} {...props} - className={cn("bg-custom-border-200", "shrink-0", orientation === "horizontal" ? "h-px w-full" : "h-full w-px")} + className={cn( + "bg-custom-border-200", + "shrink-0", + orientation === "horizontal" ? "h-px w-full" : "h-full w-px", + className + )} /> ); }); diff --git a/packages/propel/tsdown.config.ts b/packages/propel/tsdown.config.ts index b0f5273e27..2d2ca8376b 100644 --- a/packages/propel/tsdown.config.ts +++ b/packages/propel/tsdown.config.ts @@ -26,6 +26,7 @@ export default defineConfig({ "src/popover/index.ts", "src/portal/index.ts", "src/scrollarea/index.ts", + "src/separator/index.ts", "src/skeleton/index.ts", "src/switch/index.ts", "src/table/index.ts", diff --git a/packages/types/src/issues/activity/issue_comment_extended.ts b/packages/types/src/issues/activity/issue_comment_extended.ts index abb10b744c..296ee31205 100644 --- a/packages/types/src/issues/activity/issue_comment_extended.ts +++ b/packages/types/src/issues/activity/issue_comment_extended.ts @@ -4,5 +4,6 @@ import type { TIssueComment } from "./issue_comment"; export type TCommentReplyOperations = { fetchReplies: (commentId: string) => Promise; createReply: (commentId: string, data: Partial) => Promise; + updateReply: (replyId: string, data: Partial) => Promise; deleteReply: (replyId: string) => Promise; };