[WEB-5768]chore: updated comment UI #8402

This commit is contained in:
Vamsi Krishna 2025-12-19 20:02:40 +05:30 committed by GitHub
parent 313314ebd6
commit 409a3e84ab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 184 additions and 133 deletions

View file

@ -2,77 +2,43 @@ import type { ReactNode } from "react";
import { useRef } from "react";
import { observer } from "mobx-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { CommentReplyIcon } from "@plane/propel/icons";
import type { TIssueComment } from "@plane/types";
import { Avatar, Tooltip } from "@plane/ui";
import { calculateTimeAgo, cn, getFileURL, renderFormattedDate, renderFormattedTime } from "@plane/utils";
import { cn } from "@plane/utils";
// hooks
import { useMember } from "@/hooks/store/use-member";
type TCommentBlock = {
comment: TIssueComment;
ends: "top" | "bottom" | undefined;
quickActions: ReactNode;
children: ReactNode;
};
export const CommentBlock = observer(function CommentBlock(props: TCommentBlock) {
const { comment, ends, quickActions, children } = props;
// refs
const { comment, ends, children } = props;
const commentBlockRef = useRef<HTMLDivElement>(null);
// store hooks
const { getUserDetails } = useMember();
// derived values
const userDetails = getUserDetails(comment?.actor);
// translation
const { t } = useTranslation();
const displayName = comment?.actor_detail?.is_bot
? comment?.actor_detail?.first_name + ` ${t("bot")}`
: (userDetails?.display_name ?? comment?.actor_detail?.display_name);
const avatarUrl = userDetails?.avatar_url ?? comment?.actor_detail?.avatar_url;
if (!comment) return null;
return (
<div
id={comment.id}
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-px transition-border duration-1000 bg-layer-1"
className="absolute left-[13px] top-0 bottom-0 w-px transition-border duration-1000 bg-layer-3"
aria-hidden
/>
<div
className={cn(
"flex-shrink-0 relative w-7 h-6 rounded-full transition-border duration-1000 flex justify-center items-center z-[3] uppercase font-medium"
"flex-shrink-0 relative w-7 h-7 rounded-lg transition-border duration-1000 flex justify-center items-center z-[3] uppercase shadow-raised-100 bg-layer-2 border border-subtle"
)}
>
<Avatar size="base" name={displayName} src={getFileURL(avatarUrl)} className="flex-shrink-0" />
<CommentReplyIcon width={14} height={14} className="text-secondary" aria-hidden="true" />
</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="flex items-center gap-1">
<span className="text-11 font-medium">{displayName}</span>
</div>
<div className="text-11 text-tertiary">
commented{" "}
<Tooltip
tooltipContent={`${renderFormattedDate(comment.created_at)} at ${renderFormattedTime(comment.created_at)}`}
position="bottom"
>
<span className="text-tertiary">
{calculateTimeAgo(comment.created_at)}
{comment.edited_at && ` (${t("edited")})`}
</span>
</Tooltip>
</div>
</div>
<div className="flex-shrink-0 ">{quickActions}</div>
<div className="text-body-sm-regular mb-2 bg-layer-2 border border-subtle shadow-raised-100 rounded-lg p-3">
{children}
</div>
<div className="text-14 mb-2">{children}</div>
</div>
</div>
);

View file

@ -1 +1,2 @@
export * from "./comment-block";
export { CommentCardDisplay } from "@/components/comments/card/display";

View file

@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import type { ReactNode } from "react";
import { useCallback, useEffect, useState } from "react";
import { observer } from "mobx-react";
import { usePathname } from "next/navigation";
import { Globe2, Lock } from "lucide-react";
@ -7,24 +8,33 @@ import type { EditorRefApi } from "@plane/editor";
import { useHashScroll } from "@plane/hooks";
import { EIssueCommentAccessSpecifier } from "@plane/types";
import type { TCommentsOperations, TIssueComment } from "@plane/types";
import { cn } from "@plane/utils";
import { calculateTimeAgo, cn, getFileURL, renderFormattedDate, renderFormattedTime } from "@plane/utils";
// components
import { LiteTextEditor } from "@/components/editor/lite-text";
// local imports
import { CommentReactions } from "../comment-reaction";
import { CommentCardEditForm } from "./edit-form";
import { EmojiReactionButton, EmojiReactionPicker } from "@plane/propel/emoji-reaction";
import { Avatar, Tooltip } from "@plane/ui";
import { useMember } from "@/hooks/store/use-member";
type Props = {
export type TCommentCardDisplayProps = {
activityOperations: TCommentsOperations;
comment: TIssueComment;
disabled: boolean;
entityId: string;
projectId?: string;
readOnlyEditorRef: React.RefObject<EditorRefApi>;
showAccessSpecifier: boolean;
workspaceId: string;
workspaceSlug: string;
isEditing?: boolean;
setIsEditing?: (isEditing: boolean) => void;
renderFooter?: (ReactionsComponent: ReactNode | null) => ReactNode;
renderQuickActions?: () => ReactNode;
};
export const CommentCardDisplay = observer(function CommentCardDisplay(props: Props) {
export const CommentCardDisplay = observer(function CommentCardDisplay(props: TCommentCardDisplayProps) {
const {
activityOperations,
comment,
@ -34,13 +44,34 @@ export const CommentCardDisplay = observer(function CommentCardDisplay(props: Pr
showAccessSpecifier,
workspaceId,
workspaceSlug,
isEditing = false,
setIsEditing,
renderFooter,
renderQuickActions,
} = props;
// states
const [highlightClassName, setHighlightClassName] = useState("");
// state
const [isPickerOpen, setIsPickerOpen] = useState(false);
// store hooks
const { getUserDetails } = useMember();
// derived values
const userDetails = getUserDetails(comment?.actor);
const displayName = comment?.actor_detail?.is_bot
? comment?.actor_detail?.first_name + `Bot`
: (userDetails?.display_name ?? comment?.actor_detail?.display_name);
const avatarUrl = userDetails?.avatar_url ?? comment?.actor_detail?.avatar_url;
const userReactions = activityOperations.userReactions(comment.id);
// navigation
const pathname = usePathname();
// derived values
const commentBlockId = `comment-${comment?.id}`;
// Check if there are any reactions to determine if we should render the footer
const reactionIds = activityOperations.reactionIds(comment.id);
const hasReactions = reactionIds && Object.keys(reactionIds).some((key) => reactionIds[key]?.length > 0);
// scroll to comment
const { isHashMatch } = useHashScroll({
elementId: commentBlockId,
@ -57,6 +88,17 @@ export const CommentCardDisplay = observer(function CommentCardDisplay(props: Pr
return () => clearTimeout(timeout);
}, [isHashMatch]);
const handleEmojiSelect = useCallback(
(emoji: string) => {
if (!userReactions) return;
// emoji is already in decimal string format from EmojiReactionPicker
void activityOperations.react(comment.id, emoji, userReactions);
},
[activityOperations, comment.id, userReactions]
);
const shouldRenderReactions = hasReactions && !disabled;
return (
<div id={commentBlockId} className="relative flex flex-col gap-2">
{showAccessSpecifier && (
@ -68,20 +110,74 @@ export const CommentCardDisplay = observer(function CommentCardDisplay(props: Pr
)}
</div>
)}
<LiteTextEditor
editable={false}
ref={readOnlyEditorRef}
id={comment.id}
initialValue={comment.comment_html ?? ""}
workspaceId={workspaceId}
workspaceSlug={workspaceSlug}
containerClassName={cn("!py-1 transition-[border-color] duration-500", highlightClassName)}
projectId={projectId?.toString()}
displayConfig={{
fontSize: "small-font",
}}
/>
<CommentReactions comment={comment} disabled={disabled} activityOperations={activityOperations} />
<div className="flex relative w-full gap-2 items-center mb-3">
<Avatar size="sm" name={displayName} src={getFileURL(avatarUrl)} className="shrink-0" />
<div className="flex-1 flex flex-wrap items-center gap-1">
<div className="text-caption-sm-medium">{displayName}</div>
<div className="text-caption-sm-regular text-tertiary">
commented{" "}
<Tooltip
tooltipContent={`${renderFormattedDate(comment.created_at)} at ${renderFormattedTime(comment.created_at)}`}
position="bottom"
>
<span className="text-tertiary">
{calculateTimeAgo(comment.created_at)}
{comment.edited_at && " (edited)"}
</span>
</Tooltip>
</div>
</div>
{!disabled && (
<div className="flex items-center gap-1 shrink-0">
<EmojiReactionPicker
isOpen={isPickerOpen}
handleToggle={setIsPickerOpen}
onChange={handleEmojiSelect}
disabled={disabled}
label={<EmojiReactionButton onAddReaction={() => setIsPickerOpen(true)} />}
placement="bottom-start"
/>
{renderQuickActions ? renderQuickActions() : null}
</div>
)}
</div>
{isEditing && setIsEditing ? (
<CommentCardEditForm
activityOperations={activityOperations}
comment={comment}
isEditing={isEditing}
readOnlyEditorRef={readOnlyEditorRef.current}
setIsEditing={setIsEditing}
projectId={projectId}
workspaceId={workspaceId}
workspaceSlug={workspaceSlug}
/>
) : (
<>
<LiteTextEditor
editable={false}
ref={readOnlyEditorRef}
id={comment.id}
initialValue={comment.comment_html ?? ""}
workspaceId={workspaceId}
workspaceSlug={workspaceSlug}
containerClassName={cn("!py-1 transition-[border-color] duration-500", highlightClassName)}
projectId={projectId?.toString()}
displayConfig={{
fontSize: "small-font",
}}
parentClassName="border-none"
/>
{shouldRenderReactions &&
(renderFooter ? (
renderFooter(
<CommentReactions comment={comment} disabled={disabled} activityOperations={activityOperations} />
)
) : (
<CommentReactions comment={comment} disabled={disabled} activityOperations={activityOperations} />
))}
</>
)}
</div>
);
});

View file

@ -1,23 +1,22 @@
import { useCallback, useRef, useState } from "react";
import { useRef, useState } from "react";
import { observer } from "mobx-react";
// plane imports
import { EmojiReactionButton, EmojiReactionPicker } from "@plane/propel/emoji-reaction";
import type { EditorRefApi } from "@plane/editor";
import type { TIssueComment, TCommentsOperations } from "@plane/types";
// plane web imports
import { CommentBlock } from "@/plane-web/components/comments";
import { CommentBlock, CommentCardDisplay } from "@/plane-web/components/comments";
// local imports
import { CommentQuickActions } from "../quick-actions";
import { CommentCardDisplay } from "./display";
import { CommentCardEditForm } from "./edit-form";
type TCommentCard = {
workspaceSlug: string;
entityId: string;
comment: TIssueComment | undefined;
activityOperations: TCommentsOperations;
ends: "top" | "bottom" | undefined;
showAccessSpecifier: boolean;
showCopyLinkOption: boolean;
enableReplies: boolean;
disabled?: boolean;
projectId?: string;
};
@ -25,6 +24,7 @@ type TCommentCard = {
export const CommentCard = observer(function CommentCard(props: TCommentCard) {
const {
workspaceSlug,
entityId,
comment,
activityOperations,
ends,
@ -35,75 +35,37 @@ export const CommentCard = observer(function CommentCard(props: TCommentCard) {
} = props;
// states
const [isEditing, setIsEditing] = useState(false);
const [isPickerOpen, setIsPickerOpen] = useState(false);
// refs
const readOnlyEditorRef = useRef<EditorRefApi>(null);
// derived values
const workspaceId = comment?.workspace;
const userReactions = comment?.id ? activityOperations.userReactions(comment.id) : undefined;
const handleEmojiSelect = useCallback(
(emoji: string) => {
if (!userReactions || !comment?.id) return;
// emoji is already in decimal string format from EmojiReactionPicker
void activityOperations.react(comment.id, emoji, userReactions);
},
[activityOperations, comment?.id, userReactions]
);
if (!comment || !workspaceId) return null;
return (
<CommentBlock
comment={comment}
quickActions={
!disabled && (
<div className="flex items-center gap-1">
<EmojiReactionPicker
isOpen={isPickerOpen}
handleToggle={setIsPickerOpen}
onChange={handleEmojiSelect}
disabled={disabled}
label={<EmojiReactionButton onAddReaction={() => setIsPickerOpen(true)} />}
placement="bottom-start"
/>
<CommentQuickActions
activityOperations={activityOperations}
comment={comment}
setEditMode={() => setIsEditing(true)}
showAccessSpecifier={showAccessSpecifier}
showCopyLinkOption={showCopyLinkOption}
/>
</div>
)
}
ends={ends}
>
{isEditing ? (
<CommentCardEditForm
activityOperations={activityOperations}
comment={comment}
isEditing
readOnlyEditorRef={readOnlyEditorRef.current}
setIsEditing={setIsEditing}
projectId={projectId}
workspaceId={workspaceId}
workspaceSlug={workspaceSlug}
/>
) : (
<CommentCardDisplay
activityOperations={activityOperations}
comment={comment}
disabled={disabled}
projectId={projectId}
readOnlyEditorRef={readOnlyEditorRef}
showAccessSpecifier={showAccessSpecifier}
workspaceId={workspaceId}
workspaceSlug={workspaceSlug}
/>
)}
<CommentBlock comment={comment} ends={ends}>
<CommentCardDisplay
activityOperations={activityOperations}
entityId={entityId}
comment={comment}
disabled={disabled}
projectId={projectId}
readOnlyEditorRef={readOnlyEditorRef}
showAccessSpecifier={showAccessSpecifier}
workspaceId={workspaceId}
workspaceSlug={workspaceSlug}
isEditing={isEditing}
setIsEditing={setIsEditing}
renderQuickActions={() => (
<CommentQuickActions
activityOperations={activityOperations}
comment={comment}
setEditMode={() => setIsEditing(true)}
showAccessSpecifier={showAccessSpecifier}
showCopyLinkOption={showCopyLinkOption}
/>
)}
/>
</CommentBlock>
);
});

View file

@ -1,5 +1,4 @@
import type { FC } from "react";
import React, { useMemo } from "react";
import { useMemo } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
@ -19,6 +18,7 @@ type TCommentsWrapper = {
getCommentById?: (activityId: string) => TIssueComment | undefined;
showAccessSpecifier?: boolean;
showCopyLinkOption?: boolean;
enableReplies?: boolean;
};
export const CommentsWrapper = observer(function CommentsWrapper(props: TCommentsWrapper) {
@ -31,6 +31,7 @@ export const CommentsWrapper = observer(function CommentsWrapper(props: TComment
projectId,
showAccessSpecifier = false,
showCopyLinkOption = false,
enableReplies = false,
} = props;
// router
const { workspaceSlug: routerWorkspaceSlug } = useParams();
@ -65,6 +66,7 @@ export const CommentsWrapper = observer(function CommentsWrapper(props: TComment
<CommentCard
key={comment.id}
workspaceSlug={workspaceSlug}
entityId={entityId}
comment={comment}
activityOperations={activityOperations}
disabled={!isEditingAllowed}
@ -72,6 +74,7 @@ export const CommentsWrapper = observer(function CommentsWrapper(props: TComment
projectId={projectId}
showAccessSpecifier={showAccessSpecifier}
showCopyLinkOption={showCopyLinkOption}
enableReplies={enableReplies}
/>
);
})}

View file

@ -38,6 +38,7 @@ type LiteTextEditorWrapperProps = MakeOptional<
issue_id?: string;
parentClassName?: string;
editorClassName?: string;
submitButtonText?: string;
} & (
| {
editable: false;
@ -73,6 +74,7 @@ export const LiteTextEditor = React.forwardRef(function LiteTextEditor(
disabledExtensions: additionalDisabledExtensions = [],
editorClassName = "",
showPlaceholderOnEmpty = true,
submitButtonText = "common.comment",
...rest
} = props;
// states
@ -208,6 +210,7 @@ export const LiteTextEditor = React.forwardRef(function LiteTextEditor(
showAccessSpecifier={showAccessSpecifier}
editorRef={editorRef}
showSubmitButton={showSubmitButton}
submitButtonText={submitButtonText}
/>
</div>
)}

View file

@ -25,6 +25,7 @@ type Props = {
showAccessSpecifier: boolean;
showSubmitButton: boolean;
editorRef: EditorRefApi | null;
submitButtonText?: string;
};
type TCommentAccessType = {
@ -60,6 +61,7 @@ export function IssueCommentToolbar(props: Props) {
showAccessSpecifier,
showSubmitButton,
editorRef,
submitButtonText = "common.comment",
} = props;
// State to manage active states of toolbar items
const [activeStates, setActiveStates] = useState<Record<string, boolean>>({});
@ -175,7 +177,7 @@ export function IssueCommentToolbar(props: Props) {
disabled={isSubmitButtonDisabled}
loading={isSubmitting}
>
{t("common.comment")}
{t(submitButtonText)}
</Button>
</div>
)}

View file

@ -67,6 +67,7 @@ export const IssueActivityCommentRoot = observer(function IssueActivityCommentRo
<CommentCard
key={activityComment.id}
workspaceSlug={workspaceSlug}
entityId={issueId}
comment={comment}
activityOperations={activityOperations}
ends={index === 0 ? "top" : index === filteredActivityAndComments.length - 1 ? "bottom" : undefined}
@ -74,6 +75,7 @@ export const IssueActivityCommentRoot = observer(function IssueActivityCommentRo
showCopyLinkOption={!isIntakeIssue}
disabled={disabled}
projectId={projectId}
enableReplies
/>
) : BASE_ACTIVITY_FILTER_TYPES.includes(activityComment.activity_type as EActivityFilterType) ? (
<IssueActivityItem

View file

@ -10,7 +10,7 @@ import { useMember } from "@/hooks/store/use-member";
import { useProject } from "@/hooks/store/use-project";
import { useUser } from "@/hooks/store/user";
export const useCommentOperations = (
export const useWorkItemCommentOperations = (
workspaceSlug: string | undefined,
projectId: string | undefined,
issueId: string | undefined

View file

@ -19,7 +19,7 @@ import { useUser, useUserPermissions } from "@/hooks/store/user";
import { ActivityFilterRoot } from "@/plane-web/components/issues/worklog/activity/filter-root";
import { IssueActivityWorklogCreateButton } from "@/plane-web/components/issues/worklog/activity/worklog-create-button";
import { IssueActivityCommentRoot } from "./activity-comment-root";
import { useCommentOperations } from "./helper";
import { useWorkItemCommentOperations } from "./helper";
import { ActivitySortRoot } from "./sort-root";
type TIssueActivity = {
@ -81,7 +81,7 @@ export const IssueActivity = observer(function IssueActivity(props: TIssueActivi
};
// helper hooks
const activityOperations = useCommentOperations(workspaceSlug, projectId, issueId);
const activityOperations = useWorkItemCommentOperations(workspaceSlug, projectId, issueId);
const project = getProjectById(projectId);
const renderCommentCreationBox = useMemo(