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,47 +197,64 @@ const ProjectViewUpdates = () => {
}, },
}), []); }), []);
const renderComment = useCallback((comment: IProjectUpdateCommentViewModel) => { // Context menu for each comment (memoized)
const sanitizedContent = DOMPurify.sanitize(comment.content || ''); const getCommentMenu = useCallback((commentId: string) => (
const timeDifference = calculateTimeDifference(comment.created_at || ''); <Menu>
const themeClass = theme === 'dark' ? 'dark' : 'light'; <Menu.Item key="delete">
<Popconfirm
return ( title="Are you sure you want to delete this comment?"
<Flex key={comment.id} gap={8}> onConfirm={() => handleDeleteComment(commentId)}
<CustomAvatar avatarName={comment.created_by || ''} /> okText="Yes"
<Flex vertical flex={1}> cancelText="No"
<Space> >
<Typography.Text strong style={{ fontSize: 13, color: colors.lightGray }}> Delete
{comment.created_by || ''} </Popconfirm>
</Typography.Text> </Menu.Item>
<Tooltip title={comment.created_at}> </Menu>
<Typography.Text style={{ fontSize: 13, color: colors.deepLightGray }}> ), [handleDeleteComment]);
{timeDifference}
</Typography.Text> const renderComment = useCallback(
</Tooltip> (comment: IProjectUpdateCommentViewModel) => {
</Space> const linkifiedContent = linkify(comment.content || '');
<Typography.Paragraph const sanitizedContent = DOMPurify.sanitize(linkifiedContent);
style={{ margin: '8px 0' }} const timeDifference = calculateTimeDifference(comment.created_at || '');
ellipsis={{ rows: 3, expandable: true }} const themeClass = theme === 'dark' ? 'dark' : 'light';
>
<div className={`mentions-${themeClass}`} dangerouslySetInnerHTML={{ __html: sanitizedContent }} /> return (
</Typography.Paragraph> <Dropdown
<ConfigProvider key={comment.id ?? ''}
wave={{ disabled: true }} overlay={getCommentMenu(comment.id ?? '')}
theme={configProviderTheme} trigger={["contextMenu"]}
> >
<Button <div>
icon={<DeleteOutlined />} <Flex gap={8}>
shape="circle" <CustomAvatar avatarName={comment.created_by || ''} />
type="text" <Flex vertical flex={1}>
size='small' <Space>
onClick={() => handleDeleteComment(comment.id)} <Typography.Text strong style={{ fontSize: 13, color: colors.lightGray }}>
/> {comment.created_by || ''}
</ConfigProvider> </Typography.Text>
</Flex> <Tooltip title={comment.created_at}>
</Flex> <Typography.Text style={{ fontSize: 13, color: colors.deepLightGray }}>
); {timeDifference}
}, [theme, configProviderTheme, handleDeleteComment]); </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(() => const commentsList = useMemo(() =>
comments.map(renderComment), [comments, renderComment] comments.map(renderComment), [comments, renderComment]