flash on scroll
This commit is contained in:
@@ -20,6 +20,7 @@ import {atoms as a} from '#/alf'
|
||||
import * as ContextMenu from '#/components/ContextMenu'
|
||||
import {type TriggerProps} from '#/components/ContextMenu/types'
|
||||
import {useMessageDialogs} from '#/components/dms/MessageOverlays'
|
||||
import {useMessageReplies} from '#/components/dms/MessageReplies'
|
||||
import {ArrowCornerDownRight_Stroke2_Corner2_Rounded as ReplyIcon} from '#/components/icons/ArrowCornerDownRight'
|
||||
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
|
||||
import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag'
|
||||
@@ -47,7 +48,8 @@ export let MessageContextMenu = ({
|
||||
const ax = useAnalytics()
|
||||
const {currentAccount} = useSession()
|
||||
const convo = useConvoActive()
|
||||
const {openDeleteMessage, openReportMessage, openReply} = useMessageDialogs()
|
||||
const {openDeleteMessage, openReportMessage} = useMessageDialogs()
|
||||
const {openReply} = useMessageReplies()
|
||||
const langPrefs = useLanguagePrefs()
|
||||
const translate = useGoogleTranslate()
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ import Animated, {
|
||||
LinearTransition,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withDelay,
|
||||
withSequence,
|
||||
withTiming,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
@@ -46,6 +48,7 @@ import {isOnlyEmoji} from '#/alf/typography'
|
||||
import {Button} from '#/components/Button'
|
||||
import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
|
||||
import {useMessageDialogs} from '#/components/dms/MessageOverlays'
|
||||
import {useMessageReplies} from '#/components/dms/MessageReplies'
|
||||
import {ArrowCornerDownRight_Stroke2_Corner3_Rounded as ArrowCornerDownRightIcon} from '#/components/icons/ArrowCornerDownRight'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import * as ProfileCard from '#/components/ProfileCard'
|
||||
@@ -148,7 +151,8 @@ let MessageItem = ({
|
||||
const {message} = item
|
||||
const profile = useMaybeProfileShadow(relatedProfiles.get(message.sender.did))
|
||||
|
||||
const {openReactions, scrollToMessage} = useMessageDialogs()
|
||||
const {openReactions} = useMessageDialogs()
|
||||
const {scrollToMessage, highlightedMessage} = useMessageReplies()
|
||||
|
||||
// `replyTo` comes back hydrated as the referenced message (or a deleted-
|
||||
// message tombstone). Narrow away the open-union fallback so we only render
|
||||
@@ -256,6 +260,26 @@ let MessageItem = ({
|
||||
topRadiusSV.set(withTiming(targetTopRadius, {duration: 300}))
|
||||
}, [targetTopRadius, topRadiusSV])
|
||||
|
||||
// Flash the message background when it's been scrolled to (e.g. by tapping a
|
||||
// reply that quotes it), so it's easy to spot. Keyed on the highlight `key`
|
||||
// so re-tapping the same message re-triggers the flash.
|
||||
const highlightSV = useSharedValue(0)
|
||||
const isHighlighted = highlightedMessage?.id === message.id
|
||||
const highlightKey = isHighlighted ? highlightedMessage.key : null
|
||||
useEffect(() => {
|
||||
if (highlightKey === null) return
|
||||
highlightSV.set(
|
||||
withSequence(
|
||||
withTiming(1, {duration: 150}),
|
||||
withDelay(400, withTiming(0, {duration: 450})),
|
||||
),
|
||||
)
|
||||
}, [highlightKey, highlightSV])
|
||||
|
||||
const highlightStyle = useAnimatedStyle(() => ({
|
||||
opacity: highlightSV.get(),
|
||||
}))
|
||||
|
||||
const borderRadiusStyle = useAnimatedStyle(() =>
|
||||
isFromSelf
|
||||
? {
|
||||
@@ -402,6 +426,14 @@ let MessageItem = ({
|
||||
web: a.mx_lg,
|
||||
})
|
||||
|
||||
// Negative of `messageInset` so the flash bleeds past the row's horizontal
|
||||
// margin to the screen edges.
|
||||
const flashBleed = platform<number>({
|
||||
android: -a.mx_sm.marginLeft,
|
||||
ios: -a.mx_md.marginLeft,
|
||||
web: -a.mx_lg.marginLeft,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasLargeGapFromPrev && <DateDivider date={message.sentAt} />}
|
||||
@@ -411,6 +443,20 @@ let MessageItem = ({
|
||||
isFirstInCluster ? a.mt_md : {marginTop: CLUSTERED_MESSAGE_GAP},
|
||||
hasReactions && {paddingBottom: 26},
|
||||
]}>
|
||||
<Animated.View
|
||||
pointerEvents="none"
|
||||
style={[
|
||||
a.absolute,
|
||||
{
|
||||
top: -CLUSTERED_MESSAGE_GAP,
|
||||
bottom: -CLUSTERED_MESSAGE_GAP,
|
||||
left: flashBleed,
|
||||
right: flashBleed,
|
||||
backgroundColor: utils.alpha(t.palette.primary_500, 0.1),
|
||||
},
|
||||
highlightStyle,
|
||||
]}
|
||||
/>
|
||||
<View style={[a.relative]}>
|
||||
{showAvatar ? (
|
||||
<View style={[a.absolute, a.bottom_0, a.z_50]}>{avatar}</View>
|
||||
|
||||
@@ -29,16 +29,6 @@ type MessageDialogsContextType = {
|
||||
senderProfile: bsky.profile.AnyProfileView | undefined,
|
||||
) => void
|
||||
openReactions: (message: ChatBskyConvoDefs.MessageView) => void
|
||||
/**
|
||||
* The message currently staged for reply in the composer, or null.
|
||||
*/
|
||||
replyTo: ChatBskyConvoDefs.MessageView | null
|
||||
openReply: (message: ChatBskyConvoDefs.MessageView) => void
|
||||
clearReply: () => void
|
||||
/**
|
||||
* Scroll the list to a message, if it's currently loaded. No-op otherwise.
|
||||
*/
|
||||
scrollToMessage: (messageId: string) => void
|
||||
}
|
||||
|
||||
const Context = createContext<MessageDialogsContextType | null>(null)
|
||||
@@ -51,13 +41,7 @@ export function useMessageDialogs() {
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function MessageOverlays({
|
||||
children,
|
||||
scrollToMessage,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
scrollToMessage: (messageId: string) => void
|
||||
}) {
|
||||
export function MessageOverlays({children}: {children: React.ReactNode}) {
|
||||
const {t: l} = useLingui()
|
||||
const queryClient = useQueryClient()
|
||||
const convo = useConvoActive()
|
||||
@@ -77,9 +61,6 @@ export function MessageOverlays({
|
||||
useState<ChatBskyConvoDefs.MessageView | null>(null)
|
||||
const [reactionsTarget, setReactionsTarget] =
|
||||
useState<ChatBskyConvoDefs.MessageView | null>(null)
|
||||
const [replyTo, setReplyTo] = useState<ChatBskyConvoDefs.MessageView | null>(
|
||||
null,
|
||||
)
|
||||
|
||||
const openDeleteMessage = useCallback(
|
||||
(message: ChatBskyConvoDefs.MessageView) => {
|
||||
@@ -107,14 +88,6 @@ export function MessageOverlays({
|
||||
[],
|
||||
)
|
||||
|
||||
const openReply = useCallback((message: ChatBskyConvoDefs.MessageView) => {
|
||||
setReplyTo(message)
|
||||
}, [])
|
||||
|
||||
const clearReply = useCallback(() => {
|
||||
setReplyTo(null)
|
||||
}, [])
|
||||
|
||||
// These dialogs are conditionally mounted, so we can't open them in the same
|
||||
// tick that we set their targets - the control refs aren't attached yet. Open
|
||||
// in an effect after the dialog has mounted.
|
||||
@@ -148,24 +121,8 @@ export function MessageOverlays({
|
||||
}, [queryClient, reportTarget])
|
||||
|
||||
const ctx = useMemo<MessageDialogsContextType>(
|
||||
() => ({
|
||||
openDeleteMessage,
|
||||
openReportMessage,
|
||||
openReactions,
|
||||
replyTo,
|
||||
openReply,
|
||||
clearReply,
|
||||
scrollToMessage,
|
||||
}),
|
||||
[
|
||||
openDeleteMessage,
|
||||
openReportMessage,
|
||||
openReactions,
|
||||
replyTo,
|
||||
openReply,
|
||||
clearReply,
|
||||
scrollToMessage,
|
||||
],
|
||||
() => ({openDeleteMessage, openReportMessage, openReactions}),
|
||||
[openDeleteMessage, openReportMessage, openReactions],
|
||||
)
|
||||
|
||||
// `reactionsTarget` is a snapshot from when the dialog was opened. Read the
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {type ChatBskyConvoDefs} from '@atproto/api'
|
||||
|
||||
/**
|
||||
* How long a message stays highlighted after scrolling to it, before the flash
|
||||
* fades out.
|
||||
*/
|
||||
export const MESSAGE_HIGHLIGHT_DURATION_MS = 1500
|
||||
|
||||
type HighlightedMessage = {
|
||||
id: string
|
||||
/**
|
||||
* Bumped on every highlight so that re-tapping the same reply re-triggers the
|
||||
* flash even while the previous highlight is still active.
|
||||
*/
|
||||
key: number
|
||||
}
|
||||
|
||||
type MessageRepliesContextType = {
|
||||
/**
|
||||
* The message currently staged for reply in the composer, or null.
|
||||
*/
|
||||
replyTo: ChatBskyConvoDefs.MessageView | null
|
||||
setReply: (message: ChatBskyConvoDefs.MessageView) => void
|
||||
clearReply: () => void
|
||||
/**
|
||||
* Scroll the list to a message, if it's currently loaded, and flash it. No-op
|
||||
* otherwise.
|
||||
*/
|
||||
scrollToMessage: (messageId: string) => void
|
||||
/**
|
||||
* The message to flash, or null. Consumers compare against their own id.
|
||||
*/
|
||||
highlightedMessage: HighlightedMessage | null
|
||||
}
|
||||
|
||||
const Context = createContext<MessageRepliesContextType | null>(null)
|
||||
|
||||
export function useMessageReplies() {
|
||||
const ctx = useContext(Context)
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
'useMessageReplies must be used within a MessageRepliesProvider',
|
||||
)
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function MessageRepliesProvider({
|
||||
children,
|
||||
scrollToMessage: scrollToMessageRaw,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
/**
|
||||
* Performs the actual scroll. Returns true if the message was found and
|
||||
* scrolled to, false if it isn't currently loaded (so we know whether to
|
||||
* flash it).
|
||||
*/
|
||||
scrollToMessage: (messageId: string) => boolean
|
||||
}) {
|
||||
const [replyTo, setReplyTo] = useState<ChatBskyConvoDefs.MessageView | null>(
|
||||
null,
|
||||
)
|
||||
const [highlightedMessage, setHighlightedMessage] =
|
||||
useState<HighlightedMessage | null>(null)
|
||||
const highlightKey = useRef(0)
|
||||
const clearHighlightTimeout = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
)
|
||||
|
||||
const setReply = useCallback((message: ChatBskyConvoDefs.MessageView) => {
|
||||
setReplyTo(message)
|
||||
}, [])
|
||||
|
||||
const clearReply = useCallback(() => {
|
||||
setReplyTo(null)
|
||||
}, [])
|
||||
|
||||
const scrollToMessage = useCallback(
|
||||
(messageId: string) => {
|
||||
const didScroll = scrollToMessageRaw(messageId)
|
||||
if (!didScroll) return
|
||||
|
||||
highlightKey.current += 1
|
||||
setHighlightedMessage({id: messageId, key: highlightKey.current})
|
||||
if (clearHighlightTimeout.current) {
|
||||
clearTimeout(clearHighlightTimeout.current)
|
||||
}
|
||||
clearHighlightTimeout.current = setTimeout(() => {
|
||||
setHighlightedMessage(null)
|
||||
}, MESSAGE_HIGHLIGHT_DURATION_MS)
|
||||
},
|
||||
[scrollToMessageRaw],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (clearHighlightTimeout.current) {
|
||||
clearTimeout(clearHighlightTimeout.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const ctx = useMemo<MessageRepliesContextType>(
|
||||
() => ({
|
||||
replyTo,
|
||||
setReply,
|
||||
clearReply,
|
||||
scrollToMessage,
|
||||
highlightedMessage,
|
||||
}),
|
||||
[replyTo, setReply, clearReply, scrollToMessage, highlightedMessage],
|
||||
)
|
||||
|
||||
return <Context.Provider value={ctx}>{children}</Context.Provider>
|
||||
}
|
||||
@@ -29,7 +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 {useMessageDialogs} from '#/components/dms/MessageOverlays'
|
||||
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'
|
||||
@@ -65,7 +65,7 @@ export function MessageComposer({
|
||||
const editable = !needsEmailVerification && !loading
|
||||
const {getDraft, clearDraft} = useMessageDraft()
|
||||
const composerInternalApiRef = useComposerInternalApiRef()
|
||||
const {replyTo, clearReply} = useMessageDialogs()
|
||||
const {replyTo, clearReply} = useMessageReplies()
|
||||
|
||||
const [text, setText] = useState(getDraft)
|
||||
useSaveMessageDraft(text)
|
||||
|
||||
@@ -7,7 +7,7 @@ import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-disp
|
||||
import {useConvoActive} from '#/state/messages/convo'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {useMessageDialogs} from '#/components/dms/MessageOverlays'
|
||||
import {useMessageReplies} from '#/components/dms/MessageReplies'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
@@ -19,7 +19,7 @@ export function MessageInputReply() {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const convo = useConvoActive()
|
||||
const {replyTo, clearReply} = useMessageDialogs()
|
||||
const {replyTo, clearReply} = useMessageReplies()
|
||||
|
||||
if (!replyTo) {
|
||||
return null
|
||||
|
||||
@@ -63,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'
|
||||
@@ -531,7 +532,8 @@ export function MessagesList({
|
||||
|
||||
// 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.
|
||||
// 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 =>
|
||||
@@ -540,7 +542,7 @@ export function MessagesList({
|
||||
item.type === 'deleted-message') &&
|
||||
item.message.id === messageId,
|
||||
)
|
||||
if (index === -1) return
|
||||
if (index === -1) return false
|
||||
|
||||
ax.metric('chat:message:reply:tap', {convoId: convoState.convo.view.id})
|
||||
flatListRef.current?.scrollToIndex({
|
||||
@@ -548,6 +550,7 @@ export function MessagesList({
|
||||
viewPosition: 0.3,
|
||||
animated: true,
|
||||
})
|
||||
return true
|
||||
})
|
||||
|
||||
const renderItem = ({item, index}: {item: RenderItem; index: number}) => {
|
||||
@@ -601,124 +604,127 @@ export function MessagesList({
|
||||
|
||||
return (
|
||||
<InviteLinkDialogProvider convo={convoState.convo}>
|
||||
<MessageOverlays scrollToMessage={scrollToMessage}>
|
||||
<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}
|
||||
messageEmbed={messageEmbed}
|
||||
setEmbed={setEmbed}
|
||||
loading={loading}
|
||||
useNewComposer={ax.features.enabled(
|
||||
ax.features.DmsNewMessageComposerEnable,
|
||||
)}
|
||||
<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})}
|
||||
/>
|
||||
)}
|
||||
</ConversationFooter>
|
||||
</Animated.View>
|
||||
)}
|
||||
</KeyboardStickyView>
|
||||
</KeyboardGestureArea>
|
||||
}
|
||||
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}
|
||||
messageEmbed={messageEmbed}
|
||||
setEmbed={setEmbed}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridges the composer to reply state. It's rendered inside `MessageOverlays`
|
||||
* so it can read the staged reply target via `useMessageDialogs`, inject it
|
||||
* into the send call, then clear it. The reply preview is mounted alongside the
|
||||
* existing embed preview in the composer's children slot.
|
||||
* 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,
|
||||
|
||||
Reference in New Issue
Block a user