diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index 162dccdcd9..03e9d09e91 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -1534,6 +1534,8 @@ def issue_activity( IssueActivitySerializer(issue_activities_created, many=True).data, cls=DjangoJSONEncoder, ), + requested_data=requested_data, + current_instance=current_instance ) return diff --git a/apiserver/plane/bgtasks/notification_task.py b/apiserver/plane/bgtasks/notification_task.py index f290a38c00..c95e941ef6 100644 --- a/apiserver/plane/bgtasks/notification_task.py +++ b/apiserver/plane/bgtasks/notification_task.py @@ -5,16 +5,98 @@ import json from django.utils import timezone # Module imports -from plane.db.models import IssueSubscriber, Project, IssueAssignee, Issue, Notification +from plane.db.models import IssueMention, IssueSubscriber, Project, User, IssueAssignee, Issue, Notification # Third Party imports from celery import shared_task +from bs4 import BeautifulSoup + + +def get_new_mentions(requested_instance, current_instance): + # requested_data is the newer instance of the current issue + # current_instance is the older instance of the current issue, saved in the database + + # extract mentions from both the instance of data + mentions_older = extract_mentions(current_instance) + mentions_newer = extract_mentions(requested_instance) + + # Getting Set Difference from mentions_newer + new_mentions = [ + mention for mention in mentions_newer if mention not in mentions_older] + + return new_mentions + +# Get Removed Mention + + +def get_removed_mentions(requested_instance, current_instance): + # requested_data is the newer instance of the current issue + # current_instance is the older instance of the current issue, saved in the database + + # extract mentions from both the instance of data + mentions_older = extract_mentions(current_instance) + mentions_newer = extract_mentions(requested_instance) + + # Getting Set Difference from mentions_newer + removed_mentions = [ + mention for mention in mentions_older if mention not in mentions_newer] + + return removed_mentions + +# Adds mentions as subscribers + + +def extract_mentions_as_subscribers(project_id, issue_id, mentions): + # mentions is an array of User IDs representing the FILTERED set of mentioned users + + bulk_mention_subscribers = [] + + for mention_id in mentions: + # If the particular mention has not already been subscribed to the issue, he must be sent the mentioned notification + if not IssueSubscriber.objects.filter( + issue_id=issue_id, + subscriber=mention_id, + project=project_id, + ).exists(): + mentioned_user = User.objects.get(pk=mention_id) + + project = Project.objects.get(pk=project_id) + issue = Issue.objects.get(pk=issue_id) + + bulk_mention_subscribers.append(IssueSubscriber( + workspace=project.workspace, + project=project, + issue=issue, + subscriber=mentioned_user, + )) + return bulk_mention_subscribers + +# Parse Issue Description & extracts mentions + + +def extract_mentions(issue_instance): + try: + # issue_instance has to be a dictionary passed, containing the description_html and other set of activity data. + mentions = [] + # Convert string to dictionary + data = json.loads(issue_instance) + html = data.get("description_html") + soup = BeautifulSoup(html, 'html.parser') + mention_tags = soup.find_all( + 'mention-component', attrs={'target': 'users'}) + + mentions = [mention_tag['id'] for mention_tag in mention_tags] + + return list(set(mentions)) + except Exception as e: + return [] @shared_task -def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activities_created): +def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activities_created, requested_data, current_instance): issue_activities_created = ( - json.loads(issue_activities_created) if issue_activities_created is not None else None + json.loads( + issue_activities_created) if issue_activities_created is not None else None ) if type not in [ "cycle.activity.created", @@ -33,14 +115,35 @@ def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activi ]: # Create Notifications bulk_notifications = [] + + """ + Mention Tasks + 1. Perform Diffing and Extract the mentions, that mention notification needs to be sent + 2. From the latest set of mentions, extract the users which are not a subscribers & make them subscribers + """ + + # Get new mentions from the newer instance + new_mentions = get_new_mentions( + requested_instance=requested_data, current_instance=current_instance) + removed_mention = get_removed_mentions( + requested_instance=requested_data, current_instance=current_instance) + + # Get New Subscribers from the mentions of the newer instance + requested_mentions = extract_mentions( + issue_instance=requested_data) + mention_subscribers = extract_mentions_as_subscribers( + project_id=project_id, issue_id=issue_id, mentions=requested_mentions) + issue_subscribers = list( - IssueSubscriber.objects.filter(project_id=project_id, issue_id=issue_id) - .exclude(subscriber_id=actor_id) + IssueSubscriber.objects.filter( + project_id=project_id, issue_id=issue_id) + .exclude(subscriber_id__in=list(new_mentions + [actor_id])) .values_list("subscriber", flat=True) ) issue_assignees = list( - IssueAssignee.objects.filter(project_id=project_id, issue_id=issue_id) + IssueAssignee.objects.filter( + project_id=project_id, issue_id=issue_id) .exclude(assignee_id=actor_id) .values_list("assignee", flat=True) ) @@ -89,7 +192,8 @@ def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activi "new_value": str(issue_activity.get("new_value")), "old_value": str(issue_activity.get("old_value")), "issue_comment": str( - issue_activity.get("issue_comment").comment_stripped + issue_activity.get( + "issue_comment").comment_stripped if issue_activity.get("issue_comment") is not None else "" ), @@ -98,5 +202,62 @@ def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activi ) ) + # Add Mentioned as Issue Subscribers + IssueSubscriber.objects.bulk_create( + mention_subscribers, batch_size=100) + + for mention_id in new_mentions: + if (mention_id != actor_id): + for issue_activity in issue_activities_created: + bulk_notifications.append( + Notification( + workspace=project.workspace, + sender="in_app:issue_activities:mention", + triggered_by_id=actor_id, + receiver_id=mention_id, + entity_identifier=issue_id, + entity_name="issue", + project=project, + message=f"You have been mentioned in the issue {issue.name}", + data={ + "issue": { + "id": str(issue_id), + "name": str(issue.name), + "identifier": str(issue.project.identifier), + "sequence_id": issue.sequence_id, + "state_name": issue.state.name, + "state_group": issue.state.group, + }, + "issue_activity": { + "id": str(issue_activity.get("id")), + "verb": str(issue_activity.get("verb")), + "field": str(issue_activity.get("field")), + "actor": str(issue_activity.get("actor_id")), + "new_value": str(issue_activity.get("new_value")), + "old_value": str(issue_activity.get("old_value")), + }, + }, + ) + ) + + # Create New Mentions Here + aggregated_issue_mentions = [] + + for mention_id in new_mentions: + mentioned_user = User.objects.get(pk=mention_id) + aggregated_issue_mentions.append( + IssueMention( + mention=mentioned_user, + issue=issue, + project=project, + workspace=project.workspace + ) + ) + + IssueMention.objects.bulk_create( + aggregated_issue_mentions, batch_size=100) + IssueMention.objects.filter( + issue=issue.id, mention__in=removed_mention).delete() + # Bulk create notifications Notification.objects.bulk_create(bulk_notifications, batch_size=100) diff --git a/apiserver/plane/db/migrations/0046_alter_analyticview_created_by_and_more.py b/apiserver/plane/db/migrations/0046_alter_analyticview_created_by_and_more.py index 4890ec9d58..51082d14c7 100644 --- a/apiserver/plane/db/migrations/0046_alter_analyticview_created_by_and_more.py +++ b/apiserver/plane/db/migrations/0046_alter_analyticview_created_by_and_more.py @@ -5,7 +5,6 @@ from django.db import migrations, models import django.db.models.deletion import plane.db.models.issue - class Migration(migrations.Migration): dependencies = [ @@ -18,4 +17,4 @@ class Migration(migrations.Migration): name='properties', field=models.JSONField(default=plane.db.models.issue.get_default_properties), ), - ] + ] \ No newline at end of file diff --git a/apiserver/plane/db/migrations/0047_issue_mention_and_more.py b/apiserver/plane/db/migrations/0047_issue_mention_and_more.py new file mode 100644 index 0000000000..4300f1eb39 --- /dev/null +++ b/apiserver/plane/db/migrations/0047_issue_mention_and_more.py @@ -0,0 +1,45 @@ +# Generated by Django 4.2.5 on 2023-10-25 05:01 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0046_alter_analyticview_created_by_and_more'), + ] + + operations = [ + migrations.CreateModel( + name="issue_mentions", + fields=[ + ('created_at', models.DateTimeField( + auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField( + auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, + editable=False, primary_key=True, serialize=False, unique=True)), + ('mention', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, + related_name='issue_mention', to=settings.AUTH_USER_MODEL)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, + related_name='issuemention_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, + related_name='issue_mention', to='db.issue')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, + related_name='project_issuemention', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, + related_name='issuemention_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, + related_name='workspace_issuemention', to='db.workspace')), + ], + options={ + 'verbose_name': 'IssueMention', + 'verbose_name_plural': 'IssueMentions', + 'db_table': 'issue_mentions', + 'ordering': ('-created_at',), + }, + ) + ] diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index 9496b5906c..42f566baff 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -33,6 +33,7 @@ from .issue import ( Label, IssueBlocker, IssueRelation, + IssueMention, IssueLink, IssueSequence, IssueAttachment, diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index 9ba73fd433..0c227a158d 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -226,7 +226,26 @@ class IssueRelation(ProjectBaseModel): ordering = ("-created_at",) def __str__(self): - return f"{self.issue.name} {self.related_issue.name}" + return f"{self.issue.name} {self.related_issue.name}" + +class IssueMention(ProjectBaseModel): + issue = models.ForeignKey( + Issue, on_delete=models.CASCADE, related_name="issue_mention" + ) + mention = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="issue_mention", + ) + class Meta: + unique_together = ["issue", "mention"] + verbose_name = "Issue Mention" + verbose_name_plural = "Issue Mentions" + db_table = "issue_mentions" + ordering = ("-created_at",) + + def __str__(self): + return f"{self.issue.name} {self.mention.email}" class IssueAssignee(ProjectBaseModel): diff --git a/apiserver/plane/utils/issue_filters.py b/apiserver/plane/utils/issue_filters.py index eaeb4553ec..cc466c847c 100644 --- a/apiserver/plane/utils/issue_filters.py +++ b/apiserver/plane/utils/issue_filters.py @@ -150,6 +150,17 @@ def filter_assignees(params, filter, method): filter["assignees__in"] = params.get("assignees") return filter +def filter_mentions(params, filter, method): + if method == "GET": + mentions = [item for item in params.get("mentions").split(",") if item != 'null'] + mentions = filter_valid_uuids(mentions) + if len(mentions) and "" not in mentions: + filter["issue_mention__mention__id__in"] = mentions + else: + if params.get("mentions", None) and len(params.get("mentions")) and params.get("mentions") != 'null': + filter["issue_mention__mention__id__in"] = params.get("mentions") + return filter + def filter_created_by(params, filter, method): if method == "GET": @@ -326,6 +337,7 @@ def issue_filters(query_params, method): "parent": filter_parent, "labels": filter_labels, "assignees": filter_assignees, + "mentions": filter_mentions, "created_by": filter_created_by, "name": filter_name, "created_at": filter_created_at, diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt index 969ab3c893..249b29d48c 100644 --- a/apiserver/requirements/base.txt +++ b/apiserver/requirements/base.txt @@ -33,4 +33,5 @@ django_celery_beat==2.5.0 psycopg-binary==3.1.10 psycopg-c==3.1.10 scout-apm==2.26.1 -openpyxl==3.1.2 \ No newline at end of file +openpyxl==3.1.2 +beautifulsoup4==4.12.2 \ No newline at end of file diff --git a/packages/editor/core/package.json b/packages/editor/core/package.json index 2c35ead1c8..b6f4df8e59 100644 --- a/packages/editor/core/package.json +++ b/packages/editor/core/package.json @@ -21,18 +21,18 @@ "check-types": "tsc --noEmit" }, "peerDependencies": { - "react": "^18.2.0", - "react-dom": "18.2.0", "next": "12.3.2", - "next-themes": "^0.2.1" + "next-themes": "^0.2.1", + "react": "^18.2.0", + "react-dom": "18.2.0" }, "dependencies": { - "react-moveable" : "^0.54.2", "@blueprintjs/popover2": "^2.0.10", "@tiptap/core": "^2.1.7", "@tiptap/extension-color": "^2.1.11", "@tiptap/extension-image": "^2.1.7", "@tiptap/extension-link": "^2.1.7", + "@tiptap/extension-mention": "^2.1.12", "@tiptap/extension-table": "^2.1.6", "@tiptap/extension-table-cell": "^2.1.6", "@tiptap/extension-table-header": "^2.1.6", @@ -44,9 +44,10 @@ "@tiptap/pm": "^2.1.7", "@tiptap/react": "^2.1.7", "@tiptap/starter-kit": "^2.1.10", + "@tiptap/suggestion": "^2.0.4", + "@types/node": "18.15.3", "@types/react": "^18.2.5", "@types/react-dom": "18.0.11", - "@types/node": "18.15.3", "class-variance-authority": "^0.7.0", "clsx": "^1.2.1", "eslint": "8.36.0", @@ -54,6 +55,7 @@ "eventsource-parser": "^0.1.0", "lucide-react": "^0.244.0", "react-markdown": "^8.0.7", + "react-moveable": "^0.54.2", "tailwind-merge": "^1.14.0", "tippy.js": "^6.3.7", "tiptap-markdown": "^0.8.2", diff --git a/packages/editor/core/src/types/mention-suggestion.ts b/packages/editor/core/src/types/mention-suggestion.ts new file mode 100644 index 0000000000..9c9ab76069 --- /dev/null +++ b/packages/editor/core/src/types/mention-suggestion.ts @@ -0,0 +1,10 @@ +export type IMentionSuggestion = { + id: string; + type: string; + avatar: string; + title: string; + subtitle: string; + redirect_uri: string; +} + +export type IMentionHighlight = string \ No newline at end of file diff --git a/packages/editor/core/src/ui/extensions/index.tsx b/packages/editor/core/src/ui/extensions/index.tsx index 62d53e0e1d..9bf5f0d9b4 100644 --- a/packages/editor/core/src/ui/extensions/index.tsx +++ b/packages/editor/core/src/ui/extensions/index.tsx @@ -17,9 +17,12 @@ import ImageExtension from "./image"; import { DeleteImage } from "../../types/delete-image"; import { isValidHttpUrl } from "../../lib/utils"; +import { IMentionSuggestion } from "../../types/mention-suggestion"; +import { Mentions } from "../mentions"; export const CoreEditorExtensions = ( + mentionConfig: { mentionSuggestions: IMentionSuggestion[], mentionHighlights: string[] }, deleteFile: DeleteImage, ) => [ StarterKit.configure({ @@ -94,4 +97,5 @@ export const CoreEditorExtensions = ( TableHeader, CustomTableCell, TableRow, + Mentions(mentionConfig.mentionSuggestions, mentionConfig.mentionHighlights, false), ]; diff --git a/packages/editor/core/src/ui/hooks/useEditor.tsx b/packages/editor/core/src/ui/hooks/useEditor.tsx index f58c7964b6..9fcf200fb9 100644 --- a/packages/editor/core/src/ui/hooks/useEditor.tsx +++ b/packages/editor/core/src/ui/hooks/useEditor.tsx @@ -12,6 +12,7 @@ import { EditorProps } from "@tiptap/pm/view"; import { getTrimmedHTML } from "../../lib/utils"; import { UploadImage } from "../../types/upload-image"; import { useInitializedContent } from "./useInitializedContent"; +import { IMentionSuggestion } from "../../types/mention-suggestion"; interface CustomEditorProps { uploadFile: UploadImage; @@ -26,6 +27,8 @@ interface CustomEditorProps { extensions?: any; editorProps?: EditorProps; forwardedRef?: any; + mentionHighlights?: string[]; + mentionSuggestions?: IMentionSuggestion[]; } export const useEditor = ({ @@ -38,6 +41,8 @@ export const useEditor = ({ setIsSubmitting, forwardedRef, setShouldShowAlert, + mentionHighlights, + mentionSuggestions }: CustomEditorProps) => { const editor = useCustomEditor( { @@ -45,7 +50,7 @@ export const useEditor = ({ ...CoreEditorProps(uploadFile, setIsSubmitting), ...editorProps, }, - extensions: [...CoreEditorExtensions(deleteFile), ...extensions], + extensions: [...CoreEditorExtensions({ mentionSuggestions: mentionSuggestions ?? [], mentionHighlights: mentionHighlights ?? []}, deleteFile), ...extensions], content: typeof value === "string" && value.trim() !== "" ? value : "
", onUpdate: async ({ editor }) => { @@ -77,4 +82,4 @@ export const useEditor = ({ } return editor; -}; +}; \ No newline at end of file diff --git a/packages/editor/core/src/ui/hooks/useReadOnlyEditor.tsx b/packages/editor/core/src/ui/hooks/useReadOnlyEditor.tsx index 522cd94b83..9243c2f4e7 100644 --- a/packages/editor/core/src/ui/hooks/useReadOnlyEditor.tsx +++ b/packages/editor/core/src/ui/hooks/useReadOnlyEditor.tsx @@ -7,21 +7,19 @@ import { } from "react"; import { CoreReadOnlyEditorExtensions } from "../../ui/read-only/extensions"; import { CoreReadOnlyEditorProps } from "../../ui/read-only/props"; -import { EditorProps } from "@tiptap/pm/view"; +import { EditorProps } from '@tiptap/pm/view'; +import { IMentionSuggestion } from "../../types/mention-suggestion"; interface CustomReadOnlyEditorProps { value: string; forwardedRef?: any; extensions?: any; editorProps?: EditorProps; + mentionHighlights?: string[]; + mentionSuggestions?: IMentionSuggestion[]; } -export const useReadOnlyEditor = ({ - value, - forwardedRef, - extensions = [], - editorProps = {}, -}: CustomReadOnlyEditorProps) => { +export const useReadOnlyEditor = ({ value, forwardedRef, extensions = [], editorProps = {}, mentionHighlights, mentionSuggestions}: CustomReadOnlyEditorProps) => { const editor = useCustomEditor({ editable: false, content: @@ -30,7 +28,7 @@ export const useReadOnlyEditor = ({ ...CoreReadOnlyEditorProps, ...editorProps, }, - extensions: [...CoreReadOnlyEditorExtensions, ...extensions], + extensions: [...CoreReadOnlyEditorExtensions({ mentionSuggestions: mentionSuggestions ?? [], mentionHighlights: mentionHighlights ?? []}), ...extensions], }); const hasIntiliazedContent = useRef(false); diff --git a/packages/editor/core/src/ui/index.tsx b/packages/editor/core/src/ui/index.tsx index 3c64e8ba68..513bb106ee 100644 --- a/packages/editor/core/src/ui/index.tsx +++ b/packages/editor/core/src/ui/index.tsx @@ -8,6 +8,7 @@ import { EditorProps } from '@tiptap/pm/view'; import { useEditor } from './hooks/useEditor'; import { EditorContainer } from '../ui/components/editor-container'; import { EditorContentWrapper } from '../ui/components/editor-content'; +import { IMentionSuggestion } from '../types/mention-suggestion'; interface ICoreEditor { value: string; @@ -30,6 +31,8 @@ interface ICoreEditor { key: string; label: "Private" | "Public"; }[]; + mentionHighlights?: string[]; + mentionSuggestions?: IMentionSuggestion[]; extensions?: Extension[]; editorProps?: EditorProps; } @@ -61,7 +64,6 @@ const CoreEditor = ({ const editor = useEditor({ onChange, debouncedUpdatesEnabled, - editable, setIsSubmitting, setShouldShowAlert, value, diff --git a/packages/editor/core/src/ui/mentions/MentionList.tsx b/packages/editor/core/src/ui/mentions/MentionList.tsx new file mode 100644 index 0000000000..0591b2421d --- /dev/null +++ b/packages/editor/core/src/ui/mentions/MentionList.tsx @@ -0,0 +1,111 @@ +import { Editor } from '@tiptap/react'; +import React, { + forwardRef, + useEffect, + useImperativeHandle, + useState, +} from 'react' + +import { IMentionSuggestion } from '../../types/mention-suggestion'; + +interface MentionListProps { + items: IMentionSuggestion[]; + command: (item: { id: string, label: string, target: string, redirect_uri: string }) => void; + editor: Editor; +} + +// eslint-disable-next-line react/display-name +const MentionList = forwardRef((props: MentionListProps, ref) => { + const [selectedIndex, setSelectedIndex] = useState(0) + const selectItem = (index: number) => { + const item = props.items[index] + + if (item) { + props.command({ id: item.id, label: item.title, target: "users", redirect_uri: item.redirect_uri }) + } + } + + const upHandler = () => { + setSelectedIndex(((selectedIndex + props.items.length) - 1) % props.items.length) + } + + const downHandler = () => { + setSelectedIndex((selectedIndex + 1) % props.items.length) + } + + const enterHandler = () => { + selectItem(selectedIndex) + } + + useEffect(() => { + setSelectedIndex(0) + }, [props.items]) + + useImperativeHandle(ref, () => ({ + onKeyDown: ({ event }: { event: KeyboardEvent }) => { + if (event.key === 'ArrowUp') { + upHandler() + return true + } + + if (event.key === 'ArrowDown') { + downHandler() + return true + } + + if (event.key === 'Enter') { + enterHandler() + return true + } + + return false + }, + })) + + return ( + props.items && props.items.length !== 0 ?{item.title}
++ {item.subtitle} +
+No matches found
+ ) + ) : ( +
diff --git a/web/components/pages/create-update-block-inline.tsx b/web/components/pages/create-update-block-inline.tsx
index 45d4635557..1d0da50185 100644
--- a/web/components/pages/create-update-block-inline.tsx
+++ b/web/components/pages/create-update-block-inline.tsx
@@ -18,6 +18,7 @@ import { RichTextEditorWithRef } from "@plane/rich-text-editor";
import { IUser, IPageBlock } from "types";
// fetch-keys
import { PAGE_BLOCKS_LIST } from "constants/fetch-keys";
+import useEditorSuggestions from "hooks/use-editor-suggestions";
type Props = {
handleClose: () => void;
@@ -55,6 +56,8 @@ export const CreateUpdateBlockInline: FC commented {timeAgo(comment.created_at)}
+ ) : (
+