This commit is contained in:
DCCONSTRUCTIONS
2026-04-18 18:39:25 +03:00
commit 3ba092b60c
4944 changed files with 497564 additions and 0 deletions
@@ -0,0 +1,114 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React, { useRef, useState } from "react";
import { observer } from "mobx-react";
import { useForm, Controller } from "react-hook-form";
// plane imports
import type { EditorRefApi } from "@plane/editor";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { SitesFileService } from "@plane/services";
import type { TIssuePublicComment } from "@plane/types";
// editor components
import { LiteTextEditor } from "@/components/editor/lite-text-editor";
// hooks
import { usePublish } from "@/hooks/store/publish";
import { useIssueDetails } from "@/hooks/store/use-issue-details";
import { useUser } from "@/hooks/store/use-user";
// services
const fileService = new SitesFileService();
const defaultValues: Partial<TIssuePublicComment> = {
comment_html: "",
};
type Props = {
anchor: string;
disabled?: boolean;
};
export const AddComment = observer(function AddComment(props: Props) {
const { anchor } = props;
// states
const [uploadedAssetIds, setUploadAssetIds] = useState<string[]>([]);
// refs
const editorRef = useRef<EditorRefApi>(null);
// store hooks
const { peekId: issueId, addIssueComment, uploadCommentAsset } = useIssueDetails();
const { data: currentUser } = useUser();
const { workspace: workspaceID } = usePublish(anchor);
// form info
const {
handleSubmit,
control,
watch,
formState: { isSubmitting },
reset,
} = useForm<TIssuePublicComment>({ defaultValues });
const onSubmit = async (formData: TIssuePublicComment) => {
if (!anchor || !issueId || isSubmitting || !formData.comment_html) return;
await addIssueComment(anchor, issueId, formData)
.then(async (res) => {
reset(defaultValues);
editorRef.current?.clearEditor();
if (uploadedAssetIds.length > 0) {
await fileService.updateBulkAssetsUploadStatus(anchor, res.id, {
asset_ids: uploadedAssetIds,
});
setUploadAssetIds([]);
}
})
.catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Comment could not be posted. Please try again.",
})
);
};
// TODO: on click if he user is not logged in redirect to login page
return (
<div>
<div className="issue-comments-section">
<Controller
name="comment_html"
control={control}
render={({ field: { value, onChange } }) => (
<LiteTextEditor
editable
onEnterKeyPress={(e) => {
if (currentUser) handleSubmit(onSubmit)(e);
}}
anchor={anchor}
workspaceId={workspaceID?.toString() ?? ""}
ref={editorRef}
id="peek-overview-add-comment"
initialValue={
!value || value === "" || (typeof value === "object" && Object.keys(value).length === 0)
? watch("comment_html")
: value
}
onChange={(comment_json, comment_html) => onChange(comment_html)}
isSubmitting={isSubmitting}
placeholder="Add comment..."
uploadFile={async (blockId, file) => {
const { asset_id } = await uploadCommentAsset(file, anchor);
setUploadAssetIds((prev) => [...prev, asset_id]);
return asset_id;
}}
displayConfig={{
fontSize: "small-font",
}}
/>
)}
/>
</div>
</div>
);
});
@@ -0,0 +1,228 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React, { useRef, useState } from "react";
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
import { MessageSquare, MoreVertical } from "lucide-react";
import { Menu, Transition } from "@headlessui/react";
// plane imports
import type { EditorRefApi } from "@plane/editor";
import { CheckIcon, CloseIcon } from "@plane/propel/icons";
import type { TIssuePublicComment } from "@plane/types";
import { getFileURL } from "@plane/utils";
// components
import { LiteTextEditor } from "@/components/editor/lite-text-editor";
import { CommentReactions } from "@/components/issues/peek-overview/comment/comment-reactions";
// helpers
import { timeAgo } from "@/helpers/date-time.helper";
// hooks
import { usePublish } from "@/hooks/store/publish";
import { useIssueDetails } from "@/hooks/store/use-issue-details";
import { useUser } from "@/hooks/store/use-user";
import useIsInIframe from "@/hooks/use-is-in-iframe";
type Props = {
anchor: string;
comment: TIssuePublicComment;
};
export const CommentCard = observer(function CommentCard(props: Props) {
const { anchor, comment } = props;
// store hooks
const { peekId, deleteIssueComment, updateIssueComment, uploadCommentAsset } = useIssueDetails();
const { data: currentUser } = useUser();
const { workspace: workspaceID } = usePublish(anchor);
const isInIframe = useIsInIframe();
// states
const [isEditing, setIsEditing] = useState(false);
// refs
const editorRef = useRef<EditorRefApi>(null);
const showEditorRef = useRef<EditorRefApi>(null);
// form info
const {
control,
formState: { isSubmitting },
handleSubmit,
} = useForm<TIssuePublicComment>({
defaultValues: { comment_html: comment.comment_html },
});
const handleDelete = () => {
if (!anchor || !peekId) return;
deleteIssueComment(anchor, peekId, comment.id);
};
const handleCommentUpdate = async (formData: TIssuePublicComment) => {
if (!anchor || !peekId) return;
updateIssueComment(anchor, peekId, comment.id, formData);
setIsEditing(false);
editorRef.current?.setEditorValue(formData.comment_html);
showEditorRef.current?.setEditorValue(formData.comment_html);
};
return (
<div className="relative flex items-start space-x-3">
<div className="relative px-1">
{comment.actor_detail.avatar_url && comment.actor_detail.avatar_url !== "" ? (
<img
src={getFileURL(comment.actor_detail.avatar_url)}
alt={
comment.actor_detail.is_bot ? comment.actor_detail.first_name + " Bot" : comment.actor_detail.display_name
}
height={30}
width={30}
className="grid size-7 place-items-center rounded-full border-2 border-strong-1"
/>
) : (
<div
className={`bg-gray-500 grid size-7 place-items-center rounded-full border-2 border-strong-1 text-on-color`}
>
{comment.actor_detail.is_bot
? comment?.actor_detail?.first_name?.charAt(0)
: comment?.actor_detail?.display_name?.charAt(0)}
</div>
)}
<span className="absolute -right-1 -bottom-0.5 rounded-tl-sm bg-layer-1 px-0.5 py-px">
<MessageSquare className="size-3 text-secondary" aria-hidden="true" strokeWidth={2} />
</span>
</div>
<div className="min-w-0 flex-1">
<div>
<div className="text-11">
{comment.actor_detail.is_bot ? comment.actor_detail.first_name + " Bot" : comment.actor_detail.display_name}
</div>
<p className="mt-0.5 text-11 text-secondary">
<>commented {timeAgo(comment.created_at)}</>
</p>
</div>
<div className="issue-comments-section p-0">
<form
onSubmit={handleSubmit(handleCommentUpdate)}
className={`flex-col gap-2 ${isEditing ? "flex" : "hidden"}`}
>
<div>
<Controller
control={control}
name="comment_html"
render={({ field: { onChange, value } }) => (
<LiteTextEditor
editable
anchor={anchor}
workspaceId={workspaceID?.toString() ?? ""}
onEnterKeyPress={handleSubmit(handleCommentUpdate)}
ref={editorRef}
id={comment.id}
initialValue={value}
value={null}
onChange={(comment_json, comment_html) => onChange(comment_html)}
isSubmitting={isSubmitting}
showSubmitButton={false}
uploadFile={async (blockId, file) => {
const { asset_id } = await uploadCommentAsset(file, anchor, comment.id);
return asset_id;
}}
displayConfig={{
fontSize: "small-font",
}}
/>
)}
/>
</div>
<div className="flex gap-1 self-end">
<button
type="submit"
disabled={isSubmitting}
className="group shadow-md rounded-sm border border-success-strong bg-success-primary p-2 duration-300 hover:bg-success-primary"
>
<CheckIcon className="h-3 w-3 text-on-color" strokeWidth={2} />
</button>
<button
type="button"
className="group shadow-md rounded-sm border border-danger-strong bg-danger-primary p-2 duration-300 hover:bg-danger-primary-hover"
onClick={() => setIsEditing(false)}
>
<CloseIcon className="h-3 w-3 text-on-color" strokeWidth={2} />
</button>
</div>
</form>
<div className={`${isEditing ? "hidden" : ""}`}>
<LiteTextEditor
editable={false}
anchor={anchor}
workspaceId={workspaceID?.toString() ?? ""}
ref={showEditorRef}
id={comment.id}
initialValue={comment.comment_html}
displayConfig={{
fontSize: "small-font",
}}
/>
<CommentReactions anchor={anchor} commentId={comment.id} />
</div>
</div>
</div>
{!isInIframe && currentUser?.id === comment?.actor_detail?.id && (
<Menu as="div" className="relative w-min text-left">
<Menu.Button
type="button"
onClick={() => {}}
className="relative grid cursor-pointer place-items-center rounded-sm p-1 text-tertiary outline-none hover:bg-layer-transparent-hover"
>
<MoreVertical className="size-4" strokeWidth={2} />
</Menu.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="shadow-lg absolute right-0 z-10 mt-1 max-h-36 min-w-[8rem] origin-top-right overflow-auto overflow-y-scroll rounded-md border border-strong bg-surface-1 p-1 text-11 whitespace-nowrap focus:outline-none">
<Menu.Item>
{({ active }) => (
<div className="py-1">
<button
type="button"
onClick={() => {
setIsEditing(true);
}}
className={`w-full truncate rounded-sm px-1 py-1.5 text-left text-secondary select-none hover:bg-layer-transparent-hover ${
active ? "bg-layer-transparent-hover" : ""
}`}
>
Edit
</button>
</div>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<div className="py-1">
<button
type="button"
onClick={handleDelete}
className={`w-full truncate rounded-sm px-1 py-1.5 text-left text-secondary select-none hover:bg-layer-transparent-hover ${
active ? "bg-layer-transparent-hover" : ""
}`}
>
Delete
</button>
</div>
)}
</Menu.Item>
</Menu.Items>
</Transition>
</Menu>
)}
</div>
);
});
@@ -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 React, { useMemo, useState } from "react";
import { observer } from "mobx-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
// plane imports
import { stringToEmoji } from "@plane/propel/emoji-icon-picker";
import { EmojiReactionGroup, EmojiReactionPicker } from "@plane/propel/emoji-reaction";
import type { EmojiReactionType } from "@plane/propel/emoji-reaction";
// helpers
import { groupReactions } from "@/helpers/emoji.helper";
import { queryParamGenerator } from "@/helpers/query-param-generator";
// hooks
import { useIssueDetails } from "@/hooks/store/use-issue-details";
import { useUser } from "@/hooks/store/use-user";
import useIsInIframe from "@/hooks/use-is-in-iframe";
type Props = {
anchor: string;
commentId: string;
};
export const CommentReactions = observer(function CommentReactions(props: Props) {
const { anchor, commentId } = props;
// state
const [isPickerOpen, setIsPickerOpen] = useState(false);
const router = useRouter();
const pathName = usePathname();
const searchParams = useSearchParams();
// query params
const board = searchParams.get("board") || undefined;
const state = searchParams.get("state") || undefined;
const priority = searchParams.get("priority") || undefined;
const labels = searchParams.get("labels") || undefined;
// hooks
const { addCommentReaction, removeCommentReaction, details, peekId } = useIssueDetails();
const { data: user } = useUser();
const isInIframe = useIsInIframe();
const commentReactions = useMemo(() => {
if (!peekId) return [];
const peekDetails = details[peekId];
if (!peekDetails) return [];
const comment = peekDetails.comments?.find((c) => c.id === commentId);
return comment?.comment_reactions ?? [];
}, [peekId, details, commentId]);
const groupedReactions = useMemo(() => {
if (!peekId) return {};
return groupReactions(commentReactions ?? [], "reaction");
}, [peekId, commentReactions]);
const userReactions = commentReactions?.filter((r) => r?.actor_detail?.id === user?.id);
const handleAddReaction = (reactionHex: string) => {
if (!anchor || !peekId) return;
addCommentReaction(anchor, peekId, commentId, reactionHex);
};
const handleRemoveReaction = (reactionHex: string) => {
if (!anchor || !peekId) return;
removeCommentReaction(anchor, peekId, commentId, reactionHex);
};
const handleReactionClick = (reactionHex: string) => {
const userReaction = userReactions?.find((r) => r.actor_detail.id === user?.id && r.reaction === reactionHex);
if (userReaction) handleRemoveReaction(reactionHex);
else handleAddReaction(reactionHex);
};
// derived values
const { queryParam } = queryParamGenerator({ peekId, board, state, priority, labels });
// Transform reactions data to Propel EmojiReactionType format
const propelReactions: EmojiReactionType[] = useMemo(() => {
const REACTIONS_LIMIT = 1000;
return Object.keys(groupedReactions || {})
.filter((reaction) => groupedReactions?.[reaction]?.length > 0)
.map((reaction) => {
const reactionList = groupedReactions?.[reaction] ?? [];
const userNames = reactionList
.map((r) => r?.actor_detail?.display_name)
.filter((name): name is string => !!name)
.slice(0, REACTIONS_LIMIT);
return {
emoji: stringToEmoji(reaction),
count: reactionList.length,
reacted: commentReactions?.some((r) => r?.actor_detail?.id === user?.id && r.reaction === reaction) || false,
users: userNames,
};
});
}, [groupedReactions, commentReactions, user?.id]);
const handleEmojiClick = (emoji: string) => {
if (isInIframe) return;
if (!user) {
router.push(`/?next_path=${pathName}?${queryParam}`);
return;
}
// Convert emoji back to decimal string format for the API
const emojiCodePoints = Array.from(emoji)
.map((char) => char.codePointAt(0))
.filter((cp): cp is number => cp !== undefined);
const reactionString = emojiCodePoints.join("-");
handleReactionClick(reactionString);
};
const handleEmojiSelect = (emoji: string) => {
if (!user) {
router.push(`/?next_path=${pathName}?${queryParam}`);
return;
}
// emoji is already in decimal string format from EmojiReactionPicker
handleReactionClick(emoji);
};
return (
<div className="mt-2">
<EmojiReactionPicker
isOpen={isPickerOpen}
handleToggle={setIsPickerOpen}
onChange={handleEmojiSelect}
disabled={isInIframe}
label={
<EmojiReactionGroup
reactions={propelReactions}
onReactionClick={handleEmojiClick}
showAddButton={!isInIframe}
onAddReaction={() => setIsPickerOpen(true)}
/>
}
placement="bottom-start"
/>
</div>
);
});