Skip to content

Commit 62fcc85

Browse files
committed
feat: Add user mention functionality to incident comments
Closes #2187
1 parent 610a7ac commit 62fcc85

File tree

9 files changed

+418
-31
lines changed

9 files changed

+418
-31
lines changed

keep-ui/app/(keep)/incidents/[id]/activity/incident-activity.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

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

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

23-
interface IncidentActivity {
23+
export interface IncidentActivity {
2424
id: string;
2525
type: "comment" | "alert" | "newcomment" | "statuschange" | "assign";
2626
text?: string;
2727
timestamp: string;
2828
initiator?: string | AlertDto;
29+
mentions?: CommentMentionDto[];
2930
}
3031

3132
const ACTION_TYPES = [
@@ -139,6 +140,7 @@ export function IncidentActivity({ incident }: { incident: IncidentDto }) {
139140
? auditEvent.description
140141
: "",
141142
timestamp: auditEvent.timestamp,
143+
mentions: auditEvent.mentions,
142144
} as IncidentActivity;
143145
}) || []
144146
);
Lines changed: 205 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import { IncidentDto } from "@/entities/incidents/model";
22
import { TextInput, Button } from "@tremor/react";
3-
import { useState, useCallback, useEffect } from "react";
3+
import { useState, useCallback, useEffect, useRef } from "react";
44
import { toast } from "react-toastify";
55
import { KeyedMutator } from "swr";
66
import { useApi } from "@/shared/lib/hooks/useApi";
77
import { showErrorToast } from "@/shared/ui";
88
import { AuditEvent } from "@/entities/alerts/model";
9+
import type { Option } from "@/components/ui/AutocompleteInput";
10+
import { useUsers } from "@/entities/users/model/useUsers";
11+
import { User } from "@/app/(keep)/settings/models";
912

1013
export function IncidentActivityComment({
1114
incident,
@@ -15,58 +18,236 @@ export function IncidentActivityComment({
1518
mutator: KeyedMutator<AuditEvent[]>;
1619
}) {
1720
const [comment, setComment] = useState("");
21+
const [isMentioning, setIsMentioning] = useState(false);
22+
const [mentionStartPos, setMentionStartPos] = useState(0);
23+
const [mentionText, setMentionText] = useState("");
24+
const [cursorPos, setCursorPos] = useState(0);
25+
const [selectedIndex, setSelectedIndex] = useState(0);
26+
const [taggedUsers, setTaggedUsers] = useState<string[]>([]);
27+
const inputRef = useRef<HTMLInputElement>(null);
1828
const api = useApi();
29+
const { data: users = [] } = useUsers();
30+
31+
const userOptions = users.map((user: User) => ({
32+
label: user.name || user.email || "",
33+
value: user.email || "",
34+
}));
35+
36+
const filteredUserOptions = userOptions.filter(option =>
37+
option.label.toLowerCase().includes(mentionText.toLowerCase()) ||
38+
option.value.toLowerCase().includes(mentionText.toLowerCase())
39+
);
40+
41+
// Helper function to position cursor after insertion
42+
const positionCursorAfterMention = useCallback((newPosition: number) => {
43+
if (inputRef.current) {
44+
inputRef.current.focus();
45+
setTimeout(() => {
46+
if (inputRef.current) {
47+
inputRef.current.setSelectionRange(newPosition, newPosition);
48+
}
49+
}, 0);
50+
}
51+
}, []);
1952

2053
const onSubmit = useCallback(async () => {
2154
try {
2255
await api.post(`/incidents/${incident.id}/comment`, {
2356
status: incident.status,
2457
comment,
58+
tagged_users: taggedUsers,
2559
});
2660
toast.success("Comment added!", { position: "top-right" });
2761
setComment("");
62+
setTaggedUsers([]);
2863
mutator();
2964
} catch (error) {
3065
showErrorToast(error, "Failed to add comment");
3166
}
32-
}, [api, incident.id, incident.status, comment, mutator]);
67+
}, [api, incident.id, incident.status, comment, taggedUsers, mutator]);
68+
69+
const selectUser = useCallback((index: number) => {
70+
if (filteredUserOptions.length > 0 && index >= 0 && index < filteredUserOptions.length) {
71+
const option = filteredUserOptions[index];
72+
const beforeMention = comment.substring(0, mentionStartPos);
73+
// Fix: Use mentionStartPos + mentionText.length + 1 to avoid cutting text
74+
const afterMention = comment.substring(mentionStartPos + mentionText.length + 1);
75+
const newComment = `${beforeMention}@${option.label} ${afterMention}`;
76+
77+
if (!taggedUsers.includes(option.value)) {
78+
setTaggedUsers(prev => [...prev, option.value]);
79+
}
80+
81+
setComment(newComment);
82+
setIsMentioning(false);
83+
84+
// Use shared cursor positioning function
85+
const newCursorPos = mentionStartPos + option.label.length + 2; // +2 for @ and space
86+
positionCursorAfterMention(newCursorPos);
87+
}
88+
}, [comment, mentionStartPos, mentionText, filteredUserOptions, taggedUsers, positionCursorAfterMention]);
3389

90+
// Listen for @ key to trigger mention UI
3491
const handleKeyDown = useCallback(
35-
(event: KeyboardEvent) => {
92+
(event: React.KeyboardEvent<HTMLInputElement>) => {
3693
if (
3794
event.key === "Enter" &&
95+
!isMentioning &&
3896
(event.metaKey || event.ctrlKey) &&
3997
comment
4098
) {
4199
onSubmit();
100+
} else if (event.key === '@') {
101+
// Only trigger mention UI if we're not already in a mention
102+
// and the cursor is not inside an existing mention
103+
const currentPos = inputRef.current?.selectionStart || 0;
104+
const textBeforeCursor = comment.substring(0, currentPos);
105+
const wordStart = textBeforeCursor.lastIndexOf(' ') + 1;
106+
const currentWord = textBeforeCursor.substring(wordStart);
107+
108+
if (!currentWord.includes('@')) {
109+
setIsMentioning(true);
110+
setMentionStartPos(currentPos);
111+
setMentionText("");
112+
setSelectedIndex(0);
113+
}
114+
} else if (isMentioning) {
115+
if (event.key === 'Escape') {
116+
setIsMentioning(false);
117+
} else if (event.key === 'ArrowDown') {
118+
event.preventDefault();
119+
setSelectedIndex(prevIndex =>
120+
Math.min(prevIndex + 1, filteredUserOptions.length - 1)
121+
);
122+
} else if (event.key === 'ArrowUp') {
123+
event.preventDefault();
124+
setSelectedIndex(prevIndex => Math.max(prevIndex - 1, 0));
125+
} else if (event.key === 'Enter') {
126+
event.preventDefault();
127+
selectUser(selectedIndex);
128+
}
42129
}
43130
},
44-
[onSubmit, comment]
131+
[onSubmit, comment, isMentioning, selectUser, selectedIndex, filteredUserOptions.length]
45132
);
46133

134+
const handleInputChange = (value: string) => {
135+
setComment(value);
136+
137+
if (inputRef.current) {
138+
const currentCursorPos = inputRef.current.selectionStart || 0;
139+
setCursorPos(currentCursorPos);
140+
141+
// If we're in mentioning mode, update the mention text
142+
if (isMentioning) {
143+
// Get text between @ and cursor
144+
if (currentCursorPos > mentionStartPos) {
145+
const textAfterMention = value.substring(mentionStartPos + 1, currentCursorPos);
146+
// Only keep text up to the first space
147+
const mentionTextOnly = textAfterMention.split(' ')[0];
148+
setMentionText(mentionTextOnly);
149+
150+
// If there's a space after the mention text, exit mention mode
151+
if (textAfterMention.includes(' ')) {
152+
setIsMentioning(false);
153+
}
154+
} else {
155+
// If cursor is before the @ symbol, exit mention mode
156+
setIsMentioning(false);
157+
}
158+
159+
// If user deletes the @ symbol, exit mentioning mode
160+
if (!value.substring(mentionStartPos, currentCursorPos).includes('@')) {
161+
setIsMentioning(false);
162+
}
163+
}
164+
}
165+
166+
// Show mention UI when typing @ manually
167+
if (value.includes('@') && !isMentioning) {
168+
const atPos = value.lastIndexOf('@');
169+
const currentPos = inputRef.current?.selectionStart || 0;
170+
171+
// Check if cursor is right after or inside a mention
172+
if (atPos >= 0 && currentPos > atPos) {
173+
// Check if the @ is at the beginning of a word
174+
if (atPos === 0 || value[atPos-1] === ' ') {
175+
// Check if we're still typing the mention (no space after @)
176+
const textAfterAt = value.substring(atPos + 1, currentPos);
177+
if (!textAfterAt.includes(' ')) {
178+
setIsMentioning(true);
179+
setMentionStartPos(atPos);
180+
setMentionText(textAfterAt);
181+
setSelectedIndex(0);
182+
}
183+
}
184+
}
185+
}
186+
};
187+
188+
const handleUserSelect = useCallback((option: Option<string>) => {
189+
const beforeMention = comment.substring(0, mentionStartPos);
190+
// Fix: Use mentionStartPos + mentionText.length + 1 to avoid cutting text
191+
const afterMention = comment.substring(mentionStartPos + mentionText.length + 1);
192+
const newComment = `${beforeMention}@${option.label} ${afterMention}`;
193+
194+
if (!taggedUsers.includes(option.value)) {
195+
setTaggedUsers(prev => [...prev, option.value]);
196+
}
197+
198+
setComment(newComment);
199+
setIsMentioning(false);
200+
201+
// Use shared cursor positioning function
202+
const newCursorPos = mentionStartPos + option.label.length + 2;
203+
positionCursorAfterMention(newCursorPos);
204+
}, [comment, mentionStartPos, mentionText, taggedUsers, positionCursorAfterMention]);
205+
47206
useEffect(() => {
48-
window.addEventListener("keydown", handleKeyDown);
49-
return () => {
50-
window.removeEventListener("keydown", handleKeyDown);
51-
};
52-
}, [comment, handleKeyDown]);
207+
if (filteredUserOptions.length > 0 && selectedIndex >= filteredUserOptions.length) {
208+
setSelectedIndex(0);
209+
}
210+
}, [filteredUserOptions, selectedIndex]);
53211

54212
return (
55-
<div className="flex h-full w-full relative items-center">
56-
<TextInput
57-
value={comment}
58-
onValueChange={setComment}
59-
placeholder="Add a new comment..."
60-
/>
61-
<Button
62-
color="orange"
63-
variant="secondary"
64-
className="ml-2.5"
65-
disabled={!comment}
66-
onClick={onSubmit}
67-
>
68-
Comment
69-
</Button>
213+
<div className="flex flex-col w-full relative">
214+
<div className="flex w-full relative items-center">
215+
<TextInput
216+
ref={inputRef}
217+
value={comment}
218+
onValueChange={handleInputChange}
219+
onKeyDown={handleKeyDown}
220+
placeholder="Add a new comment..."
221+
/>
222+
<Button
223+
color="orange"
224+
variant="secondary"
225+
className="ml-2.5"
226+
disabled={!comment}
227+
onClick={onSubmit}
228+
>
229+
Comment
230+
</Button>
231+
</div>
232+
233+
{isMentioning && filteredUserOptions.length > 0 && (
234+
<div className="absolute top-full left-0 w-full z-10 mt-1 rounded-md border border-gray-200 bg-white shadow-md max-h-60 overflow-y-auto">
235+
<div className="p-1">
236+
{filteredUserOptions.map((option, index) => (
237+
<div
238+
key={option.value}
239+
className={`px-3 py-2 cursor-pointer rounded ${
240+
index === selectedIndex ? 'bg-blue-100' : 'hover:bg-gray-100'
241+
}`}
242+
onClick={() => handleUserSelect(option)}
243+
>
244+
<div className="font-medium text-gray-900">{option.label}</div>
245+
<div className="text-sm text-gray-500">{option.value}</div>
246+
</div>
247+
))}
248+
</div>
249+
</div>
250+
)}
70251
</div>
71252
);
72253
}

keep-ui/app/(keep)/incidents/[id]/activity/ui/IncidentActivityItem.tsx

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import { AlertSeverity } from "@/entities/alerts/ui";
2-
import { AlertDto } from "@/entities/alerts/model";
2+
import { AlertDto, CommentMentionDto } from "@/entities/alerts/model";
33
import TimeAgo from "react-timeago";
4+
import { useUsers } from "@/entities/users/model/useUsers";
5+
import { User } from "@/app/(keep)/settings/models";
46

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

79
export function IncidentActivityItem({ activity }: { activity: any }) {
10+
const { data: users = [] } = useUsers();
11+
812
const title =
913
typeof activity.initiator === "string"
1014
? activity.initiator
@@ -17,6 +21,46 @@ export function IncidentActivityItem({ activity }: { activity: any }) {
1721
: activity.initiator?.status === "firing"
1822
? " triggered"
1923
: " resolved" + ". ";
24+
25+
// Process comment text to style mentions if it's a comment with mentions
26+
const processCommentText = (text: string) => {
27+
if (!text || activity.type !== 'comment') return text;
28+
29+
const emailToName = new Map();
30+
users.forEach((user: User) => {
31+
if (user.email) {
32+
emailToName.set(user.email, user.name || user.email);
33+
}
34+
});
35+
36+
if (activity.mentions && activity.mentions.length > 0) {
37+
let processedText = text;
38+
39+
activity.mentions.forEach((mention: CommentMentionDto) => {
40+
const userEmail = mention.mentioned_user_id;
41+
42+
processedText = processedText.replace(
43+
new RegExp(`@${userEmail.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g'),
44+
`<span class="font-bold text-blue-600">@${userEmail}</span>`
45+
);
46+
47+
const userName = emailToName.get(userEmail);
48+
if (userName && userName !== userEmail) {
49+
processedText = processedText.replace(
50+
new RegExp(`@${userName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g'),
51+
`<span class="font-bold text-blue-600">@${userName}</span>`
52+
);
53+
}
54+
});
55+
56+
return (
57+
<div dangerouslySetInnerHTML={{ __html: processedText }} />
58+
);
59+
}
60+
61+
return text;
62+
};
63+
2064
return (
2165
<div className="relative h-full w-full flex flex-col">
2266
<div className="flex items-center gap-2">
@@ -32,7 +76,9 @@ export function IncidentActivityItem({ activity }: { activity: any }) {
3276
</span>
3377
</div>
3478
{activity.text && (
35-
<div className="font-light text-gray-800">{activity.text}</div>
79+
<div className="font-light text-gray-800">
80+
{processCommentText(activity.text)}
81+
</div>
3682
)}
3783
</div>
3884
);

keep-ui/entities/alerts/model/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,8 +120,13 @@ export type AuditEvent = {
120120
description: string;
121121
timestamp: string;
122122
fingerprint: string;
123+
mentions?: CommentMentionDto[];
123124
};
124125

126+
export interface CommentMentionDto {
127+
mentioned_user_id: string;
128+
}
129+
125130
export interface AlertsQuery {
126131
cel?: string;
127132
offset?: number;

0 commit comments

Comments
 (0)