Skip to content

feat: Add user mention functionality to incident comments #4649

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
May 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { AlertDto } from "@/entities/alerts/model";
import { AlertDto, CommentMentionDto } from "@/entities/alerts/model";
import { IncidentDto } from "@/entities/incidents/model";
import { useUsers } from "@/entities/users/model/useUsers";
import UserAvatar from "@/components/navbar/UserAvatar";
Expand All @@ -20,12 +20,13 @@ import { DynamicImageProviderIcon } from "@/components/ui";

// TODO: REFACTOR THIS TO SUPPORT ANY ACTIVITY TYPE, IT'S A MESS!

interface IncidentActivity {
export interface IncidentActivity {
id: string;
type: "comment" | "alert" | "newcomment" | "statuschange" | "assign";
text?: string;
timestamp: string;
initiator?: string | AlertDto;
mentions?: CommentMentionDto[];
}

const ACTION_TYPES = [
Expand Down Expand Up @@ -58,7 +59,7 @@ function Item({
{icon}
</div>
</div>
<div className="py-6 flex-1">{children}</div>
<div className="py-6 flex-1 min-w-0">{children}</div>
</div>
);
}
Expand Down Expand Up @@ -139,6 +140,7 @@ export function IncidentActivity({ incident }: { incident: IncidentDto }) {
? auditEvent.description
: "",
timestamp: auditEvent.timestamp,
mentions: auditEvent.mentions,
} as IncidentActivity;
}) || []
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* Extracts tagged user IDs from Quill editor content
* This is called when a comment is submitted to get the final list of mentions
*
* @param content - HTML content from the Quill editor
* @returns Array of user IDs that were mentioned in the content
*/
export function extractTaggedUsers(content: string): string[] {
const mentionRegex = /data-id="([^"]+)"/g;
const ids = Array.from(content.matchAll(mentionRegex)).map(match => match[1]) || [];
return ids;
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import { IncidentDto } from "@/entities/incidents/model";
import { TextInput, Button } from "@tremor/react";
import { useState, useCallback, useEffect } from "react";
import { Button } from "@tremor/react";
import { useState, useCallback } from "react";
import { toast } from "react-toastify";
import { KeyedMutator } from "swr";
import { useApi } from "@/shared/lib/hooks/useApi";
import { showErrorToast } from "@/shared/ui";
import { AuditEvent } from "@/entities/alerts/model";
import { useUsers } from "@/entities/users/model/useUsers";
import { extractTaggedUsers } from "../lib/extractTaggedUsers";
import { IncidentCommentInput } from "./IncidentCommentInput.dynamic";

/**
* Component for adding comments to an incident with user mention capability
*/
export function IncidentActivityComment({
incident,
mutator,
Expand All @@ -15,13 +21,17 @@ export function IncidentActivityComment({
mutator: KeyedMutator<AuditEvent[]>;
}) {
const [comment, setComment] = useState("");

const api = useApi();

const { data: users = [] } = useUsers();
const onSubmit = useCallback(async () => {
try {
const extractedTaggedUsers = extractTaggedUsers(comment);
await api.post(`/incidents/${incident.id}/comment`, {
status: incident.status,
comment,
tagged_users: extractedTaggedUsers,
});
toast.success("Comment added!", { position: "top-right" });
setComment("");
Expand All @@ -31,42 +41,26 @@ export function IncidentActivityComment({
}
}, [api, incident.id, incident.status, comment, mutator]);

const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
if (
event.key === "Enter" &&
(event.metaKey || event.ctrlKey) &&
comment
) {
onSubmit();
}
},
[onSubmit, comment]
);

useEffect(() => {
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [comment, handleKeyDown]);

return (
<div className="flex h-full w-full relative items-center">
<TextInput
<div className="border border-tremor-border rounded-tremor-default shadow-tremor-input flex flex-col">
<IncidentCommentInput
value={comment}
onValueChange={setComment}
placeholder="Add a new comment..."
users={users}
placeholder="Add a comment..."
className="min-h-11"
/>
<Button
color="orange"
variant="secondary"
className="ml-2.5"
disabled={!comment}
onClick={onSubmit}
>
Comment
</Button>

<div className="flex justify-end p-2">
<Button
color="orange"
variant="primary"
disabled={!comment}
onClick={onSubmit}
>
Comment
</Button>
</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -1,22 +1,36 @@
import { AlertSeverity } from "@/entities/alerts/ui";
import { AlertDto } from "@/entities/alerts/model";
import TimeAgo from "react-timeago";
import { FormattedContent } from "@/shared/ui/FormattedContent/FormattedContent";
import { IncidentActivity } from "../incident-activity";

// TODO: REFACTOR THIS TO SUPPORT ANY ACTIVITY TYPE, IT'S A MESS!

export function IncidentActivityItem({ activity }: { activity: any }) {
export function IncidentActivityItem({ activity }: { activity: IncidentActivity }) {
const title =
typeof activity.initiator === "string"
? activity.initiator
: activity.initiator?.name;
: (activity.initiator as AlertDto)?.name;
const subTitle =
activity.type === "comment"
? " Added a comment. "
: activity.type === "statuschange"
? " Incident status changed. "
: activity.initiator?.status === "firing"
: (activity.initiator as AlertDto)?.status === "firing"
? " triggered"
: " resolved" + ". ";

// Process comment text to style mentions if it's a comment with mentions
const processCommentText = (text: string) => {
if (!text || activity.type !== "comment") return text;

if (text.includes('<span class="mention">') || text.includes("<p>")) {
return <FormattedContent format="html" content={text} />;
}

return text;
};

return (
<div className="relative h-full w-full flex flex-col">
<div className="flex items-center gap-2">
Expand All @@ -32,7 +46,9 @@ export function IncidentActivityItem({ activity }: { activity: any }) {
</span>
</div>
{activity.text && (
<div className="font-light text-gray-800">{activity.text}</div>
<div className="font-light text-gray-800">
{processCommentText(activity.text)}
</div>
)}
</div>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import dynamic from "next/dynamic";

const IncidentCommentInput = dynamic(
() =>
import("./IncidentCommentInput").then((mod) => mod.IncidentCommentInput),
{
ssr: false,
// mimic the quill editor while loading
loading: () => (
<div className="w-full h-11 px-[11px] py-[16px] leading-[1.42] text-tremor-default font-[Helvetica,Arial,sans-serif] text-[#0009] italic">
Add a comment...
</div>
),
}
);

export { IncidentCommentInput };
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
.incident-comment-input .ql-container {
@apply text-tremor-default;
}

.mention {
background-color: #e8f4fe;
border-radius: 4px;
padding: 0 2px;
color: #0366d6;
}

.mention-container {
display: block !important;
position: absolute !important;
background-color: white;
border: 1px solid #ddd;
border-radius: 4px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
z-index: 9999 !important;
max-height: 100%;
overflow-y: auto;
padding: 5px 0;
min-width: 180px;
}

.mention-list {
list-style: none;
margin: 0;
padding: 0;
}

.mention-item {
display: block;
padding: 8px 12px;
cursor: pointer;
color: #333;
}

.mention-item:hover {
background-color: #f0f0f0;
}

.mention-item.selected {
background-color: #e8f4fe;
}

/* Prevent hidden overflow that could hide the dropdown */
.ql-editor p {
overflow: visible;
}
Loading
Loading