diff --git a/apps/web/ce/components/comments/comment-block.tsx b/apps/web/ce/components/comments/comment-block.tsx index 11b98d6cb..08c5bb331 100644 --- a/apps/web/ce/components/comments/comment-block.tsx +++ b/apps/web/ce/components/comments/comment-block.tsx @@ -6,7 +6,6 @@ import { TIssueComment } from "@plane/types"; import { Avatar, Tooltip } from "@plane/ui"; import { calculateTimeAgo, cn, getFileURL, renderFormattedDate, renderFormattedTime } from "@plane/utils"; // hooks -// import { useMember } from "@/hooks/store"; type TCommentBlock = { @@ -18,13 +17,17 @@ type TCommentBlock = { export const CommentBlock: FC = observer((props) => { const { comment, ends, quickActions, children } = props; + // refs const commentBlockRef = useRef(null); // store hooks const { getUserDetails } = useMember(); - const { t } = useTranslation(); + // derived values const userDetails = getUserDetails(comment?.actor); + // translation + const { t } = useTranslation(); + + if (!comment || !userDetails) return null; - if (!comment || !userDetails) return <>; return (
; + showAccessSpecifier: boolean; + workspaceId: string; + workspaceSlug: string; +}; + +export const CommentCardDisplay: React.FC = observer((props) => { + const { + activityOperations, + comment, + disabled, + projectId, + readOnlyEditorRef, + showAccessSpecifier, + workspaceId, + workspaceSlug, + } = props; + // states + const [highlightClassName, setHighlightClassName] = useState(""); + // navigation + const pathname = usePathname(); + // derived values + const commentBlockId = `comment-${comment?.id}`; + // scroll to comment + const { isHashMatch } = useHashScroll({ + elementId: commentBlockId, + pathname, + }); + + useEffect(() => { + if (!isHashMatch) return; + setHighlightClassName("border-custom-primary-100"); + const timeout = setTimeout(() => { + setHighlightClassName(""); + }, 8000); + + return () => clearTimeout(timeout); + }, [isHashMatch]); + + return ( +
+ {showAccessSpecifier && ( +
+ {comment.access === EIssueCommentAccessSpecifier.INTERNAL ? ( + + ) : ( + + )} +
+ )} + + +
+ ); +}); diff --git a/apps/web/core/components/comments/card/edit-form.tsx b/apps/web/core/components/comments/card/edit-form.tsx new file mode 100644 index 000000000..5b837bd68 --- /dev/null +++ b/apps/web/core/components/comments/card/edit-form.tsx @@ -0,0 +1,130 @@ +import React, { useEffect, useRef } from "react"; +import { observer } from "mobx-react"; +import { useForm } from "react-hook-form"; +import { Check, X } from "lucide-react"; +// plane imports +import type { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor"; +import type { TCommentsOperations, TIssueComment } from "@plane/types"; +import { isCommentEmpty } from "@plane/utils"; +// components +import { LiteTextEditor } from "@/components/editor"; + +type Props = { + activityOperations: TCommentsOperations; + comment: TIssueComment; + isEditing: boolean; + projectId?: string; + readOnlyEditorRef: EditorReadOnlyRefApi | null; + setIsEditing: (isEditing: boolean) => void; + workspaceId: string; + workspaceSlug: string; +}; + +export const CommentCardEditForm: React.FC = observer((props) => { + const { + activityOperations, + comment, + isEditing, + projectId, + readOnlyEditorRef, + setIsEditing, + workspaceId, + workspaceSlug, + } = props; + // refs + const editorRef = useRef(null); + // form info + const { + formState: { isSubmitting }, + handleSubmit, + setFocus, + watch, + setValue, + } = useForm>({ + defaultValues: { comment_html: comment?.comment_html }, + }); + const commentHTML = watch("comment_html"); + + const isEmpty = isCommentEmpty(commentHTML ?? undefined); + const isEditorReadyToDiscard = editorRef.current?.isEditorReadyToDiscard(); + const isSubmitButtonDisabled = isSubmitting || !isEditorReadyToDiscard; + const isDisabled = isSubmitting || isEmpty || isSubmitButtonDisabled; + + const onEnter = async (formData: Partial) => { + if (isSubmitting || !comment) return; + + setIsEditing(false); + + await activityOperations.updateComment(comment.id, formData); + + editorRef.current?.setEditorValue(formData?.comment_html ?? "

"); + readOnlyEditorRef?.setEditorValue(formData?.comment_html ?? "

"); + }; + + useEffect(() => { + if (isEditing) { + setFocus("comment_html"); + } + }, [isEditing, setFocus]); + + return ( +
+
{ + if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey && !isEmpty) handleSubmit(onEnter)(e); + }} + > + 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; + }} + projectId={projectId?.toString() ?? ""} + parentClassName="p-2" + displayConfig={{ + fontSize: "small-font", + }} + /> +
+
+ {!isEmpty && ( + + )} + +
+
+ ); +}); diff --git a/apps/web/core/components/comments/card/root.tsx b/apps/web/core/components/comments/card/root.tsx new file mode 100644 index 000000000..fac2e079c --- /dev/null +++ b/apps/web/core/components/comments/card/root.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { FC, useRef, useState } from "react"; +import { observer } from "mobx-react"; +// plane imports +import type { EditorReadOnlyRefApi } from "@plane/editor"; +import type { TIssueComment, TCommentsOperations } from "@plane/types"; +// plane web imports +import { CommentBlock } 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; + comment: TIssueComment | undefined; + activityOperations: TCommentsOperations; + ends: "top" | "bottom" | undefined; + showAccessSpecifier: boolean; + showCopyLinkOption: boolean; + disabled?: boolean; + projectId?: string; +}; + +export const CommentCard: FC = observer((props) => { + const { + workspaceSlug, + comment, + activityOperations, + ends, + showAccessSpecifier, + showCopyLinkOption, + disabled = false, + projectId, + } = props; + const readOnlyEditorRef = useRef(null); + // states + const [isEditing, setIsEditing] = useState(false); + // derived values + const workspaceId = comment?.workspace; + + if (!comment || !workspaceId) return null; + + return ( + setIsEditing(true)} + showAccessSpecifier={showAccessSpecifier} + showCopyLinkOption={showCopyLinkOption} + /> + ) + } + ends={ends} + > + {isEditing ? ( + + ) : ( + + )} + + ); +}); diff --git a/apps/web/core/components/comments/comment-card.tsx b/apps/web/core/components/comments/comment-card.tsx deleted file mode 100644 index 66dd75427..000000000 --- a/apps/web/core/components/comments/comment-card.tsx +++ /dev/null @@ -1,224 +0,0 @@ -"use client"; - -import { FC, useEffect, useRef, useState } from "react"; -import { observer } from "mobx-react"; -import { useForm } from "react-hook-form"; -import { Check, Globe2, Lock, Pencil, Trash2, X } from "lucide-react"; -// PLane -import { EIssueCommentAccessSpecifier } from "@plane/constants"; -import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor"; -import { useTranslation } from "@plane/i18n"; -import { TIssueComment, TCommentsOperations } from "@plane/types"; -import { CustomMenu } from "@plane/ui"; -// components -import { isCommentEmpty } from "@plane/utils"; -import { LiteTextEditor, LiteTextReadOnlyEditor } from "@/components/editor"; -// helpers -// hooks -import { useUser } from "@/hooks/store"; -// -import { CommentBlock } from "@/plane-web/components/comments"; -import { CommentReactions } from "./comment-reaction"; - -type TCommentCard = { - workspaceSlug: string; - comment: TIssueComment | undefined; - activityOperations: TCommentsOperations; - ends: "top" | "bottom" | undefined; - showAccessSpecifier?: boolean; - disabled?: boolean; - projectId?: string; -}; - -export const CommentCard: FC = observer((props) => { - const { - workspaceSlug, - comment, - activityOperations, - ends, - showAccessSpecifier = false, - disabled = false, - projectId, - } = props; - const { t } = useTranslation(); - // refs - const editorRef = useRef(null); - const showEditorRef = useRef(null); - // state - const [isEditing, setIsEditing] = useState(false); - // store hooks - const { data: currentUser } = useUser(); - // form info - const { - formState: { isSubmitting }, - handleSubmit, - setFocus, - watch, - setValue, - } = useForm>({ - defaultValues: { comment_html: comment?.comment_html }, - }); - // derived values - const workspaceId = comment?.workspace; - const commentHTML = watch("comment_html"); - const isEmpty = isCommentEmpty(commentHTML ?? undefined); - const isEditorReadyToDiscard = editorRef.current?.isEditorReadyToDiscard(); - const isSubmitButtonDisabled = isSubmitting || !isEditorReadyToDiscard; - const isDisabled = isSubmitting || isEmpty || isSubmitButtonDisabled; - - // helpers - const onEnter = async (formData: Partial) => { - if (isSubmitting || !comment) return; - - setIsEditing(false); - - await activityOperations.updateComment(comment.id, formData); - - editorRef.current?.setEditorValue(formData?.comment_html ?? "

"); - showEditorRef.current?.setEditorValue(formData?.comment_html ?? "

"); - }; - - useEffect(() => { - if (isEditing) { - setFocus("comment_html"); - } - }, [isEditing, setFocus]); - - if (!comment || !currentUser || !workspaceId) return <>; - - return ( - - {!disabled && currentUser?.id === comment.actor && ( - - setIsEditing(true)} className="flex items-center gap-1"> - - {t("common.actions.edit")} - - {showAccessSpecifier && ( - <> - {comment.access === "INTERNAL" ? ( - - activityOperations.updateComment(comment.id, { access: EIssueCommentAccessSpecifier.EXTERNAL }) - } - className="flex items-center gap-1" - > - - {t("issue.comments.switch.public")} - - ) : ( - - activityOperations.updateComment(comment.id, { access: EIssueCommentAccessSpecifier.INTERNAL }) - } - className="flex items-center gap-1" - > - - {t("issue.comments.switch.private")} - - )} - - )} - activityOperations.removeComment(comment.id)} - className="flex items-center gap-1" - > - - {t("common.actions.delete")} - - - )} - - } - ends={ends} - > - <> -
-
{ - if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey && !isEmpty) handleSubmit(onEnter)(e); - }} - > - 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; - }} - projectId={projectId?.toString() ?? ""} - parentClassName="p-2" - displayConfig={{ - fontSize: "small-font", - }} - /> -
-
- {!isEmpty && ( - - )} - -
-
-
- {showAccessSpecifier && ( -
- {comment.access === EIssueCommentAccessSpecifier.INTERNAL ? ( - - ) : ( - - )} -
- )} - - -
- -
- ); -}); diff --git a/apps/web/core/components/comments/comments.tsx b/apps/web/core/components/comments/comments.tsx index bbda0f45f..bc15adcc1 100644 --- a/apps/web/core/components/comments/comments.tsx +++ b/apps/web/core/components/comments/comments.tsx @@ -4,11 +4,10 @@ import React, { FC, useMemo } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // plane imports -import smoothScrollIntoView from "smooth-scroll-into-view-if-needed"; import { E_SORT_ORDER } from "@plane/constants"; import { TCommentsOperations, TIssueComment } from "@plane/types"; // local components -import { CommentCard } from "./comment-card"; +import { CommentCard } from "./card/root"; import { CommentCreate } from "./comment-create"; type TCommentsWrapper = { @@ -19,10 +18,21 @@ type TCommentsWrapper = { comments: TIssueComment[] | string[]; sortOrder?: E_SORT_ORDER; getCommentById?: (activityId: string) => TIssueComment | undefined; + showAccessSpecifier?: boolean; + showCopyLinkOption?: boolean; }; export const CommentsWrapper: FC = observer((props) => { - const { entityId, activityOperations, comments, getCommentById, isEditingAllowed = true, projectId } = props; + const { + entityId, + activityOperations, + comments, + getCommentById, + isEditingAllowed = true, + projectId, + showAccessSpecifier = false, + showCopyLinkOption = false, + } = props; // router const { workspaceSlug: routerWorkspaceSlug } = useParams(); const workspaceSlug = routerWorkspaceSlug?.toString(); @@ -61,6 +71,8 @@ export const CommentsWrapper: FC = observer((props) => { disabled={!isEditingAllowed} ends={index === 0 ? "top" : index === comments.length - 1 ? "bottom" : undefined} projectId={projectId} + showAccessSpecifier={showAccessSpecifier} + showCopyLinkOption={showCopyLinkOption} /> ); })} diff --git a/apps/web/core/components/comments/quick-actions.tsx b/apps/web/core/components/comments/quick-actions.tsx new file mode 100644 index 000000000..894e32630 --- /dev/null +++ b/apps/web/core/components/comments/quick-actions.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { FC, useMemo } from "react"; +import { observer } from "mobx-react"; +import { Globe2, Link, Lock, Pencil, Trash2 } from "lucide-react"; +// plane imports +import { EIssueCommentAccessSpecifier } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import type { TIssueComment, TCommentsOperations } from "@plane/types"; +import { CustomMenu, TContextMenuItem } from "@plane/ui"; +import { cn } from "@plane/utils"; +// hooks +import { useUser } from "@/hooks/store"; + +type TCommentCard = { + activityOperations: TCommentsOperations; + comment: TIssueComment; + setEditMode: () => void; + showAccessSpecifier: boolean; + showCopyLinkOption: boolean; +}; + +export const CommentQuickActions: FC = observer((props) => { + const { activityOperations, comment, setEditMode, showAccessSpecifier, showCopyLinkOption } = props; + // store hooks + const { data: currentUser } = useUser(); + // derived values + const isAuthor = currentUser?.id === comment.actor; + const canEdit = isAuthor; + const canDelete = isAuthor; + // translation + const { t } = useTranslation(); + + const MENU_ITEMS: TContextMenuItem[] = useMemo( + () => [ + { + key: "edit", + action: setEditMode, + title: t("common.actions.edit"), + icon: Pencil, + shouldRender: canEdit, + }, + { + key: "copy_link", + action: () => activityOperations.copyCommentLink(comment.id), + title: t("common.actions.copy_link"), + icon: Link, + shouldRender: showCopyLinkOption, + }, + { + key: "access_specifier", + action: () => + activityOperations.updateComment(comment.id, { + access: + comment.access === EIssueCommentAccessSpecifier.INTERNAL + ? EIssueCommentAccessSpecifier.EXTERNAL + : EIssueCommentAccessSpecifier.INTERNAL, + }), + title: + comment.access === EIssueCommentAccessSpecifier.INTERNAL + ? t("issue.comments.switch.public") + : t("issue.comments.switch.private"), + icon: comment.access === EIssueCommentAccessSpecifier.INTERNAL ? Globe2 : Lock, + shouldRender: showAccessSpecifier, + }, + { + key: "delete", + action: () => activityOperations.removeComment(comment.id), + title: t("common.actions.delete"), + icon: Trash2, + shouldRender: canDelete, + }, + ], + [activityOperations, canDelete, canEdit, comment, setEditMode, showAccessSpecifier, showCopyLinkOption] + ); + + return ( + + {MENU_ITEMS.map((item) => { + if (item.shouldRender === false) return null; + + return ( + item.action()} + className={cn( + "flex items-center gap-2", + { + "text-custom-text-400": item.disabled, + }, + item.className + )} + disabled={item.disabled} + > + {item.icon && } +
+
{item.title}
+ {item.description && ( +

+ {item.description} +

+ )} +
+
+ ); + })} +
+ ); +}); diff --git a/apps/web/core/components/issues/issue-detail/issue-activity/activity-comment-root.tsx b/apps/web/core/components/issues/issue-detail/issue-activity/activity-comment-root.tsx index 2e9047098..d9ef5ef1a 100644 --- a/apps/web/core/components/issues/issue-detail/issue-activity/activity-comment-root.tsx +++ b/apps/web/core/components/issues/issue-detail/issue-activity/activity-comment-root.tsx @@ -4,7 +4,7 @@ import { observer } from "mobx-react"; import { E_SORT_ORDER, TActivityFilters, filterActivityOnSelectedFilters } from "@plane/constants"; // hooks import { TCommentsOperations } from "@plane/types"; -import { CommentCard } from "@/components/comments/comment-card"; +import { CommentCard } from "@/components/comments/card/root"; import { useIssueDetail } from "@/hooks/store"; // plane web components import { IssueAdditionalPropertiesActivity } from "@/plane-web/components/issues"; @@ -57,7 +57,8 @@ export const IssueActivityCommentRoot: FC = observer( comment={comment} activityOperations={activityOperations} ends={index === 0 ? "top" : index === filteredActivityComments.length - 1 ? "bottom" : undefined} - showAccessSpecifier={showAccessSpecifier} + showAccessSpecifier={!!showAccessSpecifier} + showCopyLinkOption disabled={disabled} projectId={projectId} /> diff --git a/apps/web/core/components/issues/issue-detail/issue-activity/helper.tsx b/apps/web/core/components/issues/issue-detail/issue-activity/helper.tsx index b4332afd1..884c2325e 100644 --- a/apps/web/core/components/issues/issue-detail/issue-activity/helper.tsx +++ b/apps/web/core/components/issues/issue-detail/issue-activity/helper.tsx @@ -1,9 +1,9 @@ import { useMemo } from "react"; import { useTranslation } from "@plane/i18n"; -import { EFileAssetType, TCommentsOperations, TIssueComment } from "@plane/types"; +import { EFileAssetType, type TCommentsOperations } from "@plane/types"; import { setToast, TOAST_TYPE } from "@plane/ui"; -import { formatTextList } from "@plane/utils"; -import { useEditorAsset, useIssueDetail, useMember, useUser } from "@/hooks/store"; +import { copyUrlToClipboard, formatTextList, generateWorkItemLink } from "@plane/utils"; +import { useEditorAsset, useIssueDetail, useMember, useProject, useUser } from "@/hooks/store"; export const useCommentOperations = ( workspaceSlug: string | undefined, @@ -18,16 +18,49 @@ export const useCommentOperations = ( removeComment, createCommentReaction, removeCommentReaction, + issue: { getIssueById }, } = useIssueDetail(); + const { getProjectById } = useProject(); const { getUserDetails } = useMember(); const { uploadEditorAsset } = useEditorAsset(); const { data: currentUser } = useUser(); + // derived values + const issueDetails = issueId ? getIssueById(issueId) : undefined; + const projectDetails = projectId ? getProjectById(projectId) : undefined; + // translation const { t } = useTranslation(); - const operations = useMemo(() => { + const operations: TCommentsOperations = useMemo(() => { // Define operations object with all methods - const ops = { - createComment: async (data: Partial) => { + const ops: TCommentsOperations = { + copyCommentLink: (id) => { + if (!workspaceSlug || !issueDetails) return; + try { + const workItemLink = generateWorkItemLink({ + workspaceSlug, + projectId: issueDetails.project_id, + issueId, + projectIdentifier: projectDetails?.identifier, + sequenceId: issueDetails.sequence_id, + }); + const commentLink = `${workItemLink}#comment-${id}`; + copyUrlToClipboard(commentLink).then(() => { + setToast({ + title: t("common.success"), + type: TOAST_TYPE.SUCCESS, + message: t("issue.comments.copy_link.success"), + }); + }); + } catch (error) { + console.error("Error in copying comment link:", error); + setToast({ + title: t("common.error.label"), + type: TOAST_TYPE.ERROR, + message: t("issue.comments.copy_link.error"), + }); + } + }, + createComment: async (data) => { try { if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields"); const comment = await createComment(workspaceSlug, projectId, issueId, data); @@ -45,7 +78,7 @@ export const useCommentOperations = ( }); } }, - updateComment: async (commentId: string, data: Partial) => { + updateComment: async (commentId, data) => { try { if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields"); await updateComment(workspaceSlug, projectId, issueId, commentId, data); @@ -62,7 +95,7 @@ export const useCommentOperations = ( }); } }, - removeComment: async (commentId: string) => { + removeComment: async (commentId) => { try { if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields"); await removeComment(workspaceSlug, projectId, issueId, commentId); @@ -79,7 +112,7 @@ export const useCommentOperations = ( }); } }, - uploadCommentAsset: async (blockId: string, file: File, commentId?: string) => { + uploadCommentAsset: async (blockId, file, commentId) => { try { if (!workspaceSlug || !projectId) throw new Error("Missing fields"); const res = await uploadEditorAsset({ @@ -98,7 +131,7 @@ export const useCommentOperations = ( throw new Error(t("issue.comments.upload.error")); } }, - addCommentReaction: async (commentId: string, reaction: string) => { + addCommentReaction: async (commentId, reaction) => { try { if (!workspaceSlug || !projectId || !commentId) throw new Error("Missing fields"); await createCommentReaction(workspaceSlug, projectId, commentId, reaction); @@ -107,7 +140,7 @@ export const useCommentOperations = ( type: TOAST_TYPE.SUCCESS, message: "Reaction created successfully", }); - } catch (error) { + } catch { setToast({ title: "Error!", type: TOAST_TYPE.ERROR, @@ -115,7 +148,7 @@ export const useCommentOperations = ( }); } }, - deleteCommentReaction: async (commentId: string, reaction: string) => { + deleteCommentReaction: async (commentId, reaction) => { try { if (!workspaceSlug || !projectId || !commentId || !currentUser?.id) throw new Error("Missing fields"); removeCommentReaction(workspaceSlug, projectId, commentId, reaction, currentUser.id); @@ -124,7 +157,7 @@ export const useCommentOperations = ( type: TOAST_TYPE.SUCCESS, message: "Reaction removed successfully", }); - } catch (error) { + } catch { setToast({ title: "Error!", type: TOAST_TYPE.ERROR, @@ -132,14 +165,14 @@ export const useCommentOperations = ( }); } }, - react: async (commentId: string, reactionEmoji: string, userReactions: string[]) => { + react: async (commentId, reactionEmoji, userReactions) => { if (userReactions.includes(reactionEmoji)) await ops.deleteCommentReaction(commentId, reactionEmoji); else await ops.addCommentReaction(commentId, reactionEmoji); }, - reactionIds: (commentId: string) => getCommentReactionsByCommentId(commentId), - userReactions: (commentId: string) => + reactionIds: (commentId) => getCommentReactionsByCommentId(commentId), + userReactions: (commentId) => currentUser ? commentReactionsByUser(commentId, currentUser?.id).map((r) => r.reaction) : [], - getReactionUsers: (reaction: string, reactionIds: Record): string => { + getReactionUsers: (reaction, reactionIds) => { const reactionUsers = (reactionIds?.[reaction] || []) .map((reactionId) => { const reactionDetails = getCommentReactionById(reactionId); diff --git a/packages/hooks/src/index.ts b/packages/hooks/src/index.ts index 11c33cfa4..a71e06bf5 100644 --- a/packages/hooks/src/index.ts +++ b/packages/hooks/src/index.ts @@ -1,3 +1,4 @@ +export * from "./use-hash-scroll"; export * from "./use-local-storage"; export * from "./use-outside-click-detector"; export * from "./use-platform-os"; diff --git a/packages/hooks/src/use-hash-scroll.ts b/packages/hooks/src/use-hash-scroll.ts new file mode 100644 index 000000000..268d3e179 --- /dev/null +++ b/packages/hooks/src/use-hash-scroll.ts @@ -0,0 +1,128 @@ +import { useCallback, useEffect, useState } from "react"; + +type TArgs = { + elementId: string; + pathname: string; + scrollDelay?: number; +}; + +type TReturnType = { + isHashMatch: boolean; + hashIds: string[]; + scrollToElement: () => boolean; +}; + +/** + * Custom hook for handling hash-based scrolling to a specific element + * Supports multiple IDs in URL hash (comma-separated, space-separated, or other delimiters) + * + * @param {TArgs} args - The ID of the element to scroll to + * @returns {TReturnType} Object containing hash match status and scroll function + */ +export const useHashScroll = (args: TArgs): TReturnType => { + const { elementId, pathname, scrollDelay = 200 } = args; + // State to track if the current hash contains the provided element ID + const [isHashMatch, setIsHashMatch] = useState(false); + // State to track all IDs found in the hash + const [hashIds, setHashIds] = useState([]); + + /** + * Scrolls to the element with the provided ID + * @returns {boolean} - Whether the scroll was successful + */ + const scrollToElement = useCallback((): boolean => { + try { + const element = document.getElementById(elementId); + + if (element) { + setTimeout(() => { + element.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + }, scrollDelay); + + return true; + } + + return false; + } catch (error) { + console.warn("Hash scroll error:", error); + return false; + } + }, [elementId, scrollDelay]); + + /** + * Extracts multiple IDs from hash string + * Supports various delimiters: comma, space, pipe, semicolon + * @param {string} hashString - The hash part of the URL + * @returns {string[]} - Array of clean ID strings + */ + const extractIdsFromHash = (hashString: string | null): string[] => { + if (!hashString) return []; + + // Split by common delimiters and clean up + return hashString + .split(/[,\s|;]+/) // Split by comma, space, pipe, or semicolon + .map((id) => id.trim()) // Remove whitespace + .filter((id) => id.length > 0); // Remove empty strings + }; + + /** + * Get current hash from window.location + * @returns {string | null} - Current hash without the # symbol + */ + const getCurrentHash = (): string | null => { + if (typeof window === "undefined") return null; + const hash = window.location.hash; + return hash ? hash.slice(1) : null; // Remove the # symbol + }; + + // Effect to handle hash changes and initial load + useEffect(() => { + if (!elementId) { + setIsHashMatch(false); + setHashIds([]); + return; + } + + const handleHashChange = () => { + const hash = getCurrentHash(); + + // Extract all IDs from the hash + const idsInHash = extractIdsFromHash(hash); + setHashIds(idsInHash); + + // Check if provided element ID is present in the hash + const hashMatches = idsInHash.includes(elementId); + setIsHashMatch(hashMatches); + + // If hash matches, attempt to scroll to the element + if (hashMatches) { + scrollToElement(); + } + }; + + // Handle initial load + handleHashChange(); + + // Listen for hash changes + window.addEventListener("hashchange", handleHashChange); + + return () => { + window.removeEventListener("hashchange", handleHashChange); + }; + }, [elementId, pathname, scrollToElement]); // Include pathname to handle route changes + + // Return object with hash match status and utility functions + return { + // Whether the current URL hash contains the provided element ID + isHashMatch, + + // Array of all IDs found in the current hash + hashIds, + + // Manually trigger scroll to the element + scrollToElement, + }; +}; diff --git a/packages/i18n/src/locales/cs/translations.json b/packages/i18n/src/locales/cs/translations.json index e378e220d..307289bb1 100644 --- a/packages/i18n/src/locales/cs/translations.json +++ b/packages/i18n/src/locales/cs/translations.json @@ -1042,6 +1042,10 @@ }, "upload": { "error": "Nahrání přílohy se nezdařilo. Zkuste to prosím později." + }, + "copy_link": { + "success": "Odkaz na komentář byl zkopírován do schránky", + "error": "Chyba při kopírování odkazu na komentář. Zkuste to prosím později." } }, "empty_state": { diff --git a/packages/i18n/src/locales/de/translations.json b/packages/i18n/src/locales/de/translations.json index 2ab869005..3c086c8cf 100644 --- a/packages/i18n/src/locales/de/translations.json +++ b/packages/i18n/src/locales/de/translations.json @@ -1042,6 +1042,10 @@ }, "upload": { "error": "Anhang konnte nicht hochgeladen werden. Bitte versuchen Sie es später erneut." + }, + "copy_link": { + "success": "Kommentar-Link in die Zwischenablage kopiert", + "error": "Fehler beim Kopieren des Kommentar-Links. Bitte versuchen Sie es später erneut." } }, "empty_state": { diff --git a/packages/i18n/src/locales/en/translations.json b/packages/i18n/src/locales/en/translations.json index 176eae758..6d77eb341 100644 --- a/packages/i18n/src/locales/en/translations.json +++ b/packages/i18n/src/locales/en/translations.json @@ -885,6 +885,10 @@ }, "upload": { "error": "Asset upload failed. Please try again later." + }, + "copy_link": { + "success": "Comment link copied to clipboard", + "error": "Error copying comment link. Please try again later." } }, "empty_state": { diff --git a/packages/i18n/src/locales/es/translations.json b/packages/i18n/src/locales/es/translations.json index 44d4e7328..0f17e8f8a 100644 --- a/packages/i18n/src/locales/es/translations.json +++ b/packages/i18n/src/locales/es/translations.json @@ -1045,6 +1045,10 @@ }, "upload": { "error": "Error al subir el archivo. Por favor, inténtalo más tarde." + }, + "copy_link": { + "success": "Enlace del comentario copiado al portapapeles", + "error": "Error al copiar el enlace del comentario. Inténtelo de nuevo más tarde." } }, "empty_state": { diff --git a/packages/i18n/src/locales/fr/translations.json b/packages/i18n/src/locales/fr/translations.json index 0e230167a..a078526fe 100644 --- a/packages/i18n/src/locales/fr/translations.json +++ b/packages/i18n/src/locales/fr/translations.json @@ -1043,6 +1043,10 @@ }, "upload": { "error": "Échec du téléchargement du fichier. Veuillez réessayer plus tard." + }, + "copy_link": { + "success": "Lien du commentaire copié dans le presse-papiers", + "error": "Erreur lors de la copie du lien du commentaire. Veuillez réessayer plus tard." } }, "empty_state": { diff --git a/packages/i18n/src/locales/id/translations.json b/packages/i18n/src/locales/id/translations.json index fa1734a30..01f6d1424 100644 --- a/packages/i18n/src/locales/id/translations.json +++ b/packages/i18n/src/locales/id/translations.json @@ -1042,6 +1042,10 @@ }, "upload": { "error": "Gagal mengunggah aset. Silakan coba lagi nanti." + }, + "copy_link": { + "success": "Tautan komentar berhasil disalin ke clipboard", + "error": "Gagal menyalin tautan komentar. Silakan coba lagi nanti." } }, "empty_state": { diff --git a/packages/i18n/src/locales/it/translations.json b/packages/i18n/src/locales/it/translations.json index 5d7629bc5..fc457eeb0 100644 --- a/packages/i18n/src/locales/it/translations.json +++ b/packages/i18n/src/locales/it/translations.json @@ -1041,6 +1041,10 @@ }, "upload": { "error": "Caricamento dell'asset fallito. Per favore, riprova più tardi." + }, + "copy_link": { + "success": "Link del commento copiato negli appunti", + "error": "Errore durante la copia del link del commento. Riprova più tardi." } }, "empty_state": { diff --git a/packages/i18n/src/locales/ja/translations.json b/packages/i18n/src/locales/ja/translations.json index 60a76c57d..e92e0a182 100644 --- a/packages/i18n/src/locales/ja/translations.json +++ b/packages/i18n/src/locales/ja/translations.json @@ -1043,6 +1043,10 @@ }, "upload": { "error": "アセットのアップロードに失敗しました。後でもう一度お試しください。" + }, + "copy_link": { + "success": "コメントリンクがクリップボードにコピーされました", + "error": "コメントリンクのコピーに失敗しました。後でもう一度お試しください。" } }, "empty_state": { diff --git a/packages/i18n/src/locales/ko/translations.json b/packages/i18n/src/locales/ko/translations.json index e500de010..3f253931b 100644 --- a/packages/i18n/src/locales/ko/translations.json +++ b/packages/i18n/src/locales/ko/translations.json @@ -1044,6 +1044,10 @@ }, "upload": { "error": "자산 업로드 실패. 나중에 다시 시도해주세요." + }, + "copy_link": { + "success": "댓글 링크가 클립보드에 복사되었습니다", + "error": "댓글 링크 복사 중 오류가 발생했습니다. 나중에 다시 시도해 주세요." } }, "empty_state": { diff --git a/packages/i18n/src/locales/pl/translations.json b/packages/i18n/src/locales/pl/translations.json index 49cf5b1a0..fdb2d0958 100644 --- a/packages/i18n/src/locales/pl/translations.json +++ b/packages/i18n/src/locales/pl/translations.json @@ -1044,6 +1044,10 @@ }, "upload": { "error": "Nie udało się przesłać załącznika. Spróbuj później." + }, + "copy_link": { + "success": "Link do komentarza skopiowany do schowka", + "error": "Błąd podczas kopiowania linka do komentarza. Spróbuj ponownie później." } }, "empty_state": { diff --git a/packages/i18n/src/locales/pt-BR/translations.json b/packages/i18n/src/locales/pt-BR/translations.json index ace7e923b..d12918556 100644 --- a/packages/i18n/src/locales/pt-BR/translations.json +++ b/packages/i18n/src/locales/pt-BR/translations.json @@ -1044,6 +1044,10 @@ }, "upload": { "error": "Falha ao carregar o recurso. Por favor, tente novamente mais tarde." + }, + "copy_link": { + "success": "Link do comentário copiado para a área de transferência", + "error": "Erro ao copiar o link do comentário. Tente novamente mais tarde." } }, "empty_state": { diff --git a/packages/i18n/src/locales/ro/translations.json b/packages/i18n/src/locales/ro/translations.json index 9b66d0e4b..caa1178d1 100644 --- a/packages/i18n/src/locales/ro/translations.json +++ b/packages/i18n/src/locales/ro/translations.json @@ -1042,6 +1042,10 @@ }, "upload": { "error": "Încărcarea fișierului a eșuat. Te rugăm să încerci mai târziu." + }, + "copy_link": { + "success": "Linkul comentariului a fost copiat în clipboard", + "error": "Eroare la copierea linkului comentariului. Încercați din nou mai târziu." } }, "empty_state": { diff --git a/packages/i18n/src/locales/ru/translations.json b/packages/i18n/src/locales/ru/translations.json index 413c0989e..8481b596c 100644 --- a/packages/i18n/src/locales/ru/translations.json +++ b/packages/i18n/src/locales/ru/translations.json @@ -1044,6 +1044,10 @@ }, "upload": { "error": "Ошибка загрузки файла. Попробуйте позже." + }, + "copy_link": { + "success": "Ссылка на комментарий скопирована в буфер обмена", + "error": "Ошибка при копировании ссылки на комментарий. Попробуйте позже." } }, "empty_state": { diff --git a/packages/i18n/src/locales/sk/translations.json b/packages/i18n/src/locales/sk/translations.json index 1e01d86af..7c89fc131 100644 --- a/packages/i18n/src/locales/sk/translations.json +++ b/packages/i18n/src/locales/sk/translations.json @@ -1044,6 +1044,10 @@ }, "upload": { "error": "Nahratie prílohy zlyhalo. Skúste to prosím neskôr." + }, + "copy_link": { + "success": "Odkaz na komentár bol skopírovaný do schránky", + "error": "Chyba pri kopírovaní odkazu na komentár. Skúste to prosím neskôr." } }, "empty_state": { diff --git a/packages/i18n/src/locales/tr-TR/translations.json b/packages/i18n/src/locales/tr-TR/translations.json index fabaad1f0..65c830abf 100644 --- a/packages/i18n/src/locales/tr-TR/translations.json +++ b/packages/i18n/src/locales/tr-TR/translations.json @@ -1045,6 +1045,10 @@ }, "upload": { "error": "Dosya yüklenemedi. Lütfen daha sonra tekrar deneyin." + }, + "copy_link": { + "success": "Yorum bağlantısı panoya kopyalandı", + "error": "Yorum bağlantısı kopyalanırken hata oluştu. Lütfen daha sonra tekrar deneyin." } }, "empty_state": { diff --git a/packages/i18n/src/locales/ua/translations.json b/packages/i18n/src/locales/ua/translations.json index fcfe7c172..1a3ab64f9 100644 --- a/packages/i18n/src/locales/ua/translations.json +++ b/packages/i18n/src/locales/ua/translations.json @@ -1044,6 +1044,10 @@ }, "upload": { "error": "Не вдалося завантажити вкладення. Спробуйте пізніше." + }, + "copy_link": { + "success": "Посилання на коментар скопійовано в буфер обміну", + "error": "Помилка при копіюванні посилання на коментар. Спробуйте пізніше." } }, "empty_state": { diff --git a/packages/i18n/src/locales/vi-VN/translations.json b/packages/i18n/src/locales/vi-VN/translations.json index abfc703d1..947f10b16 100644 --- a/packages/i18n/src/locales/vi-VN/translations.json +++ b/packages/i18n/src/locales/vi-VN/translations.json @@ -1043,6 +1043,10 @@ }, "upload": { "error": "Không thể tải lên tài nguyên. Vui lòng thử lại sau." + }, + "copy_link": { + "success": "Liên kết bình luận đã được sao chép vào clipboard", + "error": "Lỗi khi sao chép liên kết bình luận. Vui lòng thử lại sau." } }, "empty_state": { diff --git a/packages/i18n/src/locales/zh-CN/translations.json b/packages/i18n/src/locales/zh-CN/translations.json index 1d0791c0b..444936c50 100644 --- a/packages/i18n/src/locales/zh-CN/translations.json +++ b/packages/i18n/src/locales/zh-CN/translations.json @@ -1043,6 +1043,10 @@ }, "upload": { "error": "资源上传失败。请稍后重试。" + }, + "copy_link": { + "success": "评论链接已复制到剪贴板", + "error": "复制评论链接时出错。请稍后再试。" } }, "empty_state": { diff --git a/packages/i18n/src/locales/zh-TW/translations.json b/packages/i18n/src/locales/zh-TW/translations.json index d53667df0..724c434db 100644 --- a/packages/i18n/src/locales/zh-TW/translations.json +++ b/packages/i18n/src/locales/zh-TW/translations.json @@ -1044,6 +1044,10 @@ }, "upload": { "error": "資產上傳失敗。請稍後再試。" + }, + "copy_link": { + "success": "評論連結已複製到剪貼簿", + "error": "複製評論連結時出錯。請稍後再試。" } }, "empty_state": { diff --git a/packages/types/src/issues/activity/issue_comment.ts b/packages/types/src/issues/activity/issue_comment.ts index 3cb76d39e..1a4b557b5 100644 --- a/packages/types/src/issues/activity/issue_comment.ts +++ b/packages/types/src/issues/activity/issue_comment.ts @@ -41,12 +41,13 @@ export type TIssueComment = { }; export type TCommentsOperations = { + copyCommentLink: (commentId: string) => void; createComment: (data: Partial) => Promise | undefined>; updateComment: (commentId: string, data: Partial) => Promise; removeComment: (commentId: string) => Promise; uploadCommentAsset: (blockId: string, file: File, commentId?: string) => Promise; addCommentReaction: (commentId: string, reactionEmoji: string) => Promise; - deleteCommentReaction: (commentId: string, reactionEmoji: string, userReactions: TCommentReaction[]) => Promise; + deleteCommentReaction: (commentId: string, reactionEmoji: string) => Promise; react: (commentId: string, reactionEmoji: string, userReactions: string[]) => Promise; reactionIds: (commentId: string) => | {