@@ -3,9 +3,12 @@ import { LockKeyhole, RefreshCw, Send } from 'lucide-react';
33import { Button , ConfirmDialog , IconButton , Textarea } from '@/component-library' ;
44import { usePrivacy } from '@/app/components/Privacy/PrivacyContext' ;
55import { PrivacyStatementDialog } from '@/app/components/Privacy/PrivacyStatementDialog' ;
6+ import { useImeEnterGuard } from '@/flow_chat/hooks/useImeEnterGuard' ;
67import {
78 feedbackAPI ,
89 FeedbackApiError ,
10+ FEEDBACK_CONTENT_MAX_CHARS ,
11+ feedbackInsertText ,
912 type FeedbackMessage ,
1013 type FeedbackRecordSummary ,
1114 feedbackContentLength ,
@@ -14,6 +17,7 @@ import {
1417import { useI18n } from '@/infrastructure/i18n/hooks/useI18n' ;
1518import { PrivacyStatementLink } from './PrivacyStatementLink' ;
1619import { useFeedbackInboxStore } from './feedbackInboxStore' ;
20+ import { useRejectedInsertionCaret } from './useRejectedInsertionCaret' ;
1721
1822interface FeedbackConversationViewProps {
1923 record : FeedbackRecordSummary ;
@@ -30,6 +34,7 @@ export const FeedbackConversationView: React.FC<FeedbackConversationViewProps> =
3034} ) => {
3135 const { t, formatDate } = useI18n ( 'common' ) ;
3236 const { status, accept } = usePrivacy ( ) ;
37+ const { isImeEnter, handleCompositionStart, handleCompositionEnd } = useImeEnterGuard ( ) ;
3338 const [ messages , setMessages ] = useState < FeedbackMessage [ ] > ( [ ] ) ;
3439 const [ nextCursor , setNextCursor ] = useState < string > ( ) ;
3540 const [ hasMore , setHasMore ] = useState ( false ) ;
@@ -45,6 +50,7 @@ export const FeedbackConversationView: React.FC<FeedbackConversationViewProps> =
4550 const [ showConsent , setShowConsent ] = useState ( false ) ;
4651 const [ showPrivacy , setShowPrivacy ] = useState ( false ) ;
4752 const scrollRef = useRef < HTMLDivElement > ( null ) ;
53+ const { armRejectedInsertionCaret, restoreRejectedInsertionCaret } = useRejectedInsertionCaret ( ) ;
4854 const topSentinelRef = useRef < HTMLDivElement > ( null ) ;
4955 const visibleAdminTimesRef = useRef ( new Set < string > ( ) ) ;
5056 const lastReadThroughRef = useRef < string | null > ( null ) ;
@@ -58,6 +64,9 @@ export const FeedbackConversationView: React.FC<FeedbackConversationViewProps> =
5864 const markInaccessible = useFeedbackInboxStore ( state => state . markInaccessible ) ;
5965 const refreshInbox = useFeedbackInboxStore ( state => state . refresh ) ;
6066 const draftLength = feedbackContentLength ( draft ) ;
67+ const draftNativeMaxLength = draftLength >= FEEDBACK_CONTENT_MAX_CHARS
68+ ? draft . length
69+ : undefined ;
6170 const canReply = Boolean ( draft . trim ( ) ) && ! sending && record . status !== 'resolved' ;
6271
6372 useEffect ( ( ) => {
@@ -89,7 +98,7 @@ export const FeedbackConversationView: React.FC<FeedbackConversationViewProps> =
8998 const loadLatest = useCallback ( async ( manual : boolean ) => {
9099 if ( manual ) setRefreshing ( true ) ;
91100 else setLoading ( true ) ;
92- setError ( null ) ;
101+ if ( ! manual ) setError ( null ) ;
93102 try {
94103 const page = await feedbackAPI . openConversation ( { feedbackId : record . feedbackId } ) ;
95104 if ( ! mountedRef . current ) return ;
@@ -105,12 +114,12 @@ export const FeedbackConversationView: React.FC<FeedbackConversationViewProps> =
105114 refreshedReconciledUnreadRef . current = true ;
106115 void refreshInbox ( true ) ;
107116 }
108- if ( ! manual ) {
109- requestAnimationFrame ( ( ) => {
110- const container = scrollRef . current ;
111- if ( container ) container . scrollTop = container . scrollHeight ;
112- } ) ;
113- }
117+ // Refresh can append newly arrived messages to the existing list. Wait
118+ // for the merged list to render, then keep the conversation at its tail.
119+ requestAnimationFrame ( ( ) => {
120+ const container = scrollRef . current ;
121+ if ( container ) container . scrollTop = container . scrollHeight ;
122+ } ) ;
114123 } catch ( caught ) {
115124 if ( mountedRef . current ) handleConversationError ( caught ) ;
116125 } finally {
@@ -240,6 +249,45 @@ export const FeedbackConversationView: React.FC<FeedbackConversationViewProps> =
240249 void sendReply ( ) ;
241250 } ;
242251
252+ const handleReplyKeyDown = ( event : React . KeyboardEvent < HTMLTextAreaElement > ) => {
253+ if (
254+ ! event . ctrlKey
255+ && ! event . metaKey
256+ && ! event . altKey
257+ && (
258+ event . key . length === 1
259+ || event . key === 'Process'
260+ || event . nativeEvent . keyCode === 229
261+ )
262+ ) {
263+ const textarea = event . currentTarget ;
264+ if (
265+ textarea . selectionStart === textarea . selectionEnd
266+ && feedbackContentLength ( textarea . value ) >= FEEDBACK_CONTENT_MAX_CHARS
267+ ) {
268+ armRejectedInsertionCaret ( textarea ) ;
269+ }
270+ }
271+ if ( event . key !== 'Enter' || isImeEnter ( event ) ) return ;
272+ if ( event . ctrlKey ) {
273+ const textarea = event . currentTarget ;
274+ if (
275+ textarea . selectionStart === textarea . selectionEnd
276+ && feedbackContentLength ( textarea . value ) >= FEEDBACK_CONTENT_MAX_CHARS
277+ ) {
278+ armRejectedInsertionCaret ( textarea ) ;
279+ // Let native maxLength reject the newline without touching undo state.
280+ return ;
281+ }
282+ event . preventDefault ( ) ;
283+ applyFeedbackInsertion ( event . currentTarget , '\n' ) ;
284+ return ;
285+ }
286+ // Enter sends the reply; Ctrl+Enter inserts a newline.
287+ event . preventDefault ( ) ;
288+ event . currentTarget . form ?. requestSubmit ( ) ;
289+ } ;
290+
243291 const acceptAndSend = async ( ) => {
244292 const content = draft . trim ( ) ;
245293 if ( ! content || sending || ! status ?. policy ) return ;
@@ -264,13 +312,92 @@ export const FeedbackConversationView: React.FC<FeedbackConversationViewProps> =
264312 if ( mountedRef . current ) setSending ( false ) ;
265313 } ;
266314
267- const handleDraftChange = ( value : string ) => {
315+ const handleDraftChange = ( textarea : HTMLTextAreaElement ) => {
316+ const value = textarea . value ;
317+ if ( / ^ \s / . test ( value ) && value . replace ( / ^ \s + / , '' ) === draft ) {
318+ document . execCommand ( 'undo' ) ;
319+ return ;
320+ }
268321 const truncated = truncateFeedbackContent ( value ) ;
269- setDraftTruncated ( truncated !== value ) ;
322+ setDraftTruncated ( Array . from ( value . replace ( / ^ \s + / , '' ) ) . length > FEEDBACK_CONTENT_MAX_CHARS ) ;
270323 setDraft ( truncated ) ;
271324 setReplyError ( null ) ;
272325 } ;
273326
327+ const applyFeedbackInsertion = ( textarea : HTMLTextAreaElement , insertedText : string ) => {
328+ const currentValue = textarea . value ;
329+ const start = textarea . selectionStart ;
330+ const end = textarea . selectionEnd ;
331+ if ( start === end && feedbackContentLength ( currentValue ) >= FEEDBACK_CONTENT_MAX_CHARS ) {
332+ return ;
333+ }
334+ const acceptedText = feedbackInsertText ( currentValue , start , end , insertedText ) ;
335+ if ( acceptedText && document . execCommand ( 'insertText' , false , acceptedText ) ) {
336+ // execCommand preserves the browser's native undo transaction for a
337+ // paste, unlike replacing the controlled value in onChange.
338+ } else {
339+ textarea . setRangeText ( acceptedText , start , end , 'end' ) ;
340+ }
341+ const candidate = currentValue . slice ( 0 , start ) + insertedText + currentValue . slice ( end ) ;
342+ setDraftTruncated ( feedbackContentLength ( candidate . replace ( / ^ \s + / , '' ) ) > FEEDBACK_CONTENT_MAX_CHARS ) ;
343+ } ;
344+
345+ const handleDraftPaste = ( event : React . ClipboardEvent < HTMLTextAreaElement > ) => {
346+ const textarea = event . currentTarget ;
347+ if (
348+ textarea . selectionStart === textarea . selectionEnd
349+ && feedbackContentLength ( textarea . value ) >= FEEDBACK_CONTENT_MAX_CHARS
350+ ) {
351+ armRejectedInsertionCaret ( textarea ) ;
352+ // Let the native maxLength gate reject the paste without creating a
353+ // JavaScript edit boundary in the browser's undo history.
354+ return ;
355+ }
356+ event . preventDefault ( ) ;
357+ applyFeedbackInsertion ( textarea , event . clipboardData . getData ( 'text/plain' ) ) ;
358+ } ;
359+
360+ const handleDraftBeforeInput = ( event : React . FormEvent < HTMLTextAreaElement > ) => {
361+ const nativeEvent = event . nativeEvent as InputEvent ;
362+ const textarea = event . currentTarget ;
363+ if (
364+ nativeEvent . inputType . startsWith ( 'insert' )
365+ && textarea . selectionStart === textarea . selectionEnd
366+ && feedbackContentLength ( textarea . value ) >= FEEDBACK_CONTENT_MAX_CHARS
367+ ) {
368+ armRejectedInsertionCaret ( textarea ) ;
369+ return ;
370+ }
371+ if (
372+ textarea . selectionStart === textarea . selectionEnd
373+ && feedbackContentLength ( textarea . value ) >= FEEDBACK_CONTENT_MAX_CHARS
374+ ) {
375+ // Native maxLength owns full-input rejection so the browser does not
376+ // create a JavaScript edit boundary that consumes the next undo.
377+ return ;
378+ }
379+ const insertedText = nativeEvent . inputType === 'insertLineBreak'
380+ || nativeEvent . inputType === 'insertParagraph'
381+ ? '\n'
382+ : nativeEvent . data ;
383+ if (
384+ ! [ 'insertText' , 'insertLineBreak' , 'insertParagraph' ] . includes ( nativeEvent . inputType )
385+ || nativeEvent . isComposing
386+ || insertedText == null
387+ ) {
388+ return ;
389+ }
390+ const acceptedText = feedbackInsertText (
391+ textarea . value ,
392+ textarea . selectionStart ,
393+ textarea . selectionEnd ,
394+ insertedText ,
395+ ) ;
396+ if ( acceptedText === insertedText ) return ;
397+ event . preventDefault ( ) ;
398+ applyFeedbackInsertion ( textarea , insertedText ) ;
399+ } ;
400+
274401 useEffect ( ( ) => {
275402 mountedRef . current = true ;
276403 setMessages ( [ ] ) ;
@@ -340,6 +467,29 @@ export const FeedbackConversationView: React.FC<FeedbackConversationViewProps> =
340467 < RefreshCw size = { 15 } aria-hidden = "true" />
341468 </ IconButton >
342469 </ div >
470+ { error || ackError ? (
471+ < div className = "bitfun-feedback__conversation-notices" >
472+ { error ? (
473+ < div className = "bitfun-feedback__message-error" role = "alert" >
474+ < span > { conversationErrorText ( error . code , t ) } </ span >
475+ < Button
476+ type = "button"
477+ variant = "ghost"
478+ size = "small"
479+ disabled = { sending }
480+ onClick = { ( ) => void loadLatest ( true ) }
481+ >
482+ { t ( 'feedback.actions.retry' ) }
483+ </ Button >
484+ </ div >
485+ ) : null }
486+ { ackError ? (
487+ < div className = "bitfun-feedback__message-notice" role = "status" >
488+ { t ( 'feedback.conversation.ackFailed' ) }
489+ </ div >
490+ ) : null }
491+ </ div >
492+ ) : null }
343493 < div ref = { scrollRef } className = "bitfun-feedback__messages" aria-live = "polite" >
344494 < div ref = { topSentinelRef } className = "bitfun-feedback__message-sentinel" aria-hidden = "true" />
345495 { loading || loadingEarlier ? (
@@ -349,26 +499,7 @@ export const FeedbackConversationView: React.FC<FeedbackConversationViewProps> =
349499 : t ( 'feedback.conversation.loading' ) }
350500 </ div >
351501 ) : null }
352- { error ? (
353- < div className = "bitfun-feedback__message-error" role = "alert" >
354- < span > { conversationErrorText ( error . code , t ) } </ span >
355- < Button
356- type = "button"
357- variant = "ghost"
358- size = "small"
359- disabled = { sending }
360- onClick = { ( ) => void loadLatest ( true ) }
361- >
362- { t ( 'feedback.actions.retry' ) }
363- </ Button >
364- </ div >
365- ) : null }
366- { ackError ? (
367- < div className = "bitfun-feedback__message-notice" role = "status" >
368- { t ( 'feedback.conversation.ackFailed' ) }
369- </ div >
370- ) : null }
371- { ! loading && messages . length === 0 && ! error ? (
502+ { ! loading && ! refreshing && messages . length === 0 && ! error ? (
372503 < div className = "bitfun-feedback__message-empty" >
373504 { t ( 'feedback.conversation.empty' ) }
374505 </ div >
@@ -401,11 +532,22 @@ export const FeedbackConversationView: React.FC<FeedbackConversationViewProps> =
401532 < form className = "bitfun-feedback__reply" onSubmit = { requestReply } >
402533 < Textarea
403534 value = { draft }
535+ maxLength = { draftNativeMaxLength }
404536 rows = { 3 }
405537 disabled = { sending }
406538 aria-label = { t ( 'feedback.reply.input' ) }
407539 placeholder = { t ( 'feedback.reply.placeholder' ) }
408- onChange = { event => handleDraftChange ( event . target . value ) }
540+ onKeyDown = { handleReplyKeyDown }
541+ onKeyUp = { event => restoreRejectedInsertionCaret ( event . currentTarget ) }
542+ onInput = { event => restoreRejectedInsertionCaret ( event . currentTarget ) }
543+ onCompositionStart = { handleCompositionStart }
544+ onCompositionEnd = { event => {
545+ handleCompositionEnd ( ) ;
546+ restoreRejectedInsertionCaret ( event . currentTarget ) ;
547+ } }
548+ onPaste = { handleDraftPaste }
549+ onBeforeInput = { handleDraftBeforeInput }
550+ onChange = { event => handleDraftChange ( event . target ) }
409551 />
410552 < div className = "bitfun-feedback__reply-meta" aria-live = "polite" >
411553 < span > { draftTruncated ? t ( 'feedback.contentTruncated' ) : '' } </ span >
0 commit comments