[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(

View file

@ -0,0 +1,15 @@
import * as React from "react";
import { IconWrapper } from "../icon-wrapper";
import type { ISvgIcons } from "../type";
export function CommentReplyIcon({ color = "currentColor", ...rest }: ISvgIcons) {
return (
<IconWrapper color={color} {...rest}>
<path
d="M13.375 8.79981V5.2002C13.375 4.62994 13.3743 4.23962 13.3497 3.9375C13.3256 3.64296 13.2822 3.48707 13.2256 3.37598C13.0938 3.11725 12.8828 2.90624 12.6241 2.77441C12.513 2.71788 12.3571 2.67446 12.0625 2.65039C11.7604 2.62572 11.3701 2.625 10.7998 2.625H5.20024C4.62999 2.625 4.23967 2.62572 3.93754 2.65039C3.643 2.67446 3.48712 2.71788 3.37602 2.77441C3.1173 2.90624 2.90628 3.11725 2.77446 3.37598C2.71793 3.48707 2.6745 3.64296 2.65044 3.9375C2.62576 4.23962 2.62504 4.62995 2.62504 5.2002V9.33301C2.62504 9.9927 2.63053 10.2007 2.67192 10.3555C2.79906 10.83 3.17007 11.201 3.64458 11.3281C3.79936 11.3695 4.00735 11.375 4.66704 11.375C5.01207 11.3752 5.29204 11.6549 5.29204 12V13.3652L6.73344 12.2129C7.03847 11.9689 7.25663 11.79 7.50688 11.6621C7.7175 11.5545 7.94219 11.4763 8.17387 11.4287C8.44928 11.3722 8.73134 11.375 9.12212 11.375H10.7998C11.3701 11.375 11.7604 11.3743 12.0625 11.3496C12.3571 11.3255 12.513 11.2821 12.6241 11.2256C12.8828 11.0938 13.0938 10.8827 13.2256 10.624C13.2822 10.5129 13.3256 10.357 13.3497 10.0625C13.3743 9.76038 13.375 9.37006 13.375 8.79981ZM14.625 8.79981C14.625 9.34933 14.6255 9.79928 14.5957 10.1641C14.5653 10.5361 14.5002 10.8748 14.3389 11.1914C14.0872 11.6853 13.6854 12.0872 13.1915 12.3389C12.8748 12.5001 12.5362 12.5653 12.1641 12.5957C11.7993 12.6255 11.3494 12.625 10.7998 12.625H9.12212C8.68139 12.625 8.54724 12.6282 8.42485 12.6533C8.30369 12.6782 8.18638 12.7191 8.07622 12.7754C7.96492 12.8322 7.85814 12.9139 7.51372 13.1895L5.92387 14.4619C5.79493 14.5651 5.6641 14.6693 5.55083 14.7441C5.44715 14.8127 5.25268 14.9314 5.00102 14.9316C4.70926 14.9319 4.43308 14.7993 4.25102 14.5713C4.09396 14.3745 4.06499 14.1481 4.05376 14.0244C4.04148 13.8892 4.04204 13.7217 4.04204 13.5566V12.6191C3.76509 12.6115 3.53206 12.5919 3.32036 12.5352C2.41479 12.2924 1.70767 11.5853 1.46489 10.6797C1.37008 10.3258 1.37504 9.91275 1.37504 9.33301V5.2002C1.37504 4.65067 1.37457 4.20072 1.40434 3.83594C1.43474 3.46388 1.4999 3.12523 1.66118 2.80859C1.91285 2.31467 2.31471 1.9128 2.80864 1.66113C3.12527 1.49985 3.46392 1.4347 3.83598 1.4043C4.20076 1.37452 4.65072 1.375 5.20024 1.375H10.7998C11.3494 1.375 11.7993 1.37452 12.1641 1.4043C12.5362 1.4347 12.8748 1.49986 13.1915 1.66113C13.6854 1.9128 14.0872 2.31467 14.3389 2.80859C14.5002 3.12523 14.5653 3.46388 14.5957 3.83594C14.6255 4.20072 14.625 4.65067 14.625 5.2002V8.79981Z"
fill={color}
/>
</IconWrapper>
);
}

View file

@ -1,4 +1,5 @@
export * from "./boolean-icon";
export * from "./comment-reply-icon";
export * from "./dropdown-icon";
export * from "./due-date-icon";
export * from "./duplicate-icon";