snapshot of https://github.com/makeplane/plane.git @ d868349a55de79979eb8b98701bf1702c67ad634 (2026-09-15)
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 3s
i18n sync check / check:sync (push) Failing after 3s
React Doctor / react-doctor (push) Failing after 16s
Branch Build CE / Build-Push Admin Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Admin Manifest (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Web Manifest (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Proxy Manifest (push) Has been cancelled
Branch Build CE / Merge Space Manifest (push) Has been cancelled
Branch Build CE / Prepare AIO Assets (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Live Collaboration Manifest (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge API Server Manifest (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge AIO Manifest (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-15 15:14:58 +05:00
commit 23059a1de3
5252 changed files with 573834 additions and 0 deletions

View File

@@ -0,0 +1,192 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { ReactNode } from "react";
import { useCallback, useEffect, useState } from "react";
import { observer } from "mobx-react";
import { usePathname } from "next/navigation";
// plane imports
import { Avatar } from "@makeplane/propel/components/avatar";
import { Tooltip } from "@makeplane/propel/components/tooltip";
import type { EditorRefApi } from "@plane/editor";
import { useHashScroll } from "@plane/hooks";
import { GlobeOutline, LockOutline } from "@makeplane/propel/icons";
import { EIssueCommentAccessSpecifier } from "@plane/types";
import type { TCommentsOperations, TIssueComment } from "@plane/types";
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 { useMember } from "@/hooks/store/use-member";
export type TCommentCardDisplayProps = {
activityOperations: TCommentsOperations;
comment: TIssueComment;
disabled: boolean;
entityId: string;
projectId?: string;
readOnlyEditorRef: React.RefObject<EditorRefApi | null>;
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: TCommentCardDisplayProps) {
const {
activityOperations,
comment,
disabled,
projectId,
readOnlyEditorRef,
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,
pathname,
});
useEffect(() => {
if (!isHashMatch) return;
setHighlightClassName("border-accent-strong");
const timeout = setTimeout(() => {
setHighlightClassName("");
}, 8000);
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 && (
<div className="absolute top-2.5 right-2.5 z-[1] text-tertiary">
{comment.access === EIssueCommentAccessSpecifier.INTERNAL ? (
<LockOutline className="size-3" />
) : (
<GlobeOutline className="size-3" />
)}
</div>
)}
<div className="relative mb-3 flex w-full items-center gap-2">
<Avatar alt={displayName} fallback={displayName?.[0]?.toUpperCase()} size="2xs" src={getFileURL(avatarUrl)} />
<div className="flex flex-1 flex-wrap items-center gap-1">
<div className="text-caption-sm-medium">{displayName}</div>
<div className="text-caption-sm-regular text-tertiary">
commented{" "}
<Tooltip
label={`${renderFormattedDate(comment.created_at)} at ${renderFormattedTime(comment.created_at)}`}
side="bottom"
layout="single"
delay={200}
>
<span className="text-tertiary">
{calculateTimeAgo(comment.created_at)}
{comment.edited_at && " (edited)"}
</span>
</Tooltip>
</div>
</div>
{!disabled && (
<div className="flex shrink-0 items-center gap-1">
<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

@@ -0,0 +1,144 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useEffect, useRef } from "react";
import { observer } from "mobx-react";
import { useForm } from "react-hook-form";
import type { EditorRefApi } from "@plane/editor";
import { CloseOutline, TickOutline } from "@makeplane/propel/icons";
// plane imports
import type { TCommentsOperations, TIssueComment } from "@plane/types";
import { cn, isCommentEmpty } from "@plane/utils";
// components
import { LiteTextEditor } from "@/components/editor/lite-text";
type Props = {
activityOperations: TCommentsOperations;
comment: TIssueComment;
isEditing: boolean;
projectId?: string;
readOnlyEditorRef: EditorRefApi | null;
setIsEditing: (isEditing: boolean) => void;
workspaceId: string;
workspaceSlug: string;
};
export const CommentCardEditForm = observer(function CommentCardEditForm(props: Props) {
const {
activityOperations,
comment,
isEditing,
projectId,
readOnlyEditorRef,
setIsEditing,
workspaceId,
workspaceSlug,
} = props;
// refs
const editorRef = useRef<EditorRefApi>(null);
// form info
const {
formState: { isSubmitting },
handleSubmit,
setFocus,
watch,
setValue,
} = useForm<Partial<TIssueComment>>({
defaultValues: { comment_html: comment?.comment_html },
});
const commentHTML = watch("comment_html");
const isEmpty = isCommentEmpty(commentHTML);
const isEditorReadyToDiscard = editorRef.current?.isEditorReadyToDiscard();
const isSubmitButtonDisabled = isSubmitting || !isEditorReadyToDiscard;
const isDisabled = isSubmitting || isEmpty || isSubmitButtonDisabled;
const onEnter = async (formData: Partial<TIssueComment>) => {
if (isSubmitting || !comment) return;
setIsEditing(false);
await activityOperations.updateComment(comment.id, formData);
editorRef.current?.setEditorValue(formData?.comment_html ?? "<p></p>");
readOnlyEditorRef?.setEditorValue(formData?.comment_html ?? "<p></p>");
};
useEffect(() => {
if (isEditing) {
setFocus("comment_html");
}
}, [isEditing, setFocus]);
return (
<form className="flex flex-col gap-2">
<div
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey && !isEmpty) handleSubmit(onEnter)(e);
}}
>
<LiteTextEditor
editable
workspaceId={workspaceId}
workspaceSlug={workspaceSlug}
ref={editorRef}
id={comment.id}
initialValue={commentHTML ?? ""}
value={null}
onChange={(_comment_json, comment_html) => setValue("comment_html", comment_html)}
onEnterKeyPress={(e) => {
if (!isEmpty && !isSubmitting) {
handleSubmit(onEnter)(e);
}
}}
showSubmitButton={false}
uploadFile={async (blockId, file) => {
const { asset_id } = await activityOperations.uploadCommentAsset(blockId, file, comment.id);
return asset_id;
}}
duplicateFile={async (assetId: string) => {
const { asset_id } = await activityOperations.duplicateCommentAsset(assetId, comment.id);
return asset_id;
}}
projectId={projectId}
parentClassName="p-2 bg-surface-1"
displayConfig={{
fontSize: "small-font",
}}
/>
</div>
<div className="flex gap-2 self-end">
{!isEmpty && (
<button
type="button"
onClick={handleSubmit(onEnter)}
disabled={isDisabled}
className={cn(
"group grid size-7 place-items-center rounded-lg border border-success-subtle bg-success-subtle shadow-raised-100 duration-300",
isDisabled ? "" : "hover:bg-success-subtle-1"
)}
>
<TickOutline className="size-4 text-success-primary" />
</button>
)}
<button
type="button"
disabled={isSubmitting}
className={cn(
"group grid size-7 place-items-center rounded-lg border border-danger-subtle bg-danger-subtle shadow-raised-100 duration-300",
isSubmitting ? "" : "hover:bg-danger-subtle-hover"
)}
onClick={() => {
setIsEditing(false);
editorRef.current?.setEditorValue(comment.comment_html ?? "<p></p>");
}}
>
<CloseOutline className="size-4 text-danger-primary" />
</button>
</div>
</form>
);
});

View File

@@ -0,0 +1,77 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useRef, useState } from "react";
import { observer } from "mobx-react";
// plane imports
import type { EditorRefApi } from "@plane/editor";
import type { TIssueComment, TCommentsOperations } from "@plane/types";
// local imports
import { CommentQuickActions } from "../quick-actions";
import { CommentBlock } from "../comment-block";
import { CommentCardDisplay } from "./display";
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;
};
export const CommentCard = observer(function CommentCard(props: TCommentCard) {
const {
workspaceSlug,
entityId,
comment,
activityOperations,
ends,
showAccessSpecifier,
showCopyLinkOption,
disabled = false,
projectId,
} = props;
// states
const [isEditing, setIsEditing] = useState(false);
// refs
const readOnlyEditorRef = useRef<EditorRefApi>(null);
// derived values
const workspaceId = comment?.workspace;
if (!comment || !workspaceId) return null;
return (
<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>
);
});