Update loading state for chats (#10596)

This commit is contained in:
DS Boyce
2026-05-25 01:58:46 -07:00
committed by GitHub
parent bf83f165ee
commit 537a1f7120
4 changed files with 163 additions and 151 deletions
+1 -32
View File
@@ -31,7 +31,7 @@ import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useConvoQuery} from '#/state/queries/messages/conversation' import {useConvoQuery} from '#/state/queries/messages/conversation'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {MessagesList} from '#/screens/Messages/components/MessagesList' import {MessagesList} from '#/screens/Messages/components/MessagesList'
import {atoms as a, useTheme, web} from '#/alf' import {atoms as a, web} from '#/alf'
import {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen' import {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen'
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy' import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
@@ -44,7 +44,6 @@ import {MessagesListHeader} from '#/components/dms/MessagesListHeader'
import {type ConvoWithDetails, parseConvoView} from '#/components/dms/util' import {type ConvoWithDetails, parseConvoView} from '#/components/dms/util'
import {Error} from '#/components/Error' import {Error} from '#/components/Error'
import * as Layout from '#/components/Layout' import * as Layout from '#/components/Layout'
import {Loader} from '#/components/Loader'
import * as Prompt from '#/components/Prompt' import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
@@ -100,7 +99,6 @@ export function MessagesConversationScreenInner({route}: Props) {
} }
function Inner({convoId}: {convoId: string}) { function Inner({convoId}: {convoId: string}) {
const t = useTheme()
const convoState = useConvo() const convoState = useConvo()
const {t: l} = useLingui() const {t: l} = useLingui()
const {currentAccount} = useSession() const {currentAccount} = useSession()
@@ -114,15 +112,7 @@ function Inner({convoId}: {convoId: string}) {
? parseConvoView(convoData, currentAccount?.did) ? parseConvoView(convoData, currentAccount?.did)
: null : null
// Because we want to give the list a chance to asynchronously scroll to the end before it is visible to the user,
// we use `hasScrolled` to determine when to render. With that said however, there is a chance that the chat will be
// empty. So, we also check for that possible state as well and render once we can.
const [hasScrolled, setHasScrolled] = useState(false) const [hasScrolled, setHasScrolled] = useState(false)
const readyToShow =
hasScrolled ||
(isConvoActive(convoState) &&
!convoState.isFetchingHistory &&
convoState.items.length === 0)
// Any time that we re-render the `Initializing` state, we have to reset `hasScrolled` to false. After entering this // Any time that we re-render the `Initializing` state, we have to reset `hasScrolled` to false. After entering this
// state, we know that we're resetting the list of messages and need to re-scroll to the bottom when they get added. // state, we know that we're resetting the list of messages and need to re-scroll to the bottom when they get added.
@@ -153,11 +143,6 @@ function Inner({convoId}: {convoId: string}) {
return ( return (
<Layout.Center style={[a.flex_1]}> <Layout.Center style={[a.flex_1]}>
{!readyToShow && (
<View style={IS_LIQUID_GLASS && {paddingTop: topInset}}>
<MessagesListHeader convo={convo} />
</View>
)}
<View style={[a.flex_1]}> <View style={[a.flex_1]}>
<InnerReady <InnerReady
convo={convo} convo={convo}
@@ -166,22 +151,6 @@ function Inner({convoId}: {convoId: string}) {
isActive={isConvoActive(convoState)} isActive={isConvoActive(convoState)}
isDisabled={convoState.status === ConvoStatus.Disabled} isDisabled={convoState.status === ConvoStatus.Disabled}
/> />
{!readyToShow && (
<View
style={[
a.absolute,
a.z_10,
a.w_full,
a.h_full,
a.justify_center,
a.align_center,
t.atoms.bg,
]}>
<View style={[{marginBottom: 75}]}>
<Loader size="xl" />
</View>
</View>
)}
</View> </View>
</Layout.Center> </Layout.Center>
) )
@@ -165,7 +165,7 @@ export function MessageComposer({
fallbackStyle={[t.atoms.bg_contrast_50]}> fallbackStyle={[t.atoms.bg_contrast_50]}>
{children} {children}
<View style={[a.flex_1]}> <View style={[a.flex_1]}>
{IS_WEB && ( {IS_WEB && !loading ? (
<EmojiPicker.Root <EmojiPicker.Root
onEmojiSelect={emoji => onEmojiSelect={emoji =>
composerInternalApiRef.current?.insert(emoji.native) composerInternalApiRef.current?.insert(emoji.native)
@@ -207,7 +207,7 @@ export function MessageComposer({
</EmojiPicker.Trigger> </EmojiPicker.Trigger>
<EmojiPicker.Picker /> <EmojiPicker.Picker />
</EmojiPicker.Root> </EmojiPicker.Root>
)} ) : null}
<Composer <Composer
nativeID={textInputId} nativeID={textInputId}
@@ -26,11 +26,13 @@ export function MessageInput({
hasEmbed, hasEmbed,
setEmbed, setEmbed,
children, children,
loading = false,
}: { }: {
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 {isMobile} = useWebMediaQueries() const {isMobile} = useWebMediaQueries()
const {t: l} = useLingui() const {t: l} = useLingui()
@@ -139,47 +141,50 @@ export function MessageInput({
// @ts-expect-error web only // @ts-expect-error web only
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}> onMouseLeave={() => setIsHovered(false)}>
<EmojiPicker.Root {loading ? null : (
onEmojiSelect={onEmojiInserted} <EmojiPicker.Root
nextFocusRef={textAreaRef}> onEmojiSelect={onEmojiInserted}
<EmojiPicker.Trigger label={l`Open emoji picker`}> nextFocusRef={textAreaRef}>
{({props, state}) => ( <EmojiPicker.Trigger label={l`Open emoji picker`}>
<Button {({props, state}) => (
style={[ <Button
a.rounded_full,
a.overflow_hidden,
a.align_center,
a.justify_center,
{
marginTop: 5,
height: 30,
width: 30,
},
]}
label={props.accessibilityLabel}
{...props}>
<View
style={[ style={[
a.absolute, a.rounded_full,
a.inset_0, a.overflow_hidden,
a.align_center, a.align_center,
a.justify_center, a.justify_center,
{ {
backgroundColor: marginTop: 5,
state.hovered || state.focused || state.pressed height: 30,
? t.atoms.bg.backgroundColor width: 30,
: undefined,
}, },
]}> ]}
<EmojiSmile size="lg" /> label={props.accessibilityLabel}
</View> {...props}>
</Button> <View
)} style={[
</EmojiPicker.Trigger> a.absolute,
<EmojiPicker.Picker /> a.inset_0,
</EmojiPicker.Root> a.align_center,
a.justify_center,
{
backgroundColor:
state.hovered || state.focused || state.pressed
? t.atoms.bg.backgroundColor
: undefined,
},
]}>
<EmojiSmile size="lg" />
</View>
</Button>
)}
</EmojiPicker.Trigger>
<EmojiPicker.Picker />
</EmojiPicker.Root>
)}
<TextareaAutosize <TextareaAutosize
ref={textAreaRef} ref={textAreaRef}
disabled={loading}
style={flatten([ style={flatten([
a.flex_1, a.flex_1,
a.px_sm, a.px_sm,
@@ -192,7 +197,11 @@ export function MessageInput({
}, },
])} ])}
maxRows={12} maxRows={12}
placeholder={l`Message`} placeholder={
loading
? l({message: 'Loading chat…', context: 'placeholder'})
: l({message: 'Message', context: 'action'})
}
defaultValue="" defaultValue=""
value={message} value={message}
dirName="ltr" dirName="ltr"
@@ -215,6 +224,7 @@ export function MessageInput({
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={l`Send message`} accessibilityLabel={l`Send message`}
accessibilityHint="" accessibilityHint=""
disabled={loading}
style={[ style={[
a.rounded_full, a.rounded_full,
a.align_center, a.align_center,
+115 -82
View File
@@ -12,13 +12,15 @@ import {
type KeyboardChatScrollViewProps, type KeyboardChatScrollViewProps,
KeyboardGestureArea, KeyboardGestureArea,
} from 'react-native-keyboard-controller' } from 'react-native-keyboard-controller'
import { import Animated, {
runOnJS, runOnJS,
type ScrollEvent, type ScrollEvent,
type SharedValue, type SharedValue,
useAnimatedRef, useAnimatedRef,
useAnimatedStyle,
useDerivedValue, useDerivedValue,
useSharedValue, useSharedValue,
withTiming,
} from 'react-native-reanimated' } from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context' import {useSafeAreaInsets} from 'react-native-safe-area-context'
import { import {
@@ -157,6 +159,16 @@ export function MessagesList({
startContentOffset: 0, startContentOffset: 0,
}) })
const listOpacity = useSharedValue(0)
useEffect(() => {
if (hasScrolled) {
listOpacity.set(withTiming(1, {duration: 200}))
} else {
listOpacity.set(0)
}
}, [hasScrolled, listOpacity])
const inputHeightUI = useSharedValue(0) const inputHeightUI = useSharedValue(0)
const [inputHeightJS, setInputHeightJS] = useState(0) const [inputHeightJS, setInputHeightJS] = useState(0)
@@ -476,6 +488,10 @@ export function MessagesList({
[inputHeightUI], [inputHeightUI],
) )
const animatedListStyle = useAnimatedStyle(() => ({
opacity: listOpacity.get(),
}))
return ( return (
<InviteLinkDialogProvider convo={convoState.convo}> <InviteLinkDialogProvider convo={convoState.convo}>
<KeyboardGestureArea <KeyboardGestureArea
@@ -486,64 +502,69 @@ export function MessagesList({
// textInputNativeID={textInputId} // textInputNativeID={textInputId}
style={[a.flex_1]}> style={[a.flex_1]}>
{/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */} {/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */}
<ScrollProvider onScroll={onScroll}> <Animated.View style={[a.flex_1, animatedListStyle]}>
<List <ScrollProvider onScroll={onScroll}>
ref={flatListRef} <List
data={renderItems} ref={flatListRef}
renderItem={renderItem} data={renderItems}
keyExtractor={keyExtractor} renderItem={renderItem}
disableFullWindowScroll={true} keyExtractor={keyExtractor}
disableVirtualization={true} disableFullWindowScroll={true}
// The extra two items account for the header and the footer components disableVirtualization={true}
initialNumToRender={IS_NATIVE ? 32 : 62} // The extra two items account for the header and the footer components
maxToRenderPerBatch={IS_WEB ? 32 : 62} initialNumToRender={IS_NATIVE ? 32 : 62}
keyboardDismissMode="interactive" maxToRenderPerBatch={IS_WEB ? 32 : 62}
keyboardShouldPersistTaps="handled" keyboardDismissMode="interactive"
maintainVisibleContentPosition={{minIndexForVisible: 0}} keyboardShouldPersistTaps="handled"
removeClippedSubviews={false} maintainVisibleContentPosition={{minIndexForVisible: 0}}
sideBorders={false} removeClippedSubviews={false}
onContentSizeChange={onContentSizeChange} sideBorders={false}
onStartReached={onStartReached} onContentSizeChange={onContentSizeChange}
onScrollToIndexFailed={onScrollToIndexFailed} onStartReached={onStartReached}
showsVerticalScrollIndicator={!IS_ANDROID} onScrollToIndexFailed={onScrollToIndexFailed}
scrollEventThrottle={100} showsVerticalScrollIndicator={!IS_ANDROID}
ListHeaderComponent={ scrollEventThrottle={100}
<> ListHeaderComponent={
<MaybeLoader isLoading={convoState.isFetchingHistory} /> <>
{convoState.hasAllHistory ? ( <MaybeLoader isLoading={convoState.isFetchingHistory} />
convoState.convo?.kind === 'group' ? ( {convoState.hasAllHistory ? (
<MessagesListGroupInfoPanel convo={convoState.convo} /> convoState.convo?.kind === 'group' ? (
) : ( <MessagesListGroupInfoPanel convo={convoState.convo} />
<MessagesListInfoPanel convo={convoState.convo} /> ) : (
) <MessagesListInfoPanel convo={convoState.convo} />
) : null} )
</> ) : null}
} </>
// native only (prop is not supported on web) }
renderScrollComponent={renderScrollComponent} // native only (prop is not supported on web)
contentContainerStyle={{ renderScrollComponent={renderScrollComponent}
paddingBottom: platform({ contentContainerStyle={{
// ios is slightly larger as the input has no top padding paddingBottom: platform({
ios: tokens.space.lg, // ios is slightly larger as the input has no top padding
android: tokens.space.md, ios: tokens.space.lg,
web: 0, // web uses ListFooterComponent instead for scroll reasons android: tokens.space.md,
}), web: 0, // web uses ListFooterComponent instead for scroll reasons
}} }),
ListFooterComponent={ }}
<View ListFooterComponent={
style={web({height: tokens.space.md + inputHeightJS})} <View
onLayout={onFooterLayout} style={web({height: tokens.space.md + inputHeightJS})}
/> onLayout={onFooterLayout}
} />
style={web({ }
scrollbarWidth: 'thin', style={[
scrollbarColor: `${t.palette.contrast_100} transparent`, web({
scrollbarGutter: 'stable', scrollbarWidth: 'thin',
})} scrollbarColor: `${t.palette.contrast_100} transparent`,
contentInset={{top: transparentHeaderHeight}} scrollbarGutter: 'stable',
scrollIndicatorInsets={{top: transparentHeaderHeight}} }),
/> ]}
</ScrollProvider> pointerEvents={!hasScrolled ? 'none' : 'auto'}
contentInset={{top: transparentHeaderHeight}}
scrollIndicatorInsets={{top: transparentHeaderHeight}}
/>
</ScrollProvider>
</Animated.View>
<KeyboardStickyView <KeyboardStickyView
style={[a.absolute, a.bottom_0, a.left_0, a.right_0]} style={[a.absolute, a.bottom_0, a.left_0, a.right_0]}
onLayout={onInputLayout} onLayout={onInputLayout}
@@ -559,25 +580,35 @@ export function MessagesList({
<ConversationFooter <ConversationFooter
convoState={convoState} convoState={convoState}
hasAcceptOverride={hasAcceptOverride}> hasAcceptOverride={hasAcceptOverride}>
{ax.features.enabled(ax.features.DmsNewMessageComposerEnable) ? ( {({loading}) =>
<MessageComposer ax.features.enabled(ax.features.DmsNewMessageComposerEnable) ? (
textInputId={textInputId} <MessageComposer
onSendMessage={(message: string) => textInputId={textInputId}
void onSendMessage(message) onSendMessage={(message: string) =>
} void onSendMessage(message)
hasEmbed={!!embedUri} }
setEmbed={setEmbed}> hasEmbed={!!embedUri}
<MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} /> setEmbed={setEmbed}
</MessageComposer> loading={loading}>
) : ( <MessageInputEmbed
<MessageInput embedUri={embedUri}
textInputId={textInputId} setEmbed={setEmbed}
onSendMessage={onSendMessage} />
hasEmbed={!!embedUri} </MessageComposer>
setEmbed={setEmbed}> ) : (
<MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} /> <MessageInput
</MessageInput> textInputId={textInputId}
)} onSendMessage={onSendMessage}
hasEmbed={!!embedUri}
setEmbed={setEmbed}
loading={loading}>
<MessageInputEmbed
embedUri={embedUri}
setEmbed={setEmbed}
/>
</MessageInput>
)
}
</ConversationFooter> </ConversationFooter>
)} )}
</KeyboardStickyView> </KeyboardStickyView>
@@ -657,23 +688,25 @@ function ConversationFooter({
}: { }: {
convoState: ConvoState convoState: ConvoState
hasAcceptOverride?: boolean hasAcceptOverride?: boolean
children?: React.ReactNode // message input children?: ((props: {loading?: boolean}) => React.ReactNode) | React.ReactNode
}) { }) {
if (!isConvoActive(convoState)) { if (!isConvoActive(convoState)) {
return null return null
} }
const footerState = getFooterState(convoState, hasAcceptOverride) const footerState = getFooterState(convoState, hasAcceptOverride)
const renderChildren = (loading?: boolean) =>
typeof children === 'function' ? children({loading}) : children
switch (footerState) { switch (footerState) {
case 'loading': case 'loading':
return null return renderChildren(true)
case 'new-chat': case 'new-chat':
// new chat pill goes here - removed for now // new chat pill goes here - removed for now
return children return renderChildren()
case 'request': case 'request':
return <ChatStatusInfo convoState={convoState} /> return <ChatStatusInfo convoState={convoState} />
case 'standard': case 'standard':
return children return renderChildren()
} }
} }