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" ;
9
11
10
12
export function IncidentActivityComment ( {
11
13
incident,
@@ -15,58 +17,235 @@ export function IncidentActivityComment({
15
17
mutator : KeyedMutator < AuditEvent [ ] > ;
16
18
} ) {
17
19
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 ) ;
18
27
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
+ ) ;
19
39
20
40
const onSubmit = useCallback ( async ( ) => {
21
41
try {
22
42
await api . post ( `/incidents/${ incident . id } /comment` , {
23
43
status : incident . status ,
24
44
comment,
45
+ tagged_users : taggedUsers ,
25
46
} ) ;
26
47
toast . success ( "Comment added!" , { position : "top-right" } ) ;
27
48
setComment ( "" ) ;
49
+ setTaggedUsers ( [ ] ) ;
28
50
mutator ( ) ;
29
51
} catch ( error ) {
30
52
showErrorToast ( error , "Failed to add comment" ) ;
31
53
}
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 ] ) ;
33
82
83
+ // Listen for @ key to trigger mention UI
34
84
const handleKeyDown = useCallback (
35
- ( event : KeyboardEvent ) => {
85
+ ( event : React . KeyboardEvent < HTMLInputElement > ) => {
36
86
if (
37
87
event . key === "Enter" &&
88
+ ! isMentioning &&
38
89
( event . metaKey || event . ctrlKey ) &&
39
90
comment
40
91
) {
41
92
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
+ }
42
122
}
43
123
} ,
44
- [ onSubmit , comment ]
124
+ [ onSubmit , comment , isMentioning , selectUser , selectedIndex , filteredUserOptions . length ]
45
125
) ;
46
126
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
+
47
204
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 ] ) ;
53
209
54
210
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
+ ) }
70
249
</ div >
71
250
) ;
72
251
}
0 commit comments