Add loading state to message composer and input (#10595)

This commit is contained in:
DS Boyce
2026-05-22 13:57:19 -07:00
committed by GitHub
parent 0d14513939
commit 6159cd7777
2 changed files with 43 additions and 15 deletions
@@ -32,6 +32,7 @@ import * as EmojiPicker from '#/components/EmojiPicker'
import {GlassView} from '#/components/GlassView' 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 {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast' import * as Toast from '#/components/Toast'
import {IS_ANDROID, IS_IOS, IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env' import {IS_ANDROID, IS_IOS, IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env'
@@ -43,18 +44,20 @@ export function MessageComposer({
hasEmbed, hasEmbed,
setEmbed, setEmbed,
children, children,
loading = false,
}: { }: {
textInputId?: string textInputId?: string
onSendMessage: (message: string) => void onSendMessage: (message: string) => void
hasEmbed: boolean hasEmbed: boolean
setEmbed: (embedUrl: string | undefined) => void setEmbed: (embedUrl: string | undefined) => void
children?: React.ReactNode children?: React.ReactNode
loading?: boolean
}) { }) {
const t = useTheme() const t = useTheme()
const {t: l} = useLingui() const {t: l} = useLingui()
const playHaptic = useHaptics() const playHaptic = useHaptics()
const {needsEmailVerification} = useEmail() const {needsEmailVerification} = useEmail()
const editable = !needsEmailVerification const editable = !needsEmailVerification && !loading
const {getDraft, clearDraft} = useMessageDraft() const {getDraft, clearDraft} = useMessageDraft()
const composerInternalApiRef = useComposerInternalApiRef() const composerInternalApiRef = useComposerInternalApiRef()
@@ -209,7 +212,11 @@ export function MessageComposer({
<Composer <Composer
nativeID={textInputId} nativeID={textInputId}
label={l`Message input field`} label={l`Message input field`}
placeholder={l`Message`} placeholder={
loading
? l({message: 'Loading chat…', context: 'placeholder'})
: l({message: 'Message', context: 'action'})
}
autocompletePlacement="top-start" autocompletePlacement="top-start"
internalApiRef={composerInternalApiRef} internalApiRef={composerInternalApiRef}
defaultValue={text} defaultValue={text}
@@ -238,7 +245,11 @@ export function MessageComposer({
/> />
</View> </View>
</GlassView> </GlassView>
<SubmitButton onPress={handleSubmit} disabled={submitDisabled} /> <SubmitButton
onPress={handleSubmit}
disabled={submitDisabled}
loading={loading}
/>
</GlassContainer> </GlassContainer>
</View> </View>
</ComposerContainer> </ComposerContainer>
@@ -248,9 +259,11 @@ export function MessageComposer({
function SubmitButton({ function SubmitButton({
onPress, onPress,
disabled, disabled,
loading,
}: { }: {
onPress: () => void onPress: () => void
disabled: boolean disabled: boolean
loading: boolean
}) { }) {
const {t: l} = useLingui() const {t: l} = useLingui()
const t = useTheme() const t = useTheme()
@@ -279,7 +292,11 @@ function SubmitButton({
]} ]}
onPress={onPress} onPress={onPress}
disabled={disabled}> disabled={disabled}>
{loading ? (
<Loader size="md" fill={t.palette.white} style={[a.mb_2xs]} />
) : (
<PaperPlaneIcon size="md" fill={t.palette.white} style={[a.mb_2xs]} /> <PaperPlaneIcon size="md" fill={t.palette.white} style={[a.mb_2xs]} />
)}
</Pressable> </Pressable>
</GlassView> </GlassView>
) )
@@ -28,6 +28,7 @@ import {
import {atoms as a, platform, tokens, useTheme} from '#/alf' import {atoms as a, platform, tokens, useTheme} from '#/alf'
import {GlassView} from '#/components/GlassView' import {GlassView} from '#/components/GlassView'
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 {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast' import * as Toast from '#/components/Toast'
import {IS_ANDROID, IS_IOS, IS_WEB} from '#/env' import {IS_ANDROID, IS_IOS, IS_WEB} from '#/env'
import {ComposerContainer} from './MessageComposer' import {ComposerContainer} from './MessageComposer'
@@ -43,12 +44,14 @@ export function MessageInput({
hasEmbed, hasEmbed,
setEmbed, setEmbed,
children, children,
loading = false,
}: { }: {
textInputId?: string textInputId?: string
onSendMessage: (message: string) => Promise<void> | void onSendMessage: (message: string) => Promise<void> | void
hasEmbed: boolean hasEmbed: boolean
setEmbed: (embedUrl: string | undefined) => void setEmbed: (embedUrl: string | undefined) => void
children?: React.ReactNode children?: React.ReactNode
loading?: boolean
}) { }) {
const {t: l} = useLingui() const {t: l} = useLingui()
const t = useTheme() const t = useTheme()
@@ -67,12 +70,13 @@ export function MessageInput({
const [shouldEnforceClear, setShouldEnforceClear] = useState(false) const [shouldEnforceClear, setShouldEnforceClear] = useState(false)
const {needsEmailVerification} = useEmail() const {needsEmailVerification} = useEmail()
const editable = !needsEmailVerification && !loading
useSaveMessageDraft(message) useSaveMessageDraft(message)
useExtractEmbedFromFacets(message, setEmbed) useExtractEmbedFromFacets(message, setEmbed)
const onSubmit = useCallback(() => { const onSubmit = useCallback(() => {
if (needsEmailVerification) { if (!editable) {
return return
} }
if (!hasEmbed && message.trim() === '') { if (!hasEmbed && message.trim() === '') {
@@ -103,7 +107,7 @@ export function MessageInput({
void onSendMessage(message) void onSendMessage(message)
}) })
}, [ }, [
needsEmailVerification, editable,
hasEmbed, hasEmbed,
message, message,
clearDraft, clearDraft,
@@ -139,8 +143,7 @@ export function MessageInput({
scrollEnabled: isInputScrollable.get(), scrollEnabled: isInputScrollable.get(),
})) }))
const submitDisabled = const submitDisabled = !editable || (!hasEmbed && message.trim().length === 0)
needsEmailVerification || (!hasEmbed && message.trim().length === 0)
const blur = useCallback(() => { const blur = useCallback(() => {
inputRef.current?.blur() inputRef.current?.blur()
@@ -209,7 +212,7 @@ export function MessageInput({
ref={inputRef} ref={inputRef}
hitSlop={HITSLOP_10} hitSlop={HITSLOP_10}
animatedProps={animatedProps} animatedProps={animatedProps}
editable={!needsEmailVerification} editable={editable}
/> />
</GlassView> </GlassView>
<GlassView <GlassView
@@ -226,7 +229,11 @@ export function MessageInput({
}}> }}>
<Pressable <Pressable
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={l`Send message`} accessibilityLabel={
loading
? l({message: 'Loading chat…', context: 'placeholder'})
: l({message: 'Message', context: 'action'})
}
accessibilityHint="" accessibilityHint=""
hitSlop={HITSLOP_10} hitSlop={HITSLOP_10}
style={[ style={[
@@ -240,11 +247,15 @@ export function MessageInput({
]} ]}
onPress={onSubmit} onPress={onSubmit}
disabled={submitDisabled}> disabled={submitDisabled}>
{loading ? (
<Loader size="md" fill={t.palette.white} style={[a.mb_2xs]} />
) : (
<PaperPlaneIcon <PaperPlaneIcon
size="md" size="md"
fill={t.palette.white} fill={t.palette.white}
style={[a.mb_2xs]} style={[a.mb_2xs]}
/> />
)}
</Pressable> </Pressable>
</GlassView> </GlassView>
</GlassContainer> </GlassContainer>