diff --git a/apiserver/plane/app/views/issue/comment.py b/apiserver/plane/app/views/issue/comment.py index 91d27bff29e..f257683feb1 100644 --- a/apiserver/plane/app/views/issue/comment.py +++ b/apiserver/plane/app/views/issue/comment.py @@ -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, diff --git a/packages/i18n/src/locales/en/translations.json b/packages/i18n/src/locales/en/translations.json index 6beb2917b5f..de2acb8e4c6 100644 --- a/packages/i18n/src/locales/en/translations.json +++ b/packages/i18n/src/locales/en/translations.json @@ -331,6 +331,8 @@ "re_generate_key": "Re-generate key", "export": "Export", "member": "{count, plural, one{# member} other{# members}}", + "edited": "edited", + "bot": "Bot", "project_view": { "sort_by": { diff --git a/packages/i18n/src/locales/es/translations.json b/packages/i18n/src/locales/es/translations.json index c349364d4bf..72f88fdf282 100644 --- a/packages/i18n/src/locales/es/translations.json +++ b/packages/i18n/src/locales/es/translations.json @@ -503,6 +503,8 @@ "re_generate_key": "Regenerar clave", "export": "Exportar", "member": "{count, plural, one{# miembro} other{# miembros}}", + "edited": "Modificado", + "bot": "Bot", "project_view": { "sort_by": { diff --git a/packages/i18n/src/locales/fr/translations.json b/packages/i18n/src/locales/fr/translations.json index 651622b9714..03be496340e 100644 --- a/packages/i18n/src/locales/fr/translations.json +++ b/packages/i18n/src/locales/fr/translations.json @@ -501,6 +501,8 @@ "re_generate_key": "Régénérer la clé", "export": "Exporter", "member": "{count, plural, one{# membre} other{# membres}}", + "edited": "Modifié", + "bot": "Bot", "project_view": { "sort_by": { diff --git a/packages/i18n/src/locales/it/translations.json b/packages/i18n/src/locales/it/translations.json index ba40f5b9bd1..00994c5a331 100644 --- a/packages/i18n/src/locales/it/translations.json +++ b/packages/i18n/src/locales/it/translations.json @@ -500,6 +500,8 @@ "re_generate_key": "Rigenera chiave", "export": "Esporta", "member": "{count, plural, one {# membro} other {# membri}}", + "edited": "Modificato", + "bot": "Bot", "project_view": { "sort_by": { diff --git a/packages/i18n/src/locales/ja/translations.json b/packages/i18n/src/locales/ja/translations.json index 8f3c82e0596..ad3d25fabc5 100644 --- a/packages/i18n/src/locales/ja/translations.json +++ b/packages/i18n/src/locales/ja/translations.json @@ -501,6 +501,8 @@ "re_generate_key": "キーを再生成", "export": "エクスポート", "member": "{count, plural, other{# メンバー}}", + "edited": "編集済み", + "bot": "ボット", "project_view": { "sort_by": { diff --git a/packages/i18n/src/locales/ko/translations.json b/packages/i18n/src/locales/ko/translations.json index b3b0a29d405..e8622338383 100644 --- a/packages/i18n/src/locales/ko/translations.json +++ b/packages/i18n/src/locales/ko/translations.json @@ -501,6 +501,8 @@ "re_generate_key": "키 다시 생성", "export": "내보내기", "member": "{count, plural, one{# 멤버} other{# 멤버}}", + "edited": "수정됨", + "bot": "봇", "project_view": { "sort_by": { diff --git a/packages/i18n/src/locales/pl/translations.json b/packages/i18n/src/locales/pl/translations.json index 3cee6585357..b861d03d52b 100644 --- a/packages/i18n/src/locales/pl/translations.json +++ b/packages/i18n/src/locales/pl/translations.json @@ -499,6 +499,9 @@ "re_generate_key": "Wygeneruj klucz ponownie", "export": "Eksportuj", "member": "{count, plural, one{# członek} few{# członkowie} other{# członków}}", + "edited": "Edytowano", + "bot": "Bot", + "project_view": { "sort_by": { "created_at": "Utworzono dnia", diff --git a/packages/i18n/src/locales/ru/translations.json b/packages/i18n/src/locales/ru/translations.json index d753b30bac1..664c9c42043 100644 --- a/packages/i18n/src/locales/ru/translations.json +++ b/packages/i18n/src/locales/ru/translations.json @@ -499,6 +499,8 @@ "re_generate_key": "Перегенерировать ключ", "export": "Экспорт", "member": "{count, plural, one{# участник} few{# участника} other{# участников}}", + "edited": "Редактировано", + "bot": "Бот", "project_view": { "sort_by": { diff --git a/packages/i18n/src/locales/sk/translations.json b/packages/i18n/src/locales/sk/translations.json index 30a1a4db835..ff86734bd00 100644 --- a/packages/i18n/src/locales/sk/translations.json +++ b/packages/i18n/src/locales/sk/translations.json @@ -499,6 +499,8 @@ "re_generate_key": "Znova generovať kľúč", "export": "Exportovať", "member": "{count, plural, one{# člen} few{# členovia} other{# členov}}", + "edited": "Upravené", + "bot": "Bot", "project_view": { "sort_by": { diff --git a/packages/i18n/src/locales/ua/translations.json b/packages/i18n/src/locales/ua/translations.json index ace8349fff3..389fe3d660d 100644 --- a/packages/i18n/src/locales/ua/translations.json +++ b/packages/i18n/src/locales/ua/translations.json @@ -499,6 +499,9 @@ "re_generate_key": "Повторно згенерувати ключ", "export": "Експортувати", "member": "{count, plural, one{# учасник} few{# учасники} other{# учасників}}", + "edited": "Редагувано", + "bot": "Бот", + "project_view": { "sort_by": { "created_at": "Створено", diff --git a/packages/i18n/src/locales/zh-CN/translations.json b/packages/i18n/src/locales/zh-CN/translations.json index 5e2725127b5..48774ad038a 100644 --- a/packages/i18n/src/locales/zh-CN/translations.json +++ b/packages/i18n/src/locales/zh-CN/translations.json @@ -501,6 +501,8 @@ "re_generate_key": "重新生成密钥", "export": "导出", "member": "{count, plural, other{# 成员}}", + "edited": "已编辑", + "bot": "机器人", "project_view": { "sort_by": { diff --git a/packages/i18n/src/locales/zh-TW/translations.json b/packages/i18n/src/locales/zh-TW/translations.json index a59d78e4dd1..0deb5f4a884 100644 --- a/packages/i18n/src/locales/zh-TW/translations.json +++ b/packages/i18n/src/locales/zh-TW/translations.json @@ -501,6 +501,8 @@ "re_generate_key": "重新產生金鑰", "export": "匯出", "member": "{count, plural, one{# 位成員} other{# 位成員}}", + "edited": "已編輯", + "bot": "機器人", "project_view": { "sort_by": { diff --git a/packages/types/src/issues/activity/issue_comment.d.ts b/packages/types/src/issues/activity/issue_comment.d.ts index aef5134c674..95be9a7d449 100644 --- a/packages/types/src/issues/activity/issue_comment.d.ts +++ b/packages/types/src/issues/activity/issue_comment.d.ts @@ -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; @@ -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; @@ -30,6 +39,23 @@ export type TIssueComment = { access: EIssueCommentAccessSpecifier; }; +export type TCommentsOperations = { + createComment: (data: Partial) => Promise; + updateComment: (commentId: string, data: Partial) => Promise; + removeComment: (commentId: string) => Promise; + uploadCommentAsset: (blockId: string, file: File, commentId?: string) => Promise; + addCommentReaction: (commentId: string, reactionEmoji: string) => Promise; + deleteCommentReaction: (commentId: string, reactionEmoji: string, userReactions: TCommentReaction[]) => Promise; + react: (commentId: string, reactionEmoji: string, userReactions: string[]) => Promise; + reactionIds: (commentId: string) => + | { + [reaction: string]: string[]; + } + | undefined; + userReactions: (commentId: string) => string[] | undefined; + getReactionUsers: (reaction: string, reactionIds: Record) => string; +}; + export type TIssueCommentMap = { [issue_id: string]: TIssueComment; }; diff --git a/web/ce/components/comments/comment-block.tsx b/web/ce/components/comments/comment-block.tsx new file mode 100644 index 00000000000..72b5b7bf58d --- /dev/null +++ b/web/ce/components/comments/comment-block.tsx @@ -0,0 +1,76 @@ +import { FC, ReactNode, useRef } from "react"; +import { observer } from "mobx-react"; +// plane imports +import { useTranslation } from "@plane/i18n"; +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 = observer((props) => { + const { comment, ends, quickActions, children } = props; + const commentBlockRef = useRef(null); + // store hooks + const { getUserDetails } = useMember(); + const { t } = useTranslation(); + const userDetails = getUserDetails(comment?.actor); + + if (!comment || !userDetails) return <>; + return ( +
+
+
+ +
+
+
+
+
+ {comment?.actor_detail?.is_bot + ? comment?.actor_detail?.first_name + ` ${t("bot")}` + : comment?.actor_detail?.display_name || userDetails.display_name} +
+
+ commented{" "} + + + {calculateTimeAgo(comment.updated_at)} + {comment.edited_at && ` (${t("edited")})`} + + +
+
+
{quickActions}
+
+
{children}
+
+
+ ); +}); diff --git a/web/ce/components/comments/index.ts b/web/ce/components/comments/index.ts new file mode 100644 index 00000000000..f0ef4e2b691 --- /dev/null +++ b/web/ce/components/comments/index.ts @@ -0,0 +1 @@ +export * from "./comment-block"; diff --git a/web/core/components/issues/issue-detail/issue-activity/comments/comment-card.tsx b/web/core/components/comments/comment-card.tsx similarity index 76% rename from web/core/components/issues/issue-detail/issue-activity/comments/comment-card.tsx rename to web/core/components/comments/comment-card.tsx index 96f0960b605..0f52cd66bf4 100644 --- a/web/core/components/issues/issue-detail/issue-activity/comments/comment-card.tsx +++ b/web/core/components/comments/comment-card.tsx @@ -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 = observer((props) => { +export const CommentCard: FC = 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(null); const showEditorRef = useRef(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 }, @@ -75,13 +59,17 @@ export const IssueCommentCard: FC = 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; + const isDisabled = isSubmitting || isEmpty || isSubmitButtonDisabled; + // helpers const onEnter = async (formData: Partial) => { if (isSubmitting || !comment) return; + setIsEditing(false); await activityOperations.updateComment(comment.id, formData); @@ -96,11 +84,11 @@ export const IssueCommentCard: FC = observer((props) => { } }, [isEditing, setFocus]); - if (!comment || !currentUser) return <>; + if (!comment || !currentUser || !workspaceId) return <>; return ( - {!disabled && currentUser?.id === comment.actor && ( @@ -156,8 +144,6 @@ export const IssueCommentCard: FC = observer((props) => { > = observer((props) => { const { asset_id } = await activityOperations.uploadCommentAsset(blockId, file, comment.id); return asset_id; }} + projectId={projectId?.toString() ?? ""} />
@@ -181,15 +168,14 @@ export const IssueCommentCard: FC = observer((props) => { )}
-
+
{showAccessSpecifier && (
{comment.access === EIssueCommentAccessSpecifier.INTERNAL ? ( @@ -217,18 +203,13 @@ export const IssueCommentCard: FC = observer((props) => { initialValue={comment.comment_html ?? ""} workspaceId={workspaceId} workspaceSlug={workspaceSlug} - projectId={projectId} - /> - - +
- + ); }); diff --git a/web/core/components/issues/issue-detail/issue-activity/comments/comment-create.tsx b/web/core/components/comments/comment-create.tsx similarity index 71% rename from web/core/components/issues/issue-detail/issue-activity/comments/comment-create.tsx rename to web/core/components/comments/comment-create.tsx index 6abd2d6e4e9..f82b11e802b 100644 --- a/web/core/components/issues/issue-detail/issue-activity/comments/comment-create.tsx +++ b/web/core/components/comments/comment-create.tsx @@ -1,40 +1,39 @@ import { FC, useRef, useState } from "react"; +import { observer } from "mobx-react"; import { useForm, Controller } from "react-hook-form"; +// plane constants import { EIssueCommentAccessSpecifier } from "@plane/constants"; // plane editor import { EditorRefApi } from "@plane/editor"; -// types -import { TIssueComment } from "@plane/types"; // components +import { TIssueComment, TCommentsOperations } from "@plane/types"; import { LiteTextEditor } from "@/components/editor"; +// constants // helpers import { cn } from "@/helpers/common.helper"; import { isCommentEmpty } from "@/helpers/string.helper"; // hooks -import { useIssueDetail, useWorkspace } from "@/hooks/store"; -// services +import { useWorkspace } from "@/hooks/store"; import { FileService } from "@/services/file.service"; -const fileService = new FileService(); -// editor -import { TActivityOperations } from "../root"; -type TIssueCommentCreate = { - projectId: string; +type TCommentCreate = { + entityId: string; workspaceSlug: string; - activityOperations: TActivityOperations; - showAccessSpecifier?: boolean; - issueId: string; + activityOperations: TCommentsOperations; + showToolbarInitially?: boolean; + projectId?: string; }; -export const IssueCommentCreate: FC = (props) => { - const { workspaceSlug, projectId, issueId, activityOperations, showAccessSpecifier = false } = props; +// services +const fileService = new FileService(); +export const CommentCreate: FC = observer((props) => { + const { workspaceSlug, entityId, activityOperations, showToolbarInitially = false, projectId } = props; // states const [uploadedAssetIds, setUploadedAssetIds] = useState([]); // refs const editorRef = useRef(null); // store hooks const workspaceStore = useWorkspace(); - const { peekIssue } = useIssueDetail(); // derived values const workspaceId = workspaceStore.getWorkspaceBySlug(workspaceSlug as string)?.id as string; // form info @@ -51,13 +50,19 @@ export const IssueCommentCreate: FC = (props) => { }); const onSubmit = async (formData: Partial) => { - await activityOperations + activityOperations .createComment(formData) - .then(async (res) => { + .then(async () => { if (uploadedAssetIds.length > 0) { - await fileService.updateBulkProjectAssetsUploadStatus(workspaceSlug, projectId, res.id, { - asset_ids: uploadedAssetIds, - }); + if (projectId) { + await fileService.updateBulkProjectAssetsUploadStatus(workspaceSlug, projectId.toString(), entityId, { + asset_ids: uploadedAssetIds, + }); + } else { + await fileService.updateBulkWorkspaceAssetsUploadStatus(workspaceSlug, entityId, { + asset_ids: uploadedAssetIds, + }); + } setUploadedAssetIds([]); } }) @@ -70,13 +75,11 @@ export const IssueCommentCreate: FC = (props) => { }; const commentHTML = watch("comment_html"); - const isEmpty = isCommentEmpty(commentHTML); + const isEmpty = isCommentEmpty(commentHTML ?? undefined); return (
{ if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey && !isEmpty && !isSubmitting) handleSubmit(onSubmit)(e); @@ -92,10 +95,8 @@ export const IssueCommentCreate: FC = (props) => { render={({ field: { value, onChange } }) => (

"} - projectId={projectId} - issue_id={issueId} workspaceSlug={workspaceSlug} onEnterKeyPress={(e) => { if (!isEmpty && !isSubmitting) { @@ -104,17 +105,18 @@ export const IssueCommentCreate: FC = (props) => { }} ref={editorRef} initialValue={value ?? "

"} - containerClassName="min-h-[35px]" + containerClassName="min-h-min [&_p]:!p-0 [&_p]:!text-base" onChange={(comment_json, comment_html) => onChange(comment_html)} accessSpecifier={accessValue ?? EIssueCommentAccessSpecifier.INTERNAL} handleAccessChange={onAccessChange} - showAccessSpecifier={showAccessSpecifier} isSubmitting={isSubmitting} uploadFile={async (blockId, file) => { const { asset_id } = await activityOperations.uploadCommentAsset(blockId, file); setUploadedAssetIds((prev) => [...prev, asset_id]); return asset_id; }} + showToolbarInitially={showToolbarInitially} + parentClassName="p-2" /> )} /> @@ -122,4 +124,4 @@ export const IssueCommentCreate: FC = (props) => { />
); -}; +}); diff --git a/web/core/components/comments/comment-reaction.tsx b/web/core/components/comments/comment-reaction.tsx new file mode 100644 index 00000000000..b101c9402dd --- /dev/null +++ b/web/core/components/comments/comment-reaction.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { FC } from "react"; +import { observer } from "mobx-react"; +// Plane +import { TCommentsOperations, TIssueComment } from "@plane/types"; +import { Tooltip } from "@plane/ui"; +// components +import { ReactionSelector } from "@/components/issues"; +// helpers +import { cn } from "@/helpers/common.helper"; +import { renderEmoji } from "@/helpers/emoji.helper"; + +export type TProps = { + comment: TIssueComment; + disabled?: boolean; + activityOperations: TCommentsOperations; +}; + +export const CommentReactions: FC = observer((props) => { + const { comment, activityOperations, disabled = false } = props; + + const userReactions = activityOperations.userReactions(comment.id); + const reactionIds = activityOperations.reactionIds(comment.id); + + if (!userReactions) return null; + return ( +
+ {!disabled && ( + activityOperations.react(comment.id, reactionEmoji, userReactions)} + /> + )} + + {reactionIds && + Object.keys(reactionIds || {}).map( + (reaction: string) => + reactionIds[reaction]?.length > 0 && ( + <> + + + + + ) + )} +
+ ); +}); diff --git a/web/core/components/comments/comments.tsx b/web/core/components/comments/comments.tsx new file mode 100644 index 00000000000..70fe1a0955d --- /dev/null +++ b/web/core/components/comments/comments.tsx @@ -0,0 +1,63 @@ +"use client"; + +import React, { FC } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane imports +import { TCommentsOperations, TIssueComment } from "@plane/types"; +// local components +import { CommentCard } from "./comment-card"; +import { CommentCreate } from "./comment-create"; + +type TCommentsWrapper = { + projectId?: string; + entityId: string; + isEditingAllowed?: boolean; + activityOperations: TCommentsOperations; + comments: TIssueComment[] | string[]; + getCommentById?: (activityId: string) => TIssueComment | undefined; +}; + +export const CommentsWrapper: FC = observer((props) => { + const { entityId, activityOperations, comments, getCommentById, isEditingAllowed = true, projectId } = props; + // router + const { workspaceSlug: routerWorkspaceSlug } = useParams(); + const workspaceSlug = routerWorkspaceSlug?.toString(); + + return ( +
+ {isEditingAllowed && ( + + )} + +
+ {comments?.map((data, index) => { + let comment; + if (typeof data === "string") { + comment = getCommentById?.(data); + } else { + comment = data; + } + + if (!comment) return null; + return ( + + ); + })} +
+
+ ); +}); diff --git a/web/core/components/comments/index.ts b/web/core/components/comments/index.ts new file mode 100644 index 00000000000..222a6656c00 --- /dev/null +++ b/web/core/components/comments/index.ts @@ -0,0 +1 @@ +export * from "./comments"; diff --git a/web/core/components/editor/lite-text-editor/lite-text-editor.tsx b/web/core/components/editor/lite-text-editor/lite-text-editor.tsx index b06d39c8550..107822e7947 100644 --- a/web/core/components/editor/lite-text-editor/lite-text-editor.tsx +++ b/web/core/components/editor/lite-text-editor/lite-text-editor.tsx @@ -23,15 +23,17 @@ interface LiteTextEditorWrapperProps extends MakeOptional, "disabledExtensions"> { workspaceSlug: string; workspaceId: string; - projectId: string; + projectId?: string; accessSpecifier?: EIssueCommentAccessSpecifier; handleAccessChange?: (accessKey: EIssueCommentAccessSpecifier) => void; showAccessSpecifier?: boolean; showSubmitButton?: boolean; isSubmitting?: boolean; showToolbarInitially?: boolean; + showToolbar?: boolean; uploadFile: TFileHandler["upload"]; issue_id?: string; + parentClassName?: string; } export const LiteTextEditor = React.forwardRef((props, ref) => { @@ -48,6 +50,8 @@ export const LiteTextEditor = React.forwardRef !showToolbarInitially && setIsFocused(true)} onBlur={() => !showToolbarInitially && setIsFocused(false)} > @@ -102,31 +106,33 @@ export const LiteTextEditor = React.forwardRef -
- { - // TODO: update this while toolbar homogenization - // @ts-expect-error type mismatch here - editorRef?.executeMenuItemCommand({ - itemKey: item.itemKey, - ...item.extraProps, - }); - }} - handleAccessChange={handleAccessChange} - handleSubmit={(e) => rest.onEnterKeyPress?.(e)} - isCommentEmpty={isEmpty} - isSubmitting={isSubmitting} - showAccessSpecifier={showAccessSpecifier} - editorRef={editorRef} - showSubmitButton={showSubmitButton} - /> -
+ {showToolbar && ( +
+ { + // TODO: update this while toolbar homogenization + // @ts-expect-error type mismatch here + editorRef?.executeMenuItemCommand({ + itemKey: item.itemKey, + ...item.extraProps, + }); + }} + handleAccessChange={handleAccessChange} + handleSubmit={(e) => rest.onEnterKeyPress?.(e)} + isCommentEmpty={isEmpty} + isSubmitting={isSubmitting} + showAccessSpecifier={showAccessSpecifier} + editorRef={editorRef} + showSubmitButton={showSubmitButton} + /> +
+ )}
); }); diff --git a/web/core/components/editor/lite-text-editor/lite-text-read-only-editor.tsx b/web/core/components/editor/lite-text-editor/lite-text-read-only-editor.tsx index 0763a49f968..1662211dd26 100644 --- a/web/core/components/editor/lite-text-editor/lite-text-read-only-editor.tsx +++ b/web/core/components/editor/lite-text-editor/lite-text-read-only-editor.tsx @@ -17,7 +17,7 @@ type LiteTextReadOnlyEditorWrapperProps = MakeOptional< > & { workspaceId: string; workspaceSlug: string; - projectId: string; + projectId?: string; }; export const LiteTextReadOnlyEditor = React.forwardRef( diff --git a/web/core/components/issues/issue-detail/issue-activity/activity-comment-root.tsx b/web/core/components/issues/issue-detail/issue-activity/activity-comment-root.tsx index daf0115b18b..2e904709814 100644 --- a/web/core/components/issues/issue-detail/issue-activity/activity-comment-root.tsx +++ b/web/core/components/issues/issue-detail/issue-activity/activity-comment-root.tsx @@ -3,22 +3,21 @@ import { observer } from "mobx-react"; // constants import { E_SORT_ORDER, TActivityFilters, filterActivityOnSelectedFilters } from "@plane/constants"; // hooks +import { TCommentsOperations } from "@plane/types"; +import { CommentCard } from "@/components/comments/comment-card"; import { useIssueDetail } from "@/hooks/store"; // plane web components import { IssueAdditionalPropertiesActivity } from "@/plane-web/components/issues"; import { IssueActivityWorklog } from "@/plane-web/components/issues/worklog/activity/root"; // components import { IssueActivityItem } from "./activity/activity-list"; -import { IssueCommentCard } from "./comments/comment-card"; -// types -import { TActivityOperations } from "./root"; type TIssueActivityCommentRoot = { workspaceSlug: string; projectId: string; issueId: string; selectedFilters: TActivityFilters[]; - activityOperations: TActivityOperations; + activityOperations: TCommentsOperations; showAccessSpecifier?: boolean; disabled?: boolean; sortOrder: E_SORT_ORDER; @@ -38,7 +37,7 @@ export const IssueActivityCommentRoot: FC = observer( // hooks const { activity: { getActivityCommentByIssueId }, - comment: {}, + comment: { getCommentById }, } = useIssueDetail(); const activityComments = getActivityCommentByIssueId(issueId, sortOrder); @@ -49,18 +48,18 @@ export const IssueActivityCommentRoot: FC = observer( return (
- {filteredActivityComments.map((activityComment, index) => - activityComment.activity_type === "COMMENT" ? ( - { + const comment = getCommentById(activityComment.id); + return activityComment.activity_type === "COMMENT" ? ( + ) : activityComment.activity_type === "ACTIVITY" ? ( = observer( /> ) : ( <> - ) - )} + ); + })}
); }); diff --git a/web/core/components/issues/issue-detail/issue-activity/activity/actions/helpers/activity-block.tsx b/web/core/components/issues/issue-detail/issue-activity/activity/actions/helpers/activity-block.tsx index b0981d98178..9517c9bf48e 100644 --- a/web/core/components/issues/issue-detail/issue-activity/activity/actions/helpers/activity-block.tsx +++ b/web/core/components/issues/issue-detail/issue-activity/activity/actions/helpers/activity-block.tsx @@ -48,7 +48,7 @@ export const IssueActivityBlockComponent: FC = (pr isMobile={isMobile} tooltipContent={`${renderFormattedDate(activity.created_at)}, ${renderFormattedTime(activity.created_at)}`} > - {calculateTimeAgo(activity.created_at)} + {calculateTimeAgo(activity.created_at)}
diff --git a/web/core/components/issues/issue-detail/issue-activity/comments/comment-block.tsx b/web/core/components/issues/issue-detail/issue-activity/comments/comment-block.tsx deleted file mode 100644 index 9a35ef1da4f..00000000000 --- a/web/core/components/issues/issue-detail/issue-activity/comments/comment-block.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { FC, ReactNode } from "react"; -import { observer } from "mobx-react"; -import { MessageCircle } from "lucide-react"; -// helpers -import { calculateTimeAgo } from "@/helpers/date-time.helper"; -import { getFileURL } from "@/helpers/file.helper"; -// hooks -import { useIssueDetail } from "@/hooks/store"; - -type TIssueCommentBlock = { - commentId: string; - ends: "top" | "bottom" | undefined; - quickActions: ReactNode; - children: ReactNode; -}; - -export const IssueCommentBlock: FC = observer((props) => { - const { commentId, ends, quickActions, children } = props; - // hooks - const { - comment: { getCommentById }, - } = useIssueDetail(); - - const comment = getCommentById(commentId); - - if (!comment) return <>; - return ( -
-
-
- {comment.actor_detail?.avatar_url && comment.actor_detail?.avatar_url !== "" ? ( - { - ) : ( - <> - {comment.actor_detail?.is_bot - ? comment.actor_detail?.first_name.charAt(0) - : comment.actor_detail?.display_name.charAt(0)} - - )} -
- -
-
-
-
-
-
- {comment.actor_detail?.is_bot - ? comment.actor_detail?.first_name + " Bot" - : comment.actor_detail?.display_name} -
-
commented {calculateTimeAgo(comment.created_at)}
-
-
{children}
-
-
{quickActions}
-
-
- ); -}); diff --git a/web/core/components/issues/issue-detail/issue-activity/comments/index.ts b/web/core/components/issues/issue-detail/issue-activity/comments/index.ts deleted file mode 100644 index 8077dd06d22..00000000000 --- a/web/core/components/issues/issue-detail/issue-activity/comments/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from "./comment-block"; -export * from "./comment-card"; -export * from "./comment-create"; -export * from "./root"; diff --git a/web/core/components/issues/issue-detail/issue-activity/comments/root.tsx b/web/core/components/issues/issue-detail/issue-activity/comments/root.tsx deleted file mode 100644 index 8a7e9210845..00000000000 --- a/web/core/components/issues/issue-detail/issue-activity/comments/root.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { FC } from "react"; -import { observer } from "mobx-react"; -// plane imports -import { useTranslation } from "@plane/i18n"; -// components -import { SimpleEmptyState } from "@/components/empty-state"; -// hooks -import { useIssueDetail } from "@/hooks/store"; -import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; -// local components -import { TActivityOperations } from "../root"; -import { IssueCommentCard } from "./comment-card"; - -type TIssueCommentRoot = { - projectId: string; - workspaceSlug: string; - issueId: string; - activityOperations: TActivityOperations; - showAccessSpecifier?: boolean; - disabled?: boolean; -}; - -export const IssueCommentRoot: FC = observer((props) => { - const { workspaceSlug, projectId, issueId, activityOperations, showAccessSpecifier, disabled } = props; - // hooks - const { - comment: { getCommentsByIssueId }, - } = useIssueDetail(); - const { t } = useTranslation(); - // derived values - const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/search/comments" }); - - const commentIds = getCommentsByIssueId(issueId); - if (!commentIds) return <>; - - return ( -
- {commentIds.length > 0 ? ( - commentIds.map((commentId, index) => ( - - )) - ) : ( -
- -
- )} -
- ); -}); diff --git a/web/core/components/issues/issue-detail/issue-activity/helper.tsx b/web/core/components/issues/issue-detail/issue-activity/helper.tsx new file mode 100644 index 00000000000..3ac270daf45 --- /dev/null +++ b/web/core/components/issues/issue-detail/issue-activity/helper.tsx @@ -0,0 +1,158 @@ +import { useMemo } from "react"; +import { useTranslation } from "@plane/i18n"; +import { TCommentsOperations, TIssueComment } from "@plane/types"; +import { EFileAssetType } from "@plane/types/src/enums"; +import { setToast, TOAST_TYPE } from "@plane/ui"; +import { formatTextList } from "@/helpers/issue.helper"; +import { useEditorAsset, useIssueDetail, useMember, useUser } from "@/hooks/store"; + +export const useCommentOperations = ( + workspaceSlug: string | undefined, + projectId: string | undefined, + issueId: string | undefined +): TCommentsOperations => { + // store hooks + const { + commentReaction: { getCommentReactionsByCommentId, commentReactionsByUser, getCommentReactionById }, + createComment, + updateComment, + removeComment, + createCommentReaction, + removeCommentReaction, + } = useIssueDetail(); + const { getUserDetails } = useMember(); + const { uploadEditorAsset } = useEditorAsset(); + const { data: currentUser } = useUser(); + const { t } = useTranslation(); + + const operations = useMemo(() => { + // Define operations object with all methods + const ops = { + createComment: async (data: Partial) => { + try { + if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields"); + const comment = await createComment(workspaceSlug, projectId, issueId, data); + setToast({ + title: t("common.success"), + type: TOAST_TYPE.SUCCESS, + message: t("issue.comments.create.success"), + }); + return comment; + } catch { + setToast({ + title: t("common.error.label"), + type: TOAST_TYPE.ERROR, + message: t("issue.comments.create.error"), + }); + } + }, + updateComment: async (commentId: string, data: Partial) => { + try { + if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields"); + await updateComment(workspaceSlug, projectId, issueId, commentId, data); + setToast({ + title: t("common.success"), + type: TOAST_TYPE.SUCCESS, + message: t("issue.comments.update.success"), + }); + } catch { + setToast({ + title: t("common.error.label"), + type: TOAST_TYPE.ERROR, + message: t("issue.comments.update.error"), + }); + } + }, + removeComment: async (commentId: string) => { + try { + if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields"); + await removeComment(workspaceSlug, projectId, issueId, commentId); + setToast({ + title: t("common.success"), + type: TOAST_TYPE.SUCCESS, + message: t("issue.comments.remove.success"), + }); + } catch { + setToast({ + title: t("common.error.label"), + type: TOAST_TYPE.ERROR, + message: t("issue.comments.remove.error"), + }); + } + }, + uploadCommentAsset: async (blockId: string, file: File, commentId?: string) => { + try { + if (!workspaceSlug || !projectId) throw new Error("Missing fields"); + const res = await uploadEditorAsset({ + blockId, + data: { + entity_identifier: commentId ?? "", + entity_type: EFileAssetType.COMMENT_DESCRIPTION, + }, + file, + projectId, + workspaceSlug, + }); + return res; + } catch (error) { + console.log("Error in uploading comment asset:", error); + throw new Error(t("issue.comments.upload.error")); + } + }, + addCommentReaction: async (commentId: string, reaction: string) => { + try { + if (!workspaceSlug || !projectId || !commentId) throw new Error("Missing fields"); + await createCommentReaction(workspaceSlug, projectId, commentId, reaction); + setToast({ + title: "Success!", + type: TOAST_TYPE.SUCCESS, + message: "Reaction created successfully", + }); + } catch (error) { + setToast({ + title: "Error!", + type: TOAST_TYPE.ERROR, + message: "Reaction creation failed", + }); + } + }, + deleteCommentReaction: async (commentId: string, reaction: string) => { + try { + if (!workspaceSlug || !projectId || !commentId || !currentUser?.id) throw new Error("Missing fields"); + removeCommentReaction(workspaceSlug, projectId, commentId, reaction, currentUser.id); + setToast({ + title: "Success!", + type: TOAST_TYPE.SUCCESS, + message: "Reaction removed successfully", + }); + } catch (error) { + setToast({ + title: "Error!", + type: TOAST_TYPE.ERROR, + message: "Reaction remove failed", + }); + } + }, + react: async (commentId: string, reactionEmoji: string, userReactions: string[]) => { + if (userReactions.includes(reactionEmoji)) await ops.deleteCommentReaction(commentId, reactionEmoji); + else await ops.addCommentReaction(commentId, reactionEmoji); + }, + reactionIds: (commentId: string) => getCommentReactionsByCommentId(commentId), + userReactions: (commentId: string) => + currentUser ? commentReactionsByUser(commentId, currentUser?.id).map((r) => r.reaction) : [], + getReactionUsers: (reaction: string, reactionIds: Record): string => { + const reactionUsers = (reactionIds?.[reaction] || []) + .map((reactionId) => { + const reactionDetails = getCommentReactionById(reactionId); + return reactionDetails ? getUserDetails(reactionDetails.actor)?.display_name : null; + }) + .filter((displayName): displayName is string => !!displayName); + const formattedUsers = formatTextList(reactionUsers); + return formattedUsers; + }, + }; + return ops; + }, [workspaceSlug, projectId, issueId, createComment, updateComment, uploadEditorAsset, removeComment]); + + return operations; +}; diff --git a/web/core/components/issues/issue-detail/issue-activity/index.ts b/web/core/components/issues/issue-detail/issue-activity/index.ts index e6d5ce077ca..5eb4ac7d30c 100644 --- a/web/core/components/issues/issue-detail/issue-activity/index.ts +++ b/web/core/components/issues/issue-detail/issue-activity/index.ts @@ -6,8 +6,5 @@ export * from "./activity-comment-root"; export * from "./activity/activity-list"; export * from "./activity-filter"; -// issue comment -export * from "./comments"; - // sort export * from "./sort-root"; diff --git a/web/core/components/issues/issue-detail/issue-activity/root.tsx b/web/core/components/issues/issue-detail/issue-activity/root.tsx index 0133b5e6154..2acefbf68a4 100644 --- a/web/core/components/issues/issue-detail/issue-activity/root.tsx +++ b/web/core/components/issues/issue-detail/issue-activity/root.tsx @@ -1,6 +1,6 @@ "use client"; -import { FC, useMemo } from "react"; +import { FC } from "react"; import { observer } from "mobx-react"; // plane package imports import { E_SORT_ORDER, TActivityFilters, defaultActivityFilters, EUserPermissions } from "@plane/constants"; @@ -9,16 +9,15 @@ import { useLocalStorage } from "@plane/hooks"; import { useTranslation } from "@plane/i18n"; //types import { TFileSignedURLResponse, TIssueComment } from "@plane/types"; -import { EFileAssetType } from "@plane/types/src/enums"; -import { TOAST_TYPE, setToast } from "@plane/ui"; // components -import { IssueCommentCreate } from "@/components/issues"; +import { CommentCreate } from "@/components/comments/comment-create"; import { ActivitySortRoot, IssueActivityCommentRoot } from "@/components/issues/issue-detail"; // constants // hooks -import { useEditorAsset, useIssueDetail, useProject, useUser, useUserPermissions } from "@/hooks/store"; +import { useIssueDetail, useProject, useUser, useUserPermissions } from "@/hooks/store"; // plane web components import { ActivityFilterRoot, IssueActivityWorklogCreateButton } from "@/plane-web/components/issues/worklog"; +import { useCommentOperations } from "./helper"; type TIssueActivity = { workspaceSlug: string; @@ -48,14 +47,11 @@ export const IssueActivity: FC = observer((props) => { // store hooks const { issue: { getIssueById }, - createComment, - updateComment, - removeComment, } = useIssueDetail(); + const { projectPermissionsByWorkspaceSlugAndProjectId } = useUserPermissions(); const { getProjectById } = useProject(); const { data: currentUser } = useUser(); - const { uploadEditorAsset } = useEditorAsset(); // derived values const issue = issueId ? getIssueById(issueId) : undefined; const currentUserProjectRole = projectPermissionsByWorkspaceSlugAndProjectId(workspaceSlug, projectId); @@ -81,82 +77,8 @@ export const IssueActivity: FC = observer((props) => { setSortOrder(sortOrder === E_SORT_ORDER.ASC ? E_SORT_ORDER.DESC : E_SORT_ORDER.ASC); }; - const activityOperations: TActivityOperations = useMemo( - () => ({ - createComment: async (data) => { - try { - if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields"); - const comment = await createComment(workspaceSlug, projectId, issueId, data); - setToast({ - title: t("common.success"), - type: TOAST_TYPE.SUCCESS, - message: t("issue.comments.create.success"), - }); - return comment; - } catch { - setToast({ - title: t("common.error.label"), - type: TOAST_TYPE.ERROR, - message: t("issue.comments.create.error"), - }); - } - }, - updateComment: async (commentId, data) => { - try { - if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields"); - await updateComment(workspaceSlug, projectId, issueId, commentId, data); - setToast({ - title: t("common.success"), - type: TOAST_TYPE.SUCCESS, - message: t("issue.comments.update.success"), - }); - } catch { - setToast({ - title: t("common.error.label"), - type: TOAST_TYPE.ERROR, - message: t("issue.comments.update.error"), - }); - } - }, - removeComment: async (commentId) => { - try { - if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields"); - await removeComment(workspaceSlug, projectId, issueId, commentId); - setToast({ - title: t("common.success"), - type: TOAST_TYPE.SUCCESS, - message: t("issue.comments.remove.success"), - }); - } catch { - setToast({ - title: t("common.error.label"), - type: TOAST_TYPE.ERROR, - message: t("issue.comments.remove.error"), - }); - } - }, - uploadCommentAsset: async (blockId, file, commentId) => { - try { - if (!workspaceSlug || !projectId) throw new Error("Missing fields"); - const res = await uploadEditorAsset({ - blockId, - data: { - entity_identifier: commentId ?? "", - entity_type: EFileAssetType.COMMENT_DESCRIPTION, - }, - file, - projectId, - workspaceSlug, - }); - return res; - } catch (error) { - console.log("Error in uploading comment asset:", error); - throw new Error(t("issue.comments.upload.error")); - } - }, - }), - [workspaceSlug, projectId, issueId, createComment, updateComment, uploadEditorAsset, removeComment] - ); + // helper hooks + const activityOperations = useCommentOperations(workspaceSlug, projectId, issueId); const project = getProjectById(projectId); if (!project) return <>; @@ -200,12 +122,12 @@ export const IssueActivity: FC = observer((props) => { sortOrder={sortOrder || E_SORT_ORDER.ASC} /> {!disabled && ( - )}
diff --git a/web/core/services/file.service.ts b/web/core/services/file.service.ts index dba3027c552..2c9c82a6daa 100644 --- a/web/core/services/file.service.ts +++ b/web/core/services/file.service.ts @@ -109,6 +109,20 @@ export class FileService extends APIService { }); } + async updateBulkWorkspaceAssetsUploadStatus( + workspaceSlug: string, + entityId: string, + data: { + asset_ids: string[]; + } + ): Promise { + return this.post(`/api/assets/v2/workspaces/${workspaceSlug}/${entityId}/bulk/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + async updateBulkProjectAssetsUploadStatus( workspaceSlug: string, projectId: string, diff --git a/web/core/services/issue/issue_comment.service.ts b/web/core/services/issue/issue_comment.service.ts index ccfce8a86c1..8a55f49a1d5 100644 --- a/web/core/services/issue/issue_comment.service.ts +++ b/web/core/services/issue/issue_comment.service.ts @@ -63,7 +63,7 @@ export class IssueCommentService extends APIService { issueId: string, commentId: string, data: Partial - ): Promise { + ): Promise { return this.patch( `/api/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/comments/${commentId}/`, data diff --git a/web/core/store/issue/issue-details/comment.store.ts b/web/core/store/issue/issue-details/comment.store.ts index b0fba6d3c05..9bb3a7d1937 100644 --- a/web/core/store/issue/issue-details/comment.store.ts +++ b/web/core/store/issue/issue-details/comment.store.ts @@ -155,6 +155,11 @@ export class IssueCommentStore implements IIssueCommentStore { data ); + runInAction(() => { + set(this.commentMap, [commentId, "updated_at"], response.updated_at); + set(this.commentMap, [commentId, "edited_at"], response.edited_at); + }); + return response; } catch (error) { this.rootIssueDetail.activity.fetchActivities(workspaceSlug, projectId, issueId); diff --git a/web/core/store/issue/issue-details/comment_reaction.store.ts b/web/core/store/issue/issue-details/comment_reaction.store.ts index e965ba97eed..a8865f79856 100644 --- a/web/core/store/issue/issue-details/comment_reaction.store.ts +++ b/web/core/store/issue/issue-details/comment_reaction.store.ts @@ -121,6 +121,7 @@ export class IssueCommentReactionStore implements IIssueCommentReactionStore { return response; } catch (error) { + console.log("error", error); throw error; } }; @@ -149,8 +150,9 @@ export class IssueCommentReactionStore implements IIssueCommentReactionStore { reaction, }); + if (!this.commentReactions[commentId]) this.commentReactions[commentId] = {}; runInAction(() => { - update(this.commentReactions, [commentId, reaction], (reactionId) => { + update(this.commentReactions, `${commentId}.${reaction}`, (reactionId) => { if (!reactionId) return [response.id]; return concat(reactionId, response.id); }); @@ -159,6 +161,7 @@ export class IssueCommentReactionStore implements IIssueCommentReactionStore { return response; } catch (error) { + console.log("error", error); throw error; } }; diff --git a/web/ee/components/comments/index.ts b/web/ee/components/comments/index.ts new file mode 100644 index 00000000000..6cff36e715e --- /dev/null +++ b/web/ee/components/comments/index.ts @@ -0,0 +1 @@ +export * from "ce/components/comments";