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 { useTranslation } from 'react-i18next';
import DOMPurify from 'dompurify';
@@ -10,7 +10,6 @@ import {
IMentionMemberSelectOption,
IMentionMemberViewModel,
} from '@/types/project/projectComments.types';
import { projectsApiService } from '@/api/projects/projects.api.service';
import { projectCommentsApiService } from '@/api/projects/comments/project-comments.api.service';
import { IProjectUpdateCommentViewModel } from '@/types/project/project.types';
import { calculateTimeDifference } from '@/utils/calculate-time-difference';
@@ -21,6 +20,19 @@ import { DeleteOutlined } from '@ant-design/icons';
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 { projectId } = useParams();
const [characterLength, setCharacterLength] = useState<number>(0);
@@ -88,7 +100,16 @@ const ProjectViewUpdates = () => {
const res = await projectCommentsApiService.createProjectComment(body);
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();
}
} catch (error) {
@@ -155,6 +176,18 @@ const ProjectViewUpdates = () => {
[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(() => ({
components: {
Button: {
@@ -164,47 +197,64 @@ const ProjectViewUpdates = () => {
},
}), []);
const renderComment = useCallback((comment: IProjectUpdateCommentViewModel) => {
const sanitizedContent = DOMPurify.sanitize(comment.content || '');
const timeDifference = calculateTimeDifference(comment.created_at || '');
const themeClass = theme === 'dark' ? 'dark' : 'light';
return (
<Flex key={comment.id} gap={8}>
<CustomAvatar avatarName={comment.created_by || ''} />
<Flex vertical flex={1}>
<Space>
<Typography.Text strong style={{ fontSize: 13, color: colors.lightGray }}>
{comment.created_by || ''}
</Typography.Text>
<Tooltip title={comment.created_at}>
<Typography.Text style={{ fontSize: 13, color: colors.deepLightGray }}>
{timeDifference}
</Typography.Text>
</Tooltip>
</Space>
<Typography.Paragraph
style={{ margin: '8px 0' }}
ellipsis={{ rows: 3, expandable: true }}
>
<div className={`mentions-${themeClass}`} dangerouslySetInnerHTML={{ __html: sanitizedContent }} />
</Typography.Paragraph>
<ConfigProvider
wave={{ disabled: true }}
theme={configProviderTheme}
>
<Button
icon={<DeleteOutlined />}
shape="circle"
type="text"
size='small'
onClick={() => handleDeleteComment(comment.id)}
/>
</ConfigProvider>
</Flex>
</Flex>
);
}, [theme, configProviderTheme, handleDeleteComment]);
// Context menu for each comment (memoized)
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 themeClass = theme === 'dark' ? 'dark' : 'light';
return (
<Dropdown
key={comment.id ?? ''}
overlay={getCommentMenu(comment.id ?? '')}
trigger={["contextMenu"]}
>
<div>
<Flex gap={8}>
<CustomAvatar avatarName={comment.created_by || ''} />
<Flex vertical flex={1}>
<Space>
<Typography.Text strong style={{ fontSize: 13, color: colors.lightGray }}>
{comment.created_by || ''}
</Typography.Text>
<Tooltip title={comment.created_at}>
<Typography.Text style={{ fontSize: 13, color: colors.deepLightGray }}>
{timeDifference}
</Typography.Text>
</Tooltip>
</Space>
<Typography.Paragraph style={{ margin: '8px 0' }} ellipsis={{ rows: 3, expandable: true }}>
<div
className={`mentions-${themeClass}`}
dangerouslySetInnerHTML={{ __html: sanitizedContent }}
onClick={handleCommentLinkClick}
/>
</Typography.Paragraph>
</Flex>
</Flex>
</div>
</Dropdown>
);
},
[theme, configProviderTheme, handleDeleteComment, handleCommentLinkClick]
);
const commentsList = useMemo(() =>
comments.map(renderComment), [comments, renderComment]