Skip to content
Merged
8 changes: 7 additions & 1 deletion apiserver/plane/app/views/issue/comment.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,13 @@ def partial_update(self, request, slug, project_id, issue_id, pk):
issue_comment, data=request.data, partial=True
)
if serializer.is_valid():
serializer.save()
if (
"comment_html" in request.data
and request.data["comment_html"] != issue_comment.comment_html
):
serializer.save(edited_at=timezone.now())
else:
serializer.save()
issue_activity.delay(
type="comment.activity.updated",
requested_data=requested_data,
Expand Down
26 changes: 26 additions & 0 deletions packages/types/src/issues/activity/issue_comment.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,15 @@ import {
TIssueActivityUserDetail,
} from "./base";
import { EIssueCommentAccessSpecifier } from "../../enums";
import { TFileSignedURLResponse } from "../../file";
import { IUserLite } from "../../users";

export type TCommentReaction = {
id: string;
reaction: string;
actor: string;
actor_detail: IUserLite;
};
export type TIssueComment = {
id: string;
workspace: string;
Expand All @@ -17,6 +25,7 @@ export type TIssueComment = {
actor: string;
actor_detail: TIssueActivityUserDetail;
created_at: string;
edited_at?: string | undefined;
updated_at: string;
created_by: string | undefined;
updated_by: string | undefined;
Expand All @@ -30,6 +39,23 @@ export type TIssueComment = {
access: EIssueCommentAccessSpecifier;
};

export type TCommentsOperations = {
createComment: (data: Partial<TIssueComment>) => Promise<void>;
updateComment: (commentId: string, data: Partial<TIssueComment>) => Promise<void>;
removeComment: (commentId: string) => Promise<void>;
uploadCommentAsset: (blockId: string, file: File, commentId?: string) => Promise<TFileSignedURLResponse>;
addCommentReaction: (commentId: string, reactionEmoji: string) => Promise<void>;
deleteCommentReaction: (commentId: string, reactionEmoji: string, userReactions: TCommentReaction[]) => Promise<void>;
react: (commentId: string, reactionEmoji: string, userReactions: string[]) => Promise<void>;
reactionIds: (commentId: string) =>
| {
[reaction: string]: string[];
}
| undefined;
userReactions: (commentId: string) => string[] | undefined;
getReactionUsers: (reaction: string, reactionIds: Record<string, string[]>) => string;
};

export type TIssueCommentMap = {
[issue_id: string]: TIssueComment;
};
Expand Down
74 changes: 74 additions & 0 deletions web/ce/components/comments/comment-block.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { FC, ReactNode, useRef } from "react";
import { observer } from "mobx-react";
// plane imports
import { TIssueComment } from "@plane/types";
import { Avatar, Tooltip } from "@plane/ui";
import { calculateTimeAgo, cn, getFileURL, renderFormattedDate } from "@plane/utils";
// hooks
import { renderFormattedTime } from "@/helpers/date-time.helper";
import { useMember } from "@/hooks/store";

type TCommentBlock = {
comment: TIssueComment;
ends: "top" | "bottom" | undefined;
quickActions: ReactNode;
children: ReactNode;
};

export const CommentBlock: FC<TCommentBlock> = observer((props) => {
const { comment, ends, quickActions, children } = props;
const commentBlockRef = useRef<HTMLDivElement>(null);
// store hooks
const { getUserDetails } = useMember();
const userDetails = getUserDetails(comment?.actor);

if (!comment || !userDetails) return <></>;
return (
<div
className={`relative flex gap-3 ${ends === "top" ? `pb-2` : ends === "bottom" ? `pt-2` : `py-2`}`}
ref={commentBlockRef}
>
<div
className="absolute left-[13px] top-0 bottom-0 w-0.5 transition-border duration-1000 bg-custom-background-80"
aria-hidden
/>
<div
className={cn(
"flex-shrink-0 relative w-7 h-7 rounded-full transition-border duration-1000 flex justify-center items-center z-[3] bg-gray-500 text-white border border-white uppercase font-medium"
)}
>
<Avatar
size="base"
name={userDetails?.display_name}
src={getFileURL(userDetails?.avatar_url)}
className="flex-shrink-0"
/>
</div>
<div className="flex flex-col gap-3 truncate flex-grow">
<div className="flex w-full gap-2">
<div className="flex-1 flex flex-wrap items-center gap-1">
<div className="text-xs font-medium">
{comment?.actor_detail?.is_bot
? comment?.actor_detail?.first_name + " Bot"
: comment?.actor_detail?.display_name || userDetails.display_name}
</div>
<div className="text-xs text-custom-text-300">
commented{" "}
<Tooltip
tooltipContent={`${renderFormattedDate(comment.created_at)} at ${renderFormattedTime(comment.created_at)}`}
position="bottom"
>
<span className="text-custom-text-350">
{calculateTimeAgo(comment.updated_at)}
{comment.edited_at && " (edited)"}
</span>
</Tooltip>
</div>
</div>
<div className="flex-shrink-0 ">{quickActions}</div>
</div>
<div className="text-base mb-2">{children}</div>
</div>
</div>
);
});
1 change: 1 addition & 0 deletions web/ce/components/comments/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./comment-block";
Original file line number Diff line number Diff line change
Expand Up @@ -4,66 +4,50 @@ import { FC, useEffect, useRef, useState } from "react";
import { observer } from "mobx-react";
import { useForm } from "react-hook-form";
import { Check, Globe2, Lock, Pencil, Trash2, X } from "lucide-react";
// plane constants
// PLane
import { EIssueCommentAccessSpecifier } from "@plane/constants";
// plane editor
import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor";
// plane i18n
import { useTranslation } from "@plane/i18n";
// plane types
import { TIssueComment } from "@plane/types";
// plane ui
import { TIssueComment, TCommentsOperations } from "@plane/types";
import { CustomMenu } from "@plane/ui";
// plane utils
import { cn } from "@plane/utils";
// components
import { LiteTextEditor, LiteTextReadOnlyEditor } from "@/components/editor";
// helpers
import { isCommentEmpty } from "@/helpers/string.helper";
// hooks
import { useIssueDetail, useUser, useWorkspace } from "@/hooks/store";
// components
import { IssueCommentReaction } from "../../reactions/issue-comment";
import { TActivityOperations } from "../root";
import { IssueCommentBlock } from "./comment-block";
import { useUser } from "@/hooks/store";
//
import { CommentBlock } from "@/plane-web/components/comments";
import { CommentReactions } from "./comment-reaction";

type TIssueCommentCard = {
projectId: string;
issueId: string;
type TCommentCard = {
workspaceSlug: string;
commentId: string;
activityOperations: TActivityOperations;
comment: TIssueComment | undefined;
activityOperations: TCommentsOperations;
ends: "top" | "bottom" | undefined;
showAccessSpecifier?: boolean;
disabled?: boolean;
projectId?: string;
};

export const IssueCommentCard: FC<TIssueCommentCard> = observer((props) => {
export const CommentCard: FC<TCommentCard> = observer((props) => {
const {
workspaceSlug,
projectId,
issueId,
commentId,
comment,
activityOperations,
ends,
showAccessSpecifier = false,
disabled = false,
projectId,
} = props;
const { t } = useTranslation();
// states
const [isEditing, setIsEditing] = useState(false);
// refs
const editorRef = useRef<EditorRefApi>(null);
const showEditorRef = useRef<EditorReadOnlyRefApi>(null);
// state
const [isEditing, setIsEditing] = useState(false);
// store hooks
const {
comment: { getCommentById },
} = useIssueDetail();
const { data: currentUser } = useUser();
// derived values
const comment = getCommentById(commentId);
const workspaceStore = useWorkspace();
const workspaceId = workspaceStore.getWorkspaceBySlug(comment?.workspace_detail?.slug as string)?.id as string;
// form info
const {
formState: { isSubmitting },
Expand All @@ -75,13 +59,16 @@ export const IssueCommentCard: FC<TIssueCommentCard> = observer((props) => {
defaultValues: { comment_html: comment?.comment_html },
});
// derived values
const workspaceId = comment?.workspace;
const commentHTML = watch("comment_html");
const isEmpty = isCommentEmpty(commentHTML);
const isEmpty = isCommentEmpty(commentHTML ?? undefined);
const isEditorReadyToDiscard = editorRef.current?.isEditorReadyToDiscard();
const isSubmitButtonDisabled = isSubmitting || !isEditorReadyToDiscard;

// helpers
const onEnter = async (formData: Partial<TIssueComment>) => {
if (isSubmitting || !comment) return;

setIsEditing(false);

await activityOperations.updateComment(comment.id, formData);
Expand All @@ -96,11 +83,11 @@ export const IssueCommentCard: FC<TIssueCommentCard> = observer((props) => {
}
}, [isEditing, setFocus]);

if (!comment || !currentUser) return <></>;
if (!comment || !currentUser || !workspaceId) return <></>;

return (
<IssueCommentBlock
commentId={commentId}
<CommentBlock
comment={comment}
quickActions={
<>
{!disabled && currentUser?.id === comment.actor && (
Expand Down Expand Up @@ -156,8 +143,6 @@ export const IssueCommentCard: FC<TIssueCommentCard> = observer((props) => {
>
<LiteTextEditor
workspaceId={workspaceId}
projectId={projectId}
issue_id={issueId}
workspaceSlug={workspaceSlug}
ref={editorRef}
id={comment.id}
Expand All @@ -174,22 +159,22 @@ export const IssueCommentCard: FC<TIssueCommentCard> = observer((props) => {
const { asset_id } = await activityOperations.uploadCommentAsset(blockId, file, comment.id);
return asset_id;
}}
projectId={(projectId as string) ?? ""}
/>
</div>
<div className="flex gap-1 self-end">
{!isEmpty && (
<button
type="button"
onClick={handleSubmit(onEnter)}
disabled={isSubmitButtonDisabled}
className={cn(
"group rounded border border-green-500 text-green-500 hover:text-white bg-green-500/20 hover:bg-green-500 p-2 shadow-md duration-300",
{
"pointer-events-none": isSubmitButtonDisabled,
}
)}
disabled={isSubmitting || isEmpty || isSubmitButtonDisabled}
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"
}`}
>
<Check className="size-3" />
<Check
className={`h-3 w-3 text-green-500 duration-300 ${isEmpty ? "text-black" : "group-hover:text-white"}`}
/>
</button>
)}
<button
Expand All @@ -201,7 +186,7 @@ export const IssueCommentCard: FC<TIssueCommentCard> = observer((props) => {
</button>
</div>
</form>
<div className={`relative ${isEditing ? "hidden" : ""}`}>
<div className={`relative flex flex-col gap-2 ${isEditing ? "hidden" : ""}`}>
{showAccessSpecifier && (
<div className="absolute right-2.5 top-2.5 z-[1] text-custom-text-300">
{comment.access === EIssueCommentAccessSpecifier.INTERNAL ? (
Expand All @@ -217,18 +202,13 @@ export const IssueCommentCard: FC<TIssueCommentCard> = observer((props) => {
initialValue={comment.comment_html ?? ""}
workspaceId={workspaceId}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>

<IssueCommentReaction
workspaceSlug={workspaceSlug}
projectId={comment?.project_detail?.id}
commentId={comment.id}
currentUser={currentUser}
disabled={disabled}
editorClassName="[&>*]:!py-0 [&>*]:!text-sm"
containerClassName="!py-1"
projectId={(projectId as string) ?? ""}
/>
<CommentReactions comment={comment} disabled={disabled} activityOperations={activityOperations} />
</div>
</>
</IssueCommentBlock>
</CommentBlock>
);
});
Loading