feat(project-view-updates): add linkification and context menu for comments

- Implemented linkification for URLs in comments, allowing users to click and open links in a new tab.
- Introduced a context menu for each comment with an option to delete, enhancing user interaction.
- Refactored comment rendering to include link handling and improved code organization.
This commit is contained in:
shancds
2025-06-19 12:39:32 +05:30
parent 889335c579
commit b47b3253f6

View File

@@ -1,4 +1,4 @@
import { Button, ConfigProvider, Flex, Form, Mentions, Skeleton, Space, Tooltip, Typography } from 'antd'; import { Button, ConfigProvider, Flex, Form, Mentions, Skeleton, Space, Tooltip, Typography, Dropdown, Menu, Popconfirm } from 'antd';
import { useEffect, useState, useCallback, useMemo } from 'react'; import { useEffect, useState, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
@@ -10,7 +10,6 @@ import {
IMentionMemberSelectOption, IMentionMemberSelectOption,
IMentionMemberViewModel, IMentionMemberViewModel,
} from '@/types/project/projectComments.types'; } from '@/types/project/projectComments.types';
import { projectsApiService } from '@/api/projects/projects.api.service';
import { projectCommentsApiService } from '@/api/projects/comments/project-comments.api.service'; import { projectCommentsApiService } from '@/api/projects/comments/project-comments.api.service';
import { IProjectUpdateCommentViewModel } from '@/types/project/project.types'; import { IProjectUpdateCommentViewModel } from '@/types/project/project.types';
import { calculateTimeDifference } from '@/utils/calculate-time-difference'; import { calculateTimeDifference } from '@/utils/calculate-time-difference';
@@ -21,6 +20,19 @@ import { DeleteOutlined } from '@ant-design/icons';
const MAX_COMMENT_LENGTH = 2000; const MAX_COMMENT_LENGTH = 2000;
// Compile RegExp once for linkify
const urlRegex = /((https?:\/\/|www\.)[\w\-._~:/?#[\]@!$&'()*+,;=%]+)/gi;
function linkify(text: string): string {
return text.replace(
urlRegex,
url => {
const href = url.startsWith('http') ? url : `https://${url}`;
return `<a href="${href}" target="_blank" rel="noopener noreferrer">${url}</a>`;
}
);
}
const ProjectViewUpdates = () => { const ProjectViewUpdates = () => {
const { projectId } = useParams(); const { projectId } = useParams();
const [characterLength, setCharacterLength] = useState<number>(0); const [characterLength, setCharacterLength] = useState<number>(0);
@@ -88,7 +100,16 @@ const ProjectViewUpdates = () => {
const res = await projectCommentsApiService.createProjectComment(body); const res = await projectCommentsApiService.createProjectComment(body);
if (res.done) { if (res.done) {
await getComments(); setComments(prev => [
...prev,
{
...(res.body as IProjectUpdateCommentViewModel),
created_by: getUserSession()?.name || '',
created_at: new Date().toISOString(),
content: commentValue.trim(),
mentions: (res.body as IProjectUpdateCommentViewModel).mentions ?? [undefined, undefined],
}
]);
handleCancel(); handleCancel();
} }
} catch (error) { } catch (error) {
@@ -155,6 +176,18 @@ const ProjectViewUpdates = () => {
[getComments] [getComments]
); );
// Memoize link click handler for comment links
const handleCommentLinkClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement;
if (target.tagName === 'A') {
e.preventDefault();
const href = (target as HTMLAnchorElement).getAttribute('href');
if (href) {
window.open(href, '_blank', 'noopener,noreferrer');
}
}
}, []);
const configProviderTheme = useMemo(() => ({ const configProviderTheme = useMemo(() => ({
components: { components: {
Button: { Button: {
@@ -164,13 +197,37 @@ const ProjectViewUpdates = () => {
}, },
}), []); }), []);
const renderComment = useCallback((comment: IProjectUpdateCommentViewModel) => { // Context menu for each comment (memoized)
const sanitizedContent = DOMPurify.sanitize(comment.content || ''); const getCommentMenu = useCallback((commentId: string) => (
<Menu>
<Menu.Item key="delete">
<Popconfirm
title="Are you sure you want to delete this comment?"
onConfirm={() => handleDeleteComment(commentId)}
okText="Yes"
cancelText="No"
>
Delete
</Popconfirm>
</Menu.Item>
</Menu>
), [handleDeleteComment]);
const renderComment = useCallback(
(comment: IProjectUpdateCommentViewModel) => {
const linkifiedContent = linkify(comment.content || '');
const sanitizedContent = DOMPurify.sanitize(linkifiedContent);
const timeDifference = calculateTimeDifference(comment.created_at || ''); const timeDifference = calculateTimeDifference(comment.created_at || '');
const themeClass = theme === 'dark' ? 'dark' : 'light'; const themeClass = theme === 'dark' ? 'dark' : 'light';
return ( return (
<Flex key={comment.id} gap={8}> <Dropdown
key={comment.id ?? ''}
overlay={getCommentMenu(comment.id ?? '')}
trigger={["contextMenu"]}
>
<div>
<Flex gap={8}>
<CustomAvatar avatarName={comment.created_by || ''} /> <CustomAvatar avatarName={comment.created_by || ''} />
<Flex vertical flex={1}> <Flex vertical flex={1}>
<Space> <Space>
@@ -183,28 +240,21 @@ const ProjectViewUpdates = () => {
</Typography.Text> </Typography.Text>
</Tooltip> </Tooltip>
</Space> </Space>
<Typography.Paragraph <Typography.Paragraph style={{ margin: '8px 0' }} ellipsis={{ rows: 3, expandable: true }}>
style={{ margin: '8px 0' }} <div
ellipsis={{ rows: 3, expandable: true }} className={`mentions-${themeClass}`}
> dangerouslySetInnerHTML={{ __html: sanitizedContent }}
<div className={`mentions-${themeClass}`} dangerouslySetInnerHTML={{ __html: sanitizedContent }} /> onClick={handleCommentLinkClick}
</Typography.Paragraph>
<ConfigProvider
wave={{ disabled: true }}
theme={configProviderTheme}
>
<Button
icon={<DeleteOutlined />}
shape="circle"
type="text"
size='small'
onClick={() => handleDeleteComment(comment.id)}
/> />
</ConfigProvider> </Typography.Paragraph>
</Flex> </Flex>
</Flex> </Flex>
</div>
</Dropdown>
);
},
[theme, configProviderTheme, handleDeleteComment, handleCommentLinkClick]
); );
}, [theme, configProviderTheme, handleDeleteComment]);
const commentsList = useMemo(() => const commentsList = useMemo(() =>
comments.map(renderComment), [comments, renderComment] comments.map(renderComment), [comments, renderComment]