11import { IncidentDto } from "@/entities/incidents/model" ;
22import { TextInput , Button } from "@tremor/react" ;
3- import { useState , useCallback , useEffect } from "react" ;
3+ import { useState , useCallback , useEffect , useRef } from "react" ;
44import { toast } from "react-toastify" ;
55import { KeyedMutator } from "swr" ;
66import { useApi } from "@/shared/lib/hooks/useApi" ;
77import { showErrorToast } from "@/shared/ui" ;
88import { 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
1013export 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}
0 commit comments