1
1
import { IncidentDto } from "@/entities/incidents/model" ;
2
2
import { TextInput , Button } from "@tremor/react" ;
3
- import { useState , useCallback , useEffect } from "react" ;
3
+ import { useState , useCallback , useEffect , useRef } from "react" ;
4
4
import { toast } from "react-toastify" ;
5
5
import { KeyedMutator } from "swr" ;
6
6
import { useApi } from "@/shared/lib/hooks/useApi" ;
7
7
import { showErrorToast } from "@/shared/ui" ;
8
8
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" ;
9
12
10
13
export function IncidentActivityComment ( {
11
14
incident,
@@ -15,58 +18,236 @@ export function IncidentActivityComment({
15
18
mutator : KeyedMutator < AuditEvent [ ] > ;
16
19
} ) {
17
20
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 ) ;
18
28
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
+ } , [ ] ) ;
19
52
20
53
const onSubmit = useCallback ( async ( ) => {
21
54
try {
22
55
await api . post ( `/incidents/${ incident . id } /comment` , {
23
56
status : incident . status ,
24
57
comment,
58
+ tagged_users : taggedUsers ,
25
59
} ) ;
26
60
toast . success ( "Comment added!" , { position : "top-right" } ) ;
27
61
setComment ( "" ) ;
62
+ setTaggedUsers ( [ ] ) ;
28
63
mutator ( ) ;
29
64
} catch ( error ) {
30
65
showErrorToast ( error , "Failed to add comment" ) ;
31
66
}
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 ] ) ;
33
89
90
+ // Listen for @ key to trigger mention UI
34
91
const handleKeyDown = useCallback (
35
- ( event : KeyboardEvent ) => {
92
+ ( event : React . KeyboardEvent < HTMLInputElement > ) => {
36
93
if (
37
94
event . key === "Enter" &&
95
+ ! isMentioning &&
38
96
( event . metaKey || event . ctrlKey ) &&
39
97
comment
40
98
) {
41
99
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
+ }
42
129
}
43
130
} ,
44
- [ onSubmit , comment ]
131
+ [ onSubmit , comment , isMentioning , selectUser , selectedIndex , filteredUserOptions . length ]
45
132
) ;
46
133
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
+
47
206
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 ] ) ;
53
211
54
212
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
+ ) }
70
251
</ div >
71
252
) ;
72
253
}
0 commit comments