Skip to content

Commit d2bdd19

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

File tree

9 files changed

+386
-28
lines changed

9 files changed

+386
-28
lines changed

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

Lines changed: 3 additions & 1 deletion
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";
@@ -26,6 +26,7 @@ interface IncidentActivity {
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: 203 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
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";
911

1012
export function IncidentActivityComment({
1113
incident,
@@ -15,58 +17,235 @@ export function IncidentActivityComment({
1517
mutator: KeyedMutator<AuditEvent[]>;
1618
}) {
1719
const [comment, setComment] = useState("");
20+
const [isMentioning, setIsMentioning] = useState(false);
21+
const [mentionStartPos, setMentionStartPos] = useState(0);
22+
const [mentionText, setMentionText] = useState("");
23+
const [cursorPos, setCursorPos] = useState(0);
24+
const [selectedIndex, setSelectedIndex] = useState(0);
25+
const [taggedUsers, setTaggedUsers] = useState<string[]>([]);
26+
const inputRef = useRef<HTMLInputElement>(null);
1827
const api = useApi();
28+
const { data: users = [] } = useUsers();
29+
30+
const userOptions = users.map((user: any) => ({
31+
label: user.name || user.email || "",
32+
value: user.email || "",
33+
}));
34+
35+
const filteredUserOptions = userOptions.filter(option =>
36+
option.label.toLowerCase().includes(mentionText.toLowerCase()) ||
37+
option.value.toLowerCase().includes(mentionText.toLowerCase())
38+
);
1939

2040
const onSubmit = useCallback(async () => {
2141
try {
2242
await api.post(`/incidents/${incident.id}/comment`, {
2343
status: incident.status,
2444
comment,
45+
tagged_users: taggedUsers,
2546
});
2647
toast.success("Comment added!", { position: "top-right" });
2748
setComment("");
49+
setTaggedUsers([]);
2850
mutator();
2951
} catch (error) {
3052
showErrorToast(error, "Failed to add comment");
3153
}
32-
}, [api, incident.id, incident.status, comment, mutator]);
54+
}, [api, incident.id, incident.status, comment, taggedUsers, mutator]);
55+
56+
const selectUser = useCallback((index: number) => {
57+
if (filteredUserOptions.length > 0 && index >= 0 && index < filteredUserOptions.length) {
58+
const option = filteredUserOptions[index];
59+
const beforeMention = comment.substring(0, mentionStartPos);
60+
const afterMention = comment.substring(cursorPos);
61+
const newComment = `${beforeMention}@${option.label} ${afterMention}`;
62+
63+
if (!taggedUsers.includes(option.value)) {
64+
setTaggedUsers(prev => [...prev, option.value]);
65+
}
66+
67+
setComment(newComment);
68+
setIsMentioning(false);
69+
70+
if (inputRef.current) {
71+
inputRef.current.focus();
72+
// Set cursor position after the inserted mention
73+
const newCursorPos = mentionStartPos + option.label.length + 2; // +2 for @ and space
74+
setTimeout(() => {
75+
if (inputRef.current) {
76+
inputRef.current.setSelectionRange(newCursorPos, newCursorPos);
77+
}
78+
}, 0);
79+
}
80+
}
81+
}, [comment, mentionStartPos, cursorPos, filteredUserOptions]);
3382

83+
// Listen for @ key to trigger mention UI
3484
const handleKeyDown = useCallback(
35-
(event: KeyboardEvent) => {
85+
(event: React.KeyboardEvent<HTMLInputElement>) => {
3686
if (
3787
event.key === "Enter" &&
88+
!isMentioning &&
3889
(event.metaKey || event.ctrlKey) &&
3990
comment
4091
) {
4192
onSubmit();
93+
} else if (event.key === '@') {
94+
// Only trigger mention UI if we're not already in a mention
95+
// and the cursor is not inside an existing mention
96+
const currentPos = inputRef.current?.selectionStart || 0;
97+
const textBeforeCursor = comment.substring(0, currentPos);
98+
const wordStart = textBeforeCursor.lastIndexOf(' ') + 1;
99+
const currentWord = textBeforeCursor.substring(wordStart);
100+
101+
if (!currentWord.includes('@')) {
102+
setIsMentioning(true);
103+
setMentionStartPos(currentPos);
104+
setMentionText("");
105+
setSelectedIndex(0);
106+
}
107+
} else if (isMentioning) {
108+
if (event.key === 'Escape') {
109+
setIsMentioning(false);
110+
} else if (event.key === 'ArrowDown') {
111+
event.preventDefault();
112+
setSelectedIndex(prevIndex =>
113+
Math.min(prevIndex + 1, filteredUserOptions.length - 1)
114+
);
115+
} else if (event.key === 'ArrowUp') {
116+
event.preventDefault();
117+
setSelectedIndex(prevIndex => Math.max(prevIndex - 1, 0));
118+
} else if (event.key === 'Enter') {
119+
event.preventDefault();
120+
selectUser(selectedIndex);
121+
}
42122
}
43123
},
44-
[onSubmit, comment]
124+
[onSubmit, comment, isMentioning, selectUser, selectedIndex, filteredUserOptions.length]
45125
);
46126

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

54210
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>
211+
<div className="flex flex-col w-full relative">
212+
<div className="flex w-full relative items-center">
213+
<TextInput
214+
ref={inputRef}
215+
value={comment}
216+
onValueChange={handleInputChange}
217+
onKeyDown={handleKeyDown}
218+
placeholder="Add a new comment..."
219+
/>
220+
<Button
221+
color="orange"
222+
variant="secondary"
223+
className="ml-2.5"
224+
disabled={!comment}
225+
onClick={onSubmit}
226+
>
227+
Comment
228+
</Button>
229+
</div>
230+
231+
{isMentioning && filteredUserOptions.length > 0 && (
232+
<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">
233+
<div className="p-1">
234+
{filteredUserOptions.map((option, index) => (
235+
<div
236+
key={option.value}
237+
className={`px-3 py-2 cursor-pointer rounded ${
238+
index === selectedIndex ? 'bg-blue-100' : 'hover:bg-gray-100'
239+
}`}
240+
onClick={() => handleUserSelect(option)}
241+
>
242+
<div className="font-medium text-gray-900">{option.label}</div>
243+
<div className="text-sm text-gray-500">{option.value}</div>
244+
</div>
245+
))}
246+
</div>
247+
</div>
248+
)}
70249
</div>
71250
);
72251
}

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

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import { AlertSeverity } from "@/entities/alerts/ui";
22
import { AlertDto } from "@/entities/alerts/model";
33
import TimeAgo from "react-timeago";
4+
import { useUsers } from "@/entities/users/model/useUsers";
45

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

78
export function IncidentActivityItem({ activity }: { activity: any }) {
9+
const { data: users = [] } = useUsers();
10+
811
const title =
912
typeof activity.initiator === "string"
1013
? activity.initiator
@@ -17,6 +20,46 @@ export function IncidentActivityItem({ activity }: { activity: any }) {
1720
: activity.initiator?.status === "firing"
1821
? " triggered"
1922
: " resolved" + ". ";
23+
24+
// Process comment text to style mentions if it's a comment with mentions
25+
const processCommentText = (text: string) => {
26+
if (!text || activity.type !== 'comment') return text;
27+
28+
const emailToName = new Map();
29+
users.forEach((user: any) => {
30+
if (user.email) {
31+
emailToName.set(user.email, user.name || user.email);
32+
}
33+
});
34+
35+
if (activity.mentions && activity.mentions.length > 0) {
36+
let processedText = text;
37+
38+
activity.mentions.forEach((mention: any) => {
39+
const userEmail = mention.mentioned_user_id;
40+
41+
processedText = processedText.replace(
42+
new RegExp(`@${userEmail.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g'),
43+
`<span class="font-bold text-blue-600">@${userEmail}</span>`
44+
);
45+
46+
const userName = emailToName.get(userEmail);
47+
if (userName && userName !== userEmail) {
48+
processedText = processedText.replace(
49+
new RegExp(`@${userName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g'),
50+
`<span class="font-bold text-blue-600">@${userName}</span>`
51+
);
52+
}
53+
});
54+
55+
return (
56+
<div dangerouslySetInnerHTML={{ __html: processedText }} />
57+
);
58+
}
59+
60+
return text;
61+
};
62+
2063
return (
2164
<div className="relative h-full w-full flex flex-col">
2265
<div className="flex items-center gap-2">
@@ -32,7 +75,9 @@ export function IncidentActivityItem({ activity }: { activity: any }) {
3275
</span>
3376
</div>
3477
{activity.text && (
35-
<div className="font-light text-gray-800">{activity.text}</div>
78+
<div className="font-light text-gray-800">
79+
{processCommentText(activity.text)}
80+
</div>
3681
)}
3782
</div>
3883
);

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)