[Chat] Workaround for autocomplete suggestion bug on iOS (#10321)

This commit is contained in:
Samuel Newman
2026-04-21 13:56:07 -07:00
committed by GitHub
parent 3153ea4302
commit bfdaab0a14
@@ -1,4 +1,4 @@
import {useState} from 'react' import {useRef, useState} from 'react'
import {Pressable, View} from 'react-native' import {Pressable, View} from 'react-native'
import { import {
useKeyboardHandler, useKeyboardHandler,
@@ -19,6 +19,7 @@ import {countGraphemes} from 'unicode-segmenter/grapheme'
import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants' import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants'
import {useHaptics} from '#/lib/haptics' import {useHaptics} from '#/lib/haptics'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {isBskyPostUrl} from '#/lib/strings/url-helpers' import {isBskyPostUrl} from '#/lib/strings/url-helpers'
import {useEmail} from '#/state/email-verification' import {useEmail} from '#/state/email-verification'
import { import {
@@ -32,7 +33,7 @@ import {GlassView} from '#/components/GlassView'
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji' import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji'
import {PaperPlaneVertical_Filled_Stroke2_Corner1_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane' import {PaperPlaneVertical_Filled_Stroke2_Corner1_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane'
import * as Toast from '#/components/Toast' import * as Toast from '#/components/Toast'
import {IS_ANDROID, IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env' import {IS_ANDROID, IS_IOS, IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env'
const MIN_HEIGHT = 40 const MIN_HEIGHT = 40
@@ -61,9 +62,9 @@ export function MessageComposer({
useSaveMessageDraft(text) useSaveMessageDraft(text)
// Android interactive dismiss sometimes doesn't blur the input // Android interactive dismiss sometimes doesn't blur the input
const blur = () => { const blur = useNonReactiveCallback(() => {
composerInternalApiRef.current?.input?.blur() composerInternalApiRef.current?.input?.blur()
} })
useKeyboardHandler({ useKeyboardHandler({
onEnd: evt => { onEnd: evt => {
@@ -76,10 +77,10 @@ export function MessageComposer({
const submitDisabled = !editable || (!hasEmbed && text.trim().length === 0) const submitDisabled = !editable || (!hasEmbed && text.trim().length === 0)
const onSubmit = () => { const onSubmit = (message: string) => {
if (!editable) return if (!editable) return
if (!hasEmbed && text.trim() === '') return if (!hasEmbed && message.trim() === '') return
const graphemeCount = countGraphemes(text) const graphemeCount = countGraphemes(message)
if (graphemeCount > MAX_DM_GRAPHEME_LENGTH) { if (graphemeCount > MAX_DM_GRAPHEME_LENGTH) {
Toast.show( Toast.show(
l`Message is too long (${graphemeCount}/${MAX_DM_GRAPHEME_LENGTH})`, l`Message is too long (${graphemeCount}/${MAX_DM_GRAPHEME_LENGTH})`,
@@ -89,7 +90,7 @@ export function MessageComposer({
} }
clearDraft() clearDraft()
onSendMessage(text) onSendMessage(message)
playHaptic() playHaptic()
setEmbed(undefined) setEmbed(undefined)
composerInternalApiRef.current?.clear() composerInternalApiRef.current?.clear()
@@ -99,6 +100,45 @@ export function MessageComposer({
} }
} }
const isFlushingAutocorrectSuggestion = useRef(false)
const handleSubmit = () => {
if (IS_IOS) {
// HACKFIX: If there's a pending autocomplete suggestion, iOS will prioritize
// accepting the suggestion over any imperative `.clear()` action on the textinput.
// This means we'll send the message with the typo while the corrected text remains
// in the composer's textinput.
//
// In MessageInput, the previous iteration, we simply sent it, and if another text change
// event came in, we'd clear it again. However, it's nicer UX to actually accept the suggestion.
//
// Thus the solution:
// 1. Set a ref indicating we're flushing the autocorrect suggestion
// 2. Watch for incoming onChange events. If something comes in, it's almost certainly the corrected text,
// so send that
// 3. Meanwhile, race that against a simple timeout. If the timeout fires first, send the original text.
//
// Hopefully, it's delaying the send by no more than a couple frames -sfn
isFlushingAutocorrectSuggestion.current = true
setTimeout(() => {
if (isFlushingAutocorrectSuggestion.current) {
isFlushingAutocorrectSuggestion.current = false
onSubmit(text)
}
}, 20)
} else {
onSubmit(text)
}
}
const handleChange = (nextText: string) => {
if (IS_IOS && isFlushingAutocorrectSuggestion.current) {
isFlushingAutocorrectSuggestion.current = false
onSubmit(nextText)
} else {
setText(nextText)
}
}
return ( return (
<ComposerContainer> <ComposerContainer>
{children} {children}
@@ -180,7 +220,7 @@ export function MessageComposer({
paddingBottom: 10, paddingBottom: 10,
paddingRight: 16 + platform({web: 20, default: 0}), paddingRight: 16 + platform({web: 20, default: 0}),
}} }}
onChange={setText} onChange={handleChange}
onFacetCommitted={facet => { onFacetCommitted={facet => {
if (facet.type === 'url' && isBskyPostUrl(facet.value)) { if (facet.type === 'url' && isBskyPostUrl(facet.value)) {
setEmbed(facet.value) setEmbed(facet.value)
@@ -189,11 +229,11 @@ export function MessageComposer({
onRequestSubmit={req => { onRequestSubmit={req => {
if (req.platform === 'web' && req.shiftKey) return if (req.platform === 'web' && req.shiftKey) return
req.nativeEvent.preventDefault() req.nativeEvent.preventDefault()
onSubmit() handleSubmit()
}} }}
/> />
</GlassView> </GlassView>
<SubmitButton onPress={onSubmit} disabled={submitDisabled} /> <SubmitButton onPress={handleSubmit} disabled={submitDisabled} />
</GlassContainer> </GlassContainer>
</View> </View>
</ComposerContainer> </ComposerContainer>