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