base
This commit is contained in:
@@ -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>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user