Message replies in chat (#10903)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -262,7 +262,6 @@ function InnerReady({
|
||||
{IS_LIQUID_GLASS ? (
|
||||
<ScrollEdgeEffect
|
||||
edge="top"
|
||||
effect="soft"
|
||||
style={[a.absolute, a.w_full, a.z_10, {paddingTop: topInset}]}
|
||||
onLayout={onHeaderLayout}>
|
||||
{header}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {useRef, useState} from 'react'
|
||||
import {useEffect, useRef, useState} from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import {
|
||||
useKeyboardHandler,
|
||||
@@ -13,6 +13,7 @@ import Animated, {
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {GlassContainer} from 'expo-glass-effect'
|
||||
import {LinearGradient} from 'expo-linear-gradient'
|
||||
import {type $Typed, type ChatBskyConvoDefs} from '@atproto/api'
|
||||
import {ScrollEdgeEffect} from '@bsky.app/expo-scroll-edge-effect'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
import {countGraphemes} from 'unicode-segmenter/grapheme'
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
} from '#/state/messages/message-drafts'
|
||||
import {atoms as a, native, platform, tokens, useTheme, utils} from '#/alf'
|
||||
import {Composer, useComposerInternalApiRef} from '#/components/Composer'
|
||||
import {useMessageReplies} from '#/components/dms/MessageReplies'
|
||||
import * as EmojiPicker from '#/components/EmojiPicker'
|
||||
import {GlassView} from '#/components/GlassView'
|
||||
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji'
|
||||
@@ -47,7 +49,10 @@ export function MessageComposer({
|
||||
loading = false,
|
||||
}: {
|
||||
textInputId?: string
|
||||
onSendMessage: (message: string) => void
|
||||
onSendMessage: (
|
||||
message: string,
|
||||
replyTo?: $Typed<ChatBskyConvoDefs.MessageView>,
|
||||
) => void
|
||||
hasEmbed: boolean
|
||||
setEmbed: (embedUrl: string | undefined) => void
|
||||
children?: React.ReactNode
|
||||
@@ -60,10 +65,16 @@ export function MessageComposer({
|
||||
const editable = !needsEmailVerification && !loading
|
||||
const {getDraft, clearDraft} = useMessageDraft()
|
||||
const composerInternalApiRef = useComposerInternalApiRef()
|
||||
const {replyTo, clearReply} = useMessageReplies()
|
||||
|
||||
const [text, setText] = useState(getDraft)
|
||||
useSaveMessageDraft(text)
|
||||
|
||||
useEffect(() => {
|
||||
if (!replyTo) return
|
||||
composerInternalApiRef.current?.input?.focus()
|
||||
}, [replyTo, composerInternalApiRef])
|
||||
|
||||
// Android interactive dismiss sometimes doesn't blur the input
|
||||
const blur = useNonReactiveCallback(() => {
|
||||
composerInternalApiRef.current?.input?.blur()
|
||||
@@ -80,7 +91,10 @@ export function MessageComposer({
|
||||
|
||||
const submitDisabled = !editable || (!hasEmbed && text.trim().length === 0)
|
||||
|
||||
const onSubmit = (message: string) => {
|
||||
const onSubmit = (
|
||||
message: string,
|
||||
replyTo: ChatBskyConvoDefs.MessageView | null,
|
||||
) => {
|
||||
if (!editable) return
|
||||
if (!hasEmbed && message.trim() === '') return
|
||||
const graphemeCount = countGraphemes(message)
|
||||
@@ -95,6 +109,7 @@ export function MessageComposer({
|
||||
clearDraft()
|
||||
playHaptic()
|
||||
setEmbed(undefined)
|
||||
clearReply()
|
||||
composerInternalApiRef.current?.clear()
|
||||
|
||||
if (IS_WEB) {
|
||||
@@ -103,7 +118,15 @@ export function MessageComposer({
|
||||
|
||||
// defer send by a frame so that the textinput resizes before we send the message
|
||||
requestAnimationFrame(() => {
|
||||
onSendMessage(message)
|
||||
onSendMessage(
|
||||
message,
|
||||
replyTo
|
||||
? {
|
||||
...replyTo,
|
||||
$type: 'chat.bsky.convo.defs#messageView',
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -129,18 +152,18 @@ export function MessageComposer({
|
||||
setTimeout(() => {
|
||||
if (isFlushingAutocorrectSuggestion.current) {
|
||||
isFlushingAutocorrectSuggestion.current = false
|
||||
onSubmit(text)
|
||||
onSubmit(text, replyTo)
|
||||
}
|
||||
}, 20)
|
||||
} else {
|
||||
onSubmit(text)
|
||||
onSubmit(text, replyTo)
|
||||
}
|
||||
}
|
||||
|
||||
const handleChange = (nextText: string) => {
|
||||
if (IS_IOS && isFlushingAutocorrectSuggestion.current) {
|
||||
isFlushingAutocorrectSuggestion.current = false
|
||||
onSubmit(nextText)
|
||||
onSubmit(nextText, replyTo)
|
||||
} else {
|
||||
setText(nextText)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import Animated, {
|
||||
} from 'react-native-reanimated'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {GlassContainer} from 'expo-glass-effect'
|
||||
import {type $Typed, type ChatBskyConvoDefs} from '@atproto/api'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
import {countGraphemes} from 'unicode-segmenter/grapheme'
|
||||
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
useSaveMessageDraft,
|
||||
} from '#/state/messages/message-drafts'
|
||||
import {atoms as a, platform, tokens, useTheme} from '#/alf'
|
||||
import {useMessageReplies} from '#/components/dms/MessageReplies'
|
||||
import {GlassView} from '#/components/GlassView'
|
||||
import {PaperPlaneVertical_Filled_Stroke2_Corner1_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane'
|
||||
import {Loader} from '#/components/Loader'
|
||||
@@ -47,7 +49,10 @@ export function MessageInput({
|
||||
loading = false,
|
||||
}: {
|
||||
textInputId?: string
|
||||
onSendMessage: (message: string) => Promise<void> | void
|
||||
onSendMessage: (
|
||||
message: string,
|
||||
replyTo?: $Typed<ChatBskyConvoDefs.MessageView>,
|
||||
) => Promise<void> | void
|
||||
hasEmbed: boolean
|
||||
setEmbed: (embedUrl: string | undefined) => void
|
||||
children?: React.ReactNode
|
||||
@@ -57,6 +62,7 @@ export function MessageInput({
|
||||
const t = useTheme()
|
||||
const playHaptic = useHaptics()
|
||||
const {getDraft, clearDraft} = useMessageDraft()
|
||||
const {replyTo, clearReply} = useMessageReplies()
|
||||
|
||||
// Input layout
|
||||
const {top: topInset} = useSafeAreaInsets()
|
||||
@@ -92,6 +98,9 @@ export function MessageInput({
|
||||
playHaptic()
|
||||
setEmbed(undefined)
|
||||
setMessage('')
|
||||
// Capture the reply before clearing - the deferred send below reads it.
|
||||
const reply = replyTo
|
||||
clearReply()
|
||||
if (IS_IOS) {
|
||||
setShouldEnforceClear(true)
|
||||
}
|
||||
@@ -104,7 +113,12 @@ export function MessageInput({
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
void onSendMessage(message)
|
||||
void onSendMessage(
|
||||
message,
|
||||
reply
|
||||
? {...reply, $type: 'chat.bsky.convo.defs#messageView'}
|
||||
: undefined,
|
||||
)
|
||||
})
|
||||
}, [
|
||||
editable,
|
||||
@@ -116,6 +130,8 @@ export function MessageInput({
|
||||
setEmbed,
|
||||
inputRef,
|
||||
l,
|
||||
replyTo,
|
||||
clearReply,
|
||||
])
|
||||
|
||||
useFocusedInputHandler(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {useCallback, useRef, useState} from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import {type $Typed, type ChatBskyConvoDefs} from '@atproto/api'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
import {flushSync} from 'react-dom'
|
||||
import TextareaAutosize from 'react-textarea-autosize'
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
} from '#/state/messages/message-drafts'
|
||||
import {atoms as a, flatten, useTheme} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {useMessageReplies} from '#/components/dms/MessageReplies'
|
||||
import * as EmojiPicker from '#/components/EmojiPicker'
|
||||
import {useSharedInputStyles} from '#/components/forms/TextField'
|
||||
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
|
||||
@@ -28,7 +30,10 @@ export function MessageInput({
|
||||
children,
|
||||
loading = false,
|
||||
}: {
|
||||
onSendMessage: (message: string) => void
|
||||
onSendMessage: (
|
||||
message: string,
|
||||
replyTo?: $Typed<ChatBskyConvoDefs.MessageView>,
|
||||
) => void
|
||||
hasEmbed: boolean
|
||||
setEmbed: (embedUrl: string | undefined) => void
|
||||
children?: React.ReactNode
|
||||
@@ -38,6 +43,7 @@ export function MessageInput({
|
||||
const {t: l} = useLingui()
|
||||
const t = useTheme()
|
||||
const {getDraft, clearDraft} = useMessageDraft()
|
||||
const {replyTo, clearReply} = useMessageReplies()
|
||||
const [message, setMessage] = useState(getDraft)
|
||||
|
||||
const inputStyles = useSharedInputStyles()
|
||||
@@ -58,10 +64,25 @@ export function MessageInput({
|
||||
return
|
||||
}
|
||||
clearDraft()
|
||||
onSendMessage(message)
|
||||
onSendMessage(
|
||||
message,
|
||||
replyTo
|
||||
? {...replyTo, $type: 'chat.bsky.convo.defs#messageView'}
|
||||
: undefined,
|
||||
)
|
||||
clearReply()
|
||||
setMessage('')
|
||||
setEmbed(undefined)
|
||||
}, [message, onSendMessage, l, clearDraft, hasEmbed, setEmbed])
|
||||
}, [
|
||||
message,
|
||||
onSendMessage,
|
||||
l,
|
||||
clearDraft,
|
||||
hasEmbed,
|
||||
setEmbed,
|
||||
replyTo,
|
||||
clearReply,
|
||||
])
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import {LayoutAnimation, View} from 'react-native'
|
||||
import {AppBskyEmbedRecord, ChatBskyEmbedJoinLink} from '@atproto/api'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {HITSLOP_20} from '#/lib/constants'
|
||||
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
|
||||
import {useConvoActive} from '#/state/messages/convo'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {useMessageReplies} from '#/components/dms/MessageReplies'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
/**
|
||||
* The reply staged in the message composer. Renders a preview of the message
|
||||
* being replied to, with a button to cancel the reply.
|
||||
*/
|
||||
export function MessageInputReply() {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const convo = useConvoActive()
|
||||
const {replyTo, clearReply} = useMessageReplies()
|
||||
|
||||
if (!replyTo) {
|
||||
return null
|
||||
}
|
||||
|
||||
const onRemove = () => {
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
clearReply()
|
||||
}
|
||||
|
||||
const senderProfile = convo.relatedProfiles.get(replyTo.sender.did)
|
||||
const displayName = senderProfile
|
||||
? createSanitizedDisplayName(senderProfile, false)
|
||||
: null
|
||||
|
||||
let text = replyTo.text
|
||||
let subtle = false
|
||||
if (!text.trim()) {
|
||||
subtle = true
|
||||
if (ChatBskyEmbedJoinLink.isView(replyTo.embed)) {
|
||||
text = l`(chat invite link)`
|
||||
} else if (AppBskyEmbedRecord.isView(replyTo.embed)) {
|
||||
text = l`(contains embedded content)`
|
||||
} else {
|
||||
text = l`No text`
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.flex_row,
|
||||
a.gap_sm,
|
||||
a.align_start,
|
||||
t.atoms.border_contrast_high,
|
||||
a.rounded_md,
|
||||
a.border,
|
||||
a.p_sm,
|
||||
a.mt_sm,
|
||||
a.mx_sm,
|
||||
a.gap_2xs,
|
||||
]}>
|
||||
<View style={[a.flex_1]}>
|
||||
{displayName && (
|
||||
<Text
|
||||
style={[a.text_xs, t.atoms.text_contrast_high]}
|
||||
emoji
|
||||
numberOfLines={1}>
|
||||
{displayName}
|
||||
</Text>
|
||||
)}
|
||||
<Text
|
||||
style={[a.text_sm, subtle && [a.italic, t.atoms.text_contrast_high]]}
|
||||
emoji
|
||||
numberOfLines={2}>
|
||||
{text}
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
label={l`Cancel reply`}
|
||||
onPress={onRemove}
|
||||
style={[a.px_2xs]}
|
||||
hitSlop={HITSLOP_20}>
|
||||
<XIcon size="xs" style={t.atoms.text_contrast_high} />
|
||||
</Button>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
} from '@atproto/api'
|
||||
import {useScrollEdgeEffectRef} from '@bsky.app/expo-scroll-edge-effect'
|
||||
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import {mergeRefs} from '#/lib/merge-refs'
|
||||
import {ScrollProvider} from '#/lib/ScrollContext'
|
||||
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
|
||||
@@ -62,6 +63,7 @@ import {atoms as a, platform, tokens, useTheme, web} from '#/alf'
|
||||
import {DateDivider} from '#/components/dms/DateDivider'
|
||||
import {MessageItem} from '#/components/dms/MessageItem'
|
||||
import {MessageOverlays} from '#/components/dms/MessageOverlays'
|
||||
import {MessageRepliesProvider} from '#/components/dms/MessageReplies'
|
||||
import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
|
||||
import {SystemMessageGroup} from '#/components/dms/SystemMessageGroup'
|
||||
import {SystemMessageItem} from '#/components/dms/SystemMessageItem'
|
||||
@@ -72,7 +74,12 @@ import {IS_ANDROID, IS_NATIVE, IS_WEB} from '#/env'
|
||||
import {ChatStatusInfo} from './ChatStatusInfo'
|
||||
import {groupSystemMessages, type RenderItem} from './groupSystemMessages'
|
||||
import {InviteLinkDialogProvider} from './InviteLinkDialogProvider'
|
||||
import {MessageInputEmbed, useMessageEmbed} from './MessageInputEmbed'
|
||||
import {
|
||||
type MessageEmbedState,
|
||||
MessageInputEmbed,
|
||||
useMessageEmbed,
|
||||
} from './MessageInputEmbed'
|
||||
import {MessageInputReply} from './MessageInputReply'
|
||||
import {MessagesListGroupInfoPanel} from './MessagesListGroupInfoPanel'
|
||||
import {MessagesListInfoPanel} from './MessagesListInfoPanel'
|
||||
import {KeyboardStickyView} from './vendor/KeyboardStickyView'
|
||||
@@ -371,7 +378,7 @@ export function MessagesList({
|
||||
|
||||
// -- Message sending
|
||||
const onSendMessage = useCallback(
|
||||
async (text: string) => {
|
||||
async (text: string, reply?: $Typed<ChatBskyConvoDefs.MessageView>) => {
|
||||
let rt = new RichText({text: text.trimEnd()}, {cleanNewlines: true})
|
||||
|
||||
// detect facets without resolution first - this is used to see if there's
|
||||
@@ -387,6 +394,7 @@ export function MessagesList({
|
||||
| $Typed<AppBskyEmbedRecord.View>
|
||||
| $Typed<ChatBskyEmbedJoinLink.View>
|
||||
| undefined
|
||||
let replyTo: ChatBskyConvoDefs.ReplyRef | undefined
|
||||
|
||||
// Find the embedded link facet and, if it's at the start or end of the
|
||||
// message, remove it from the text (the embed card replaces it).
|
||||
@@ -455,6 +463,10 @@ export function MessagesList({
|
||||
stripLinkFacet(uri => getChatInviteCodeFromUrl(uri) === code)
|
||||
}
|
||||
|
||||
if (reply) {
|
||||
replyTo = {messageId: reply.id}
|
||||
}
|
||||
|
||||
await rt.detectFacets(agent)
|
||||
|
||||
rt = shortenLinks(rt)
|
||||
@@ -469,10 +481,18 @@ export function MessagesList({
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
embed,
|
||||
replyTo,
|
||||
},
|
||||
embedView,
|
||||
reply,
|
||||
)
|
||||
|
||||
if (replyTo) {
|
||||
ax.metric('chat:message:reply:send', {
|
||||
convoId: convoState.convo.view.id,
|
||||
isGroup: convoState.convo.kind === 'group',
|
||||
})
|
||||
}
|
||||
if (convoState.convo.kind === 'group') {
|
||||
ax.metric('groupchat:message:send', {
|
||||
convoId: convoState.convo.view.id,
|
||||
@@ -510,6 +530,29 @@ export function MessagesList({
|
||||
})
|
||||
}, [flatListRef])
|
||||
|
||||
// Scroll to a message by id, if it's currently loaded in the list. Per the
|
||||
// feature scope, we don't fetch history to find unloaded messages - tapping a
|
||||
// reply to an out-of-window message is a no-op. Returns whether the message
|
||||
// was found, so the caller knows whether to flash it.
|
||||
const scrollToMessage = useNonReactiveCallback((messageId: string) => {
|
||||
const index = renderItems.findIndex(
|
||||
item =>
|
||||
(item.type === 'message' ||
|
||||
item.type === 'pending-message' ||
|
||||
item.type === 'deleted-message') &&
|
||||
item.message.id === messageId,
|
||||
)
|
||||
if (index === -1) return false
|
||||
|
||||
ax.metric('chat:message:reply:tap', {convoId: convoState.convo.view.id})
|
||||
flatListRef.current?.scrollToIndex({
|
||||
index,
|
||||
viewPosition: 0.3,
|
||||
animated: true,
|
||||
})
|
||||
return true
|
||||
})
|
||||
|
||||
const renderItem = ({item, index}: {item: RenderItem; index: number}) => {
|
||||
if (item.type === 'message' || item.type === 'pending-message') {
|
||||
return (
|
||||
@@ -561,138 +604,180 @@ export function MessagesList({
|
||||
|
||||
return (
|
||||
<InviteLinkDialogProvider convo={convoState.convo}>
|
||||
<MessageOverlays>
|
||||
<KeyboardGestureArea
|
||||
interpolator="ios"
|
||||
// HACKFIX: https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1419
|
||||
offset={Math.round(inputHeightJS)}
|
||||
// slightly too buggy unfortunately, enable when possible
|
||||
// textInputNativeID={textInputId}
|
||||
style={[a.flex_1]}>
|
||||
{/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */}
|
||||
<Animated.View style={[a.flex_1, animatedListStyle]}>
|
||||
<ScrollProvider onScroll={onScroll}>
|
||||
<List
|
||||
ref={flatListRef}
|
||||
data={renderItems}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
disableFullWindowScroll={true}
|
||||
disableVirtualization={true}
|
||||
// The extra two items account for the header and the footer components
|
||||
initialNumToRender={IS_NATIVE ? 32 : 62}
|
||||
maxToRenderPerBatch={IS_WEB ? 32 : 62}
|
||||
keyboardDismissMode="interactive"
|
||||
keyboardShouldPersistTaps="handled"
|
||||
maintainVisibleContentPosition={{minIndexForVisible: 0}}
|
||||
removeClippedSubviews={false}
|
||||
sideBorders={false}
|
||||
onContentSizeChange={onContentSizeChange}
|
||||
onStartReached={onStartReached}
|
||||
onScrollToIndexFailed={onScrollToIndexFailed}
|
||||
showsVerticalScrollIndicator={!IS_ANDROID}
|
||||
scrollEventThrottle={100}
|
||||
ListHeaderComponent={
|
||||
<>
|
||||
<MaybeLoader isLoading={convoState.isFetchingHistory} />
|
||||
{convoState.hasAllHistory ? (
|
||||
convoState.convo?.kind === 'group' ? (
|
||||
<MessagesListGroupInfoPanel convo={convoState.convo} />
|
||||
) : (
|
||||
<MessagesListInfoPanel convo={convoState.convo} />
|
||||
)
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
// native only (prop is not supported on web)
|
||||
renderScrollComponent={renderScrollComponent}
|
||||
contentContainerStyle={{
|
||||
paddingBottom: platform({
|
||||
// ios is slightly larger as the input has no top padding
|
||||
ios: tokens.space.lg,
|
||||
android: tokens.space.md,
|
||||
web: 0, // web uses ListFooterComponent instead for scroll reasons
|
||||
}),
|
||||
}}
|
||||
ListFooterComponent={
|
||||
<View
|
||||
style={web({height: tokens.space.md + inputHeightJS})}
|
||||
/>
|
||||
}
|
||||
style={[
|
||||
web({
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: `${t.palette.contrast_100} transparent`,
|
||||
scrollbarGutter: 'stable',
|
||||
}),
|
||||
]}
|
||||
pointerEvents={!hasScrolled ? 'none' : 'auto'}
|
||||
contentInset={{top: transparentHeaderHeight}}
|
||||
scrollIndicatorInsets={{top: transparentHeaderHeight}}
|
||||
/>
|
||||
</ScrollProvider>
|
||||
</Animated.View>
|
||||
<KeyboardStickyView
|
||||
style={[a.absolute, a.bottom_0, a.left_0, a.right_0]}
|
||||
onLayout={onInputLayout}
|
||||
minimumOffset={bottomInset}
|
||||
offset={{
|
||||
closed: platform({
|
||||
ios: tokens.space.lg, // hide bottom padding when closed
|
||||
default: 0,
|
||||
}),
|
||||
opened: 0,
|
||||
}}>
|
||||
{footer ?? (
|
||||
<Animated.View entering={FadeIn.duration(200)}>
|
||||
<ConversationFooter
|
||||
convoState={convoState}
|
||||
hasAcceptOverride={hasAcceptOverride}>
|
||||
{({loading}) =>
|
||||
ax.features.enabled(
|
||||
ax.features.DmsNewMessageComposerEnable,
|
||||
) ? (
|
||||
<MessageComposer
|
||||
textInputId={textInputId}
|
||||
onSendMessage={(message: string) =>
|
||||
void onSendMessage(message)
|
||||
}
|
||||
hasEmbed={!!messageEmbed}
|
||||
setEmbed={setEmbed}
|
||||
loading={loading}>
|
||||
<MessageInputEmbed
|
||||
embed={messageEmbed}
|
||||
setEmbed={setEmbed}
|
||||
/>
|
||||
</MessageComposer>
|
||||
) : (
|
||||
<MessageInput
|
||||
<MessageRepliesProvider scrollToMessage={scrollToMessage}>
|
||||
<MessageOverlays>
|
||||
<KeyboardGestureArea
|
||||
interpolator="ios"
|
||||
// HACKFIX: https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1419
|
||||
offset={Math.round(inputHeightJS)}
|
||||
// slightly too buggy unfortunately, enable when possible
|
||||
// textInputNativeID={textInputId}
|
||||
style={[a.flex_1]}>
|
||||
{/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */}
|
||||
<Animated.View style={[a.flex_1, animatedListStyle]}>
|
||||
<ScrollProvider onScroll={onScroll}>
|
||||
<List
|
||||
ref={flatListRef}
|
||||
data={renderItems}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
disableFullWindowScroll={true}
|
||||
disableVirtualization={true}
|
||||
// The extra two items account for the header and the footer components
|
||||
initialNumToRender={IS_NATIVE ? 32 : 62}
|
||||
maxToRenderPerBatch={IS_WEB ? 32 : 62}
|
||||
keyboardDismissMode="interactive"
|
||||
keyboardShouldPersistTaps="handled"
|
||||
maintainVisibleContentPosition={{minIndexForVisible: 0}}
|
||||
removeClippedSubviews={false}
|
||||
sideBorders={false}
|
||||
onContentSizeChange={onContentSizeChange}
|
||||
onStartReached={onStartReached}
|
||||
onScrollToIndexFailed={onScrollToIndexFailed}
|
||||
showsVerticalScrollIndicator={!IS_ANDROID}
|
||||
scrollEventThrottle={100}
|
||||
ListHeaderComponent={
|
||||
<>
|
||||
<MaybeLoader isLoading={convoState.isFetchingHistory} />
|
||||
{convoState.hasAllHistory ? (
|
||||
convoState.convo?.kind === 'group' ? (
|
||||
<MessagesListGroupInfoPanel
|
||||
convo={convoState.convo}
|
||||
/>
|
||||
) : (
|
||||
<MessagesListInfoPanel convo={convoState.convo} />
|
||||
)
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
// native only (prop is not supported on web)
|
||||
renderScrollComponent={renderScrollComponent}
|
||||
contentContainerStyle={{
|
||||
paddingBottom: platform({
|
||||
// ios is slightly larger as the input has no top padding
|
||||
ios: tokens.space.lg,
|
||||
android: tokens.space.md,
|
||||
web: 0, // web uses ListFooterComponent instead for scroll reasons
|
||||
}),
|
||||
}}
|
||||
ListFooterComponent={
|
||||
<View
|
||||
style={web({height: tokens.space.md + inputHeightJS})}
|
||||
/>
|
||||
}
|
||||
style={[
|
||||
web({
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: `${t.palette.contrast_100} transparent`,
|
||||
scrollbarGutter: 'stable',
|
||||
}),
|
||||
]}
|
||||
pointerEvents={!hasScrolled ? 'none' : 'auto'}
|
||||
contentInset={{top: transparentHeaderHeight}}
|
||||
scrollIndicatorInsets={{top: transparentHeaderHeight}}
|
||||
/>
|
||||
</ScrollProvider>
|
||||
</Animated.View>
|
||||
<KeyboardStickyView
|
||||
style={[a.absolute, a.bottom_0, a.left_0, a.right_0]}
|
||||
onLayout={onInputLayout}
|
||||
minimumOffset={bottomInset}
|
||||
offset={{
|
||||
closed: platform({
|
||||
ios: tokens.space.lg, // hide bottom padding when closed
|
||||
default: 0,
|
||||
}),
|
||||
opened: 0,
|
||||
}}>
|
||||
{footer ?? (
|
||||
<Animated.View entering={FadeIn.duration(200)}>
|
||||
<ConversationFooter
|
||||
convoState={convoState}
|
||||
hasAcceptOverride={hasAcceptOverride}>
|
||||
{({loading}) => (
|
||||
<Composer
|
||||
textInputId={textInputId}
|
||||
onSendMessage={onSendMessage}
|
||||
hasEmbed={!!messageEmbed}
|
||||
messageEmbed={messageEmbed}
|
||||
setEmbed={setEmbed}
|
||||
loading={loading}>
|
||||
<MessageInputEmbed
|
||||
embed={messageEmbed}
|
||||
setEmbed={setEmbed}
|
||||
/>
|
||||
</MessageInput>
|
||||
)
|
||||
}
|
||||
</ConversationFooter>
|
||||
</Animated.View>
|
||||
)}
|
||||
</KeyboardStickyView>
|
||||
</KeyboardGestureArea>
|
||||
loading={loading}
|
||||
useNewComposer={ax.features.enabled(
|
||||
ax.features.DmsNewMessageComposerEnable,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</ConversationFooter>
|
||||
</Animated.View>
|
||||
)}
|
||||
</KeyboardStickyView>
|
||||
</KeyboardGestureArea>
|
||||
|
||||
{newMessagesPill.show && (
|
||||
<NewMessagesPill onPress={scrollToEndOnPress} />
|
||||
)}
|
||||
</MessageOverlays>
|
||||
{newMessagesPill.show && (
|
||||
<NewMessagesPill onPress={scrollToEndOnPress} />
|
||||
)}
|
||||
</MessageOverlays>
|
||||
</MessageRepliesProvider>
|
||||
</InviteLinkDialogProvider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the new vs legacy composer and mounts the reply preview alongside the
|
||||
* existing embed preview in the composer's children slot. The staged reply
|
||||
* itself is read and cleared inside the composer via `useMessageReplies`.
|
||||
*/
|
||||
function Composer({
|
||||
textInputId,
|
||||
onSendMessage,
|
||||
messageEmbed,
|
||||
setEmbed,
|
||||
loading,
|
||||
useNewComposer,
|
||||
}: {
|
||||
textInputId: string
|
||||
onSendMessage: (
|
||||
message: string,
|
||||
replyTo?: $Typed<ChatBskyConvoDefs.MessageView>,
|
||||
) => Promise<void>
|
||||
messageEmbed: MessageEmbedState | undefined
|
||||
setEmbed: (embedUrl: string | undefined) => void
|
||||
loading?: boolean
|
||||
useNewComposer: boolean
|
||||
}) {
|
||||
const handleSendMessage = useNonReactiveCallback(
|
||||
(message: string, replyTo?: $Typed<ChatBskyConvoDefs.MessageView>) => {
|
||||
void onSendMessage(message, replyTo)
|
||||
},
|
||||
)
|
||||
|
||||
const previews = (
|
||||
<>
|
||||
<MessageInputReply />
|
||||
<MessageInputEmbed embed={messageEmbed} setEmbed={setEmbed} />
|
||||
</>
|
||||
)
|
||||
|
||||
return useNewComposer ? (
|
||||
<MessageComposer
|
||||
textInputId={textInputId}
|
||||
onSendMessage={handleSendMessage}
|
||||
hasEmbed={!!messageEmbed}
|
||||
setEmbed={setEmbed}
|
||||
loading={loading}>
|
||||
{previews}
|
||||
</MessageComposer>
|
||||
) : (
|
||||
<MessageInput
|
||||
textInputId={textInputId}
|
||||
onSendMessage={handleSendMessage}
|
||||
hasEmbed={!!messageEmbed}
|
||||
setEmbed={setEmbed}
|
||||
loading={loading}>
|
||||
{previews}
|
||||
</MessageInput>
|
||||
)
|
||||
}
|
||||
|
||||
/** Note: native only */
|
||||
function ChatScrollComponent({
|
||||
ref,
|
||||
|
||||
Reference in New Issue
Block a user