@@ -42,6 +42,7 @@ import {
4242 UserAvatar ,
4343} from '..' ;
4444import { delay } from '../../utils/editor' ;
45+ import { appendDictatedText } from '../../utils/dictationInsert' ;
4546import { hasClipboardImage as detectClipboardImage } from '../../utils/clipboard' ;
4647import { deleteReplyCacheEntry , updateReplyCache } from '../../redux/actions/cacheActions' ;
4748import { default as ROUTES } from '../../constants/routeNames' ;
@@ -513,7 +514,7 @@ export const QuickPostModalContent = forwardRef(
513514 RootNavigation . navigate ( {
514515 name : ROUTES . SCREENS . AI_IMAGE_GENERATOR ,
515516 params : {
516- suggestedPrompt : commentValue ?. trim ( ) || undefined ,
517+ suggestedPrompt : commentValueRef . current ?. trim ( ) || undefined ,
517518 onInsert : ( url : string ) => {
518519 _handleMediaInsert ( [
519520 {
@@ -533,17 +534,77 @@ export const QuickPostModalContent = forwardRef(
533534 [ _addQuickCommentIntoCache ] ,
534535 ) ;
535536
537+ // Dictation appends: see appendDictatedText for why the end of the body is the only position
538+ // that is always right here. Reads commentValueRef rather than the commentValue state, since
539+ // the sheet stays open and fires once per recorded segment, so a captured state value would
540+ // be stale from the second segment on.
541+ const _handleDictationResult = useCallback (
542+ ( text : string ) => {
543+ const body = commentValueRef . current || '' ;
544+ const next = appendDictatedText ( body , text ) ;
545+ if ( next === body ) {
546+ return ;
547+ }
548+
549+ // The collapsed caret has to travel with the text. Android's updateExtraData preserves
550+ // the caret's DISTANCE FROM THE END across a text-only update, so after an append it
551+ // lands inside the segment just added and the next keystroke splits it.
552+ const caret = next . length ;
553+
554+ // Same order as the AI assist onApply: cancel first, or the pending 500ms callback fires
555+ // with the pre-dictation body and overwrites the draft cache.
556+ _deboucedCacheUpdate . cancel ( ) ;
557+ commentValueRef . current = next ;
558+ setCommentValue ( next ) ;
559+ inputRef . current ?. setNativeProps ( { text : next , selection : { start : caret , end : caret } } ) ;
560+ _addQuickCommentIntoCache ( next ) ;
561+ } ,
562+ [ _deboucedCacheUpdate , _addQuickCommentIntoCache ] ,
563+ ) ;
564+
565+ // A sheet payload is frozen at show time, so everything handed to one has to be reached
566+ // through a ref or the sheet keeps calling the handler that existed when it opened. Both of
567+ // these write the draft cache through closures over mediaUrls / videoEmbedUrl / videoThumbUrl,
568+ // and that write replaces the whole entry, so a stale one drops media added in the meantime.
569+ // Dictation needs it most, since its sheet stays open and fires once per recorded segment.
570+ const _dictationResultRef = useRef ( _handleDictationResult ) ;
571+ const _aiImageBtnRef = useRef ( _handleAiImageBtn ) ;
572+ useEffect ( ( ) => {
573+ _dictationResultRef . current = _handleDictationResult ;
574+ _aiImageBtnRef . current = _handleAiImageBtn ;
575+ } ) ;
576+
577+ const _handleDictationBtn = ( ) => {
578+ // Dismissed for room, not for focus: the recorder needs the vertical space, and nothing is
579+ // typed while speaking. The sheet library dismisses the keyboard on hide regardless.
580+ Keyboard . dismiss ( ) ;
581+ SheetManager . show ( SheetNames . DICTATION , {
582+ payload : {
583+ onInsert : ( text : string ) => _dictationResultRef . current ( text ) ,
584+ } ,
585+ } ) ;
586+ } ;
587+
536588 const _handleAiAssistBtn = ( ) => {
537589 SheetManager . show ( SheetNames . AI_ASSIST , {
538590 payload : {
539591 text : commentValueRef . current ,
540592 supportedActions : [ 'improve' , 'check_grammar' , 'summarize' ] ,
593+ // AI image generation lives in this sheet now rather than its own toolbar icon.
594+ onGenerateImage : ( ) => _aiImageBtnRef . current ( ) ,
541595 onApply : ( output : string , _action : string ) => {
542596 // Cancel any pending debounced cache update to prevent stale overwrite
543597 _deboucedCacheUpdate . cancel ( ) ;
544598 commentValueRef . current = output ;
545599 setCommentValue ( output ) ;
546- inputRef . current ?. setNativeProps ( { text : output } ) ;
600+ // Caret to the end, for the same reason as the dictation insert above: a text-only
601+ // update keeps the caret's distance from the end, which is meaningless once the
602+ // whole body has been replaced by different text.
603+ const caret = output . length ;
604+ inputRef . current ?. setNativeProps ( {
605+ text : output ,
606+ selection : { start : caret , end : caret } ,
607+ } ) ;
547608 _addQuickCommentIntoCache ( output ) ;
548609 } ,
549610 } ,
@@ -712,7 +773,27 @@ export const QuickPostModalContent = forwardRef(
712773 ) ;
713774 } ;
714775
776+ // Content buttons first, then the AI pair, then expand: expand leaves this composer for the
777+ // full editor, so it belongs at the end rather than interrupting the compose actions. The row
778+ // is a fixed-width non-wrapping flex row, which is why AI image generation moved into the AI
779+ // assist sheet to make room for dictation rather than taking a sixth slot.
715780 const _renderExpandBtn = ( ) => {
781+ // Dictation sits with the other ways of getting content in, which puts it next to the
782+ // video button in a wave and next to the image button in a reply. Same element either
783+ // way, so the two placements cannot drift apart.
784+ const _dictationBtn = (
785+ < IconButton
786+ iconType = "MaterialCommunityIcons"
787+ name = "microphone-outline"
788+ onPress = { _handleDictationBtn }
789+ size = { 22 }
790+ color = { EStyleSheet . value ( '$primaryBlack' ) }
791+ badgeCount = "AI"
792+ badgeStyle = { styles . aiBadge }
793+ badgeTextStyle = { styles . aiBadgeText }
794+ />
795+ ) ;
796+
716797 return (
717798 < View style = { styles . toolbarContainer } >
718799 < IconButton
@@ -723,16 +804,7 @@ export const QuickPostModalContent = forwardRef(
723804 size = { 24 }
724805 color = { EStyleSheet . value ( '$primaryBlack' ) }
725806 />
726- { mode !== 'wave' && canCommentToCommunity && (
727- < IconButton
728- iconType = "MaterialCommunityIcons"
729- name = "arrow-expand"
730- onPress = { _handleExpandBtn }
731- size = { 24 }
732- color = { EStyleSheet . value ( '$primaryBlack' ) }
733- />
734- ) }
735- { mode === 'wave' && (
807+ { mode === 'wave' ? (
736808 < >
737809 < IconButton
738810 iconType = "MaterialCommunityIcons"
@@ -742,6 +814,7 @@ export const QuickPostModalContent = forwardRef(
742814 color = { EStyleSheet . value ( videoEmbedUrl ? '$primaryBlue' : '$primaryBlack' ) }
743815 disabled = { ! ! videoEmbedUrl || isVideoUploading }
744816 />
817+ { _dictationBtn }
745818 < IconButton
746819 iconType = "SimpleLineIcons"
747820 style = { ! ! pollDraft && styles . iconBottomBar }
@@ -751,17 +824,9 @@ export const QuickPostModalContent = forwardRef(
751824 color = { EStyleSheet . value ( '$primaryBlack' ) }
752825 />
753826 </ >
827+ ) : (
828+ _dictationBtn
754829 ) }
755- < IconButton
756- iconType = "MaterialsIcons"
757- name = "image-outline"
758- onPress = { _handleAiImageBtn }
759- size = { 24 }
760- color = { EStyleSheet . value ( '$primaryBlack' ) }
761- badgeCount = "AI"
762- badgeStyle = { styles . aiBadge }
763- badgeTextStyle = { styles . aiBadgeText }
764- />
765830 < IconButton
766831 iconType = "MaterialCommunityIcons"
767832 name = "creation"
@@ -772,6 +837,15 @@ export const QuickPostModalContent = forwardRef(
772837 badgeStyle = { styles . aiBadge }
773838 badgeTextStyle = { styles . aiBadgeText }
774839 />
840+ { mode !== 'wave' && canCommentToCommunity && (
841+ < IconButton
842+ iconType = "MaterialCommunityIcons"
843+ name = "arrow-expand"
844+ onPress = { _handleExpandBtn }
845+ size = { 24 }
846+ color = { EStyleSheet . value ( '$primaryBlack' ) }
847+ />
848+ ) }
775849 </ View >
776850 ) ;
777851 } ;
0 commit comments