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 * as ContextMenu from '#/components/ContextMenu'
|
||||||
import {type TriggerProps} from '#/components/ContextMenu/types'
|
import {type TriggerProps} from '#/components/ContextMenu/types'
|
||||||
import {useMessageDialogs} from '#/components/dms/MessageOverlays'
|
import {useMessageDialogs} from '#/components/dms/MessageOverlays'
|
||||||
|
import {useMessageReplies} from '#/components/dms/MessageReplies'
|
||||||
import {ArrowCornerDownRight_Stroke2_Corner2_Rounded as ReplyIcon} from '#/components/icons/ArrowCornerDownRight'
|
import {ArrowCornerDownRight_Stroke2_Corner2_Rounded as ReplyIcon} from '#/components/icons/ArrowCornerDownRight'
|
||||||
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
|
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
|
||||||
import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag'
|
import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag'
|
||||||
@@ -47,7 +48,8 @@ export let MessageContextMenu = ({
|
|||||||
const ax = useAnalytics()
|
const ax = useAnalytics()
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const convo = useConvoActive()
|
const convo = useConvoActive()
|
||||||
const {openDeleteMessage, openReportMessage, openReply} = useMessageDialogs()
|
const {openDeleteMessage, openReportMessage} = useMessageDialogs()
|
||||||
|
const {openReply} = useMessageReplies()
|
||||||
const langPrefs = useLanguagePrefs()
|
const langPrefs = useLanguagePrefs()
|
||||||
const translate = useGoogleTranslate()
|
const translate = useGoogleTranslate()
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import Animated, {
|
|||||||
LinearTransition,
|
LinearTransition,
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
|
withDelay,
|
||||||
|
withSequence,
|
||||||
withTiming,
|
withTiming,
|
||||||
ZoomIn,
|
ZoomIn,
|
||||||
ZoomOut,
|
ZoomOut,
|
||||||
@@ -46,6 +48,7 @@ import {isOnlyEmoji} from '#/alf/typography'
|
|||||||
import {Button} from '#/components/Button'
|
import {Button} from '#/components/Button'
|
||||||
import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
|
import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
|
||||||
import {useMessageDialogs} from '#/components/dms/MessageOverlays'
|
import {useMessageDialogs} from '#/components/dms/MessageOverlays'
|
||||||
|
import {useMessageReplies} from '#/components/dms/MessageReplies'
|
||||||
import {ArrowCornerDownRight_Stroke2_Corner3_Rounded as ArrowCornerDownRightIcon} from '#/components/icons/ArrowCornerDownRight'
|
import {ArrowCornerDownRight_Stroke2_Corner3_Rounded as ArrowCornerDownRightIcon} from '#/components/icons/ArrowCornerDownRight'
|
||||||
import {InlineLinkText} from '#/components/Link'
|
import {InlineLinkText} from '#/components/Link'
|
||||||
import * as ProfileCard from '#/components/ProfileCard'
|
import * as ProfileCard from '#/components/ProfileCard'
|
||||||
@@ -148,7 +151,8 @@ let MessageItem = ({
|
|||||||
const {message} = item
|
const {message} = item
|
||||||
const profile = useMaybeProfileShadow(relatedProfiles.get(message.sender.did))
|
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-
|
// `replyTo` comes back hydrated as the referenced message (or a deleted-
|
||||||
// message tombstone). Narrow away the open-union fallback so we only render
|
// message tombstone). Narrow away the open-union fallback so we only render
|
||||||
@@ -256,6 +260,26 @@ let MessageItem = ({
|
|||||||
topRadiusSV.set(withTiming(targetTopRadius, {duration: 300}))
|
topRadiusSV.set(withTiming(targetTopRadius, {duration: 300}))
|
||||||
}, [targetTopRadius, topRadiusSV])
|
}, [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(() =>
|
const borderRadiusStyle = useAnimatedStyle(() =>
|
||||||
isFromSelf
|
isFromSelf
|
||||||
? {
|
? {
|
||||||
@@ -402,6 +426,14 @@ let MessageItem = ({
|
|||||||
web: a.mx_lg,
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{hasLargeGapFromPrev && <DateDivider date={message.sentAt} />}
|
{hasLargeGapFromPrev && <DateDivider date={message.sentAt} />}
|
||||||
@@ -411,6 +443,20 @@ let MessageItem = ({
|
|||||||
isFirstInCluster ? a.mt_md : {marginTop: CLUSTERED_MESSAGE_GAP},
|
isFirstInCluster ? a.mt_md : {marginTop: CLUSTERED_MESSAGE_GAP},
|
||||||
hasReactions && {paddingBottom: 26},
|
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]}>
|
<View style={[a.relative]}>
|
||||||
{showAvatar ? (
|
{showAvatar ? (
|
||||||
<View style={[a.absolute, a.bottom_0, a.z_50]}>{avatar}</View>
|
<View style={[a.absolute, a.bottom_0, a.z_50]}>{avatar}</View>
|
||||||
|
|||||||
@@ -29,16 +29,6 @@ type MessageDialogsContextType = {
|
|||||||
senderProfile: bsky.profile.AnyProfileView | undefined,
|
senderProfile: bsky.profile.AnyProfileView | undefined,
|
||||||
) => void
|
) => void
|
||||||
openReactions: (message: ChatBskyConvoDefs.MessageView) => 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)
|
const Context = createContext<MessageDialogsContextType | null>(null)
|
||||||
@@ -51,13 +41,7 @@ export function useMessageDialogs() {
|
|||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageOverlays({
|
export function MessageOverlays({children}: {children: React.ReactNode}) {
|
||||||
children,
|
|
||||||
scrollToMessage,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode
|
|
||||||
scrollToMessage: (messageId: string) => void
|
|
||||||
}) {
|
|
||||||
const {t: l} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const convo = useConvoActive()
|
const convo = useConvoActive()
|
||||||
@@ -77,9 +61,6 @@ export function MessageOverlays({
|
|||||||
useState<ChatBskyConvoDefs.MessageView | null>(null)
|
useState<ChatBskyConvoDefs.MessageView | null>(null)
|
||||||
const [reactionsTarget, setReactionsTarget] =
|
const [reactionsTarget, setReactionsTarget] =
|
||||||
useState<ChatBskyConvoDefs.MessageView | null>(null)
|
useState<ChatBskyConvoDefs.MessageView | null>(null)
|
||||||
const [replyTo, setReplyTo] = useState<ChatBskyConvoDefs.MessageView | null>(
|
|
||||||
null,
|
|
||||||
)
|
|
||||||
|
|
||||||
const openDeleteMessage = useCallback(
|
const openDeleteMessage = useCallback(
|
||||||
(message: ChatBskyConvoDefs.MessageView) => {
|
(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
|
// 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
|
// tick that we set their targets - the control refs aren't attached yet. Open
|
||||||
// in an effect after the dialog has mounted.
|
// in an effect after the dialog has mounted.
|
||||||
@@ -148,24 +121,8 @@ export function MessageOverlays({
|
|||||||
}, [queryClient, reportTarget])
|
}, [queryClient, reportTarget])
|
||||||
|
|
||||||
const ctx = useMemo<MessageDialogsContextType>(
|
const ctx = useMemo<MessageDialogsContextType>(
|
||||||
() => ({
|
() => ({openDeleteMessage, openReportMessage, openReactions}),
|
||||||
openDeleteMessage,
|
[openDeleteMessage, openReportMessage, openReactions],
|
||||||
openReportMessage,
|
|
||||||
openReactions,
|
|
||||||
replyTo,
|
|
||||||
openReply,
|
|
||||||
clearReply,
|
|
||||||
scrollToMessage,
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
openDeleteMessage,
|
|
||||||
openReportMessage,
|
|
||||||
openReactions,
|
|
||||||
replyTo,
|
|
||||||
openReply,
|
|
||||||
clearReply,
|
|
||||||
scrollToMessage,
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// `reactionsTarget` is a snapshot from when the dialog was opened. Read the
|
// `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'
|
} from '#/state/messages/message-drafts'
|
||||||
import {atoms as a, native, platform, tokens, useTheme, utils} from '#/alf'
|
import {atoms as a, native, platform, tokens, useTheme, utils} from '#/alf'
|
||||||
import {Composer, useComposerInternalApiRef} from '#/components/Composer'
|
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 * 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'
|
||||||
@@ -65,7 +65,7 @@ export function MessageComposer({
|
|||||||
const editable = !needsEmailVerification && !loading
|
const editable = !needsEmailVerification && !loading
|
||||||
const {getDraft, clearDraft} = useMessageDraft()
|
const {getDraft, clearDraft} = useMessageDraft()
|
||||||
const composerInternalApiRef = useComposerInternalApiRef()
|
const composerInternalApiRef = useComposerInternalApiRef()
|
||||||
const {replyTo, clearReply} = useMessageDialogs()
|
const {replyTo, clearReply} = useMessageReplies()
|
||||||
|
|
||||||
const [text, setText] = useState(getDraft)
|
const [text, setText] = useState(getDraft)
|
||||||
useSaveMessageDraft(text)
|
useSaveMessageDraft(text)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-disp
|
|||||||
import {useConvoActive} from '#/state/messages/convo'
|
import {useConvoActive} from '#/state/messages/convo'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {Button} from '#/components/Button'
|
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 {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ export function MessageInputReply() {
|
|||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {t: l} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const convo = useConvoActive()
|
const convo = useConvoActive()
|
||||||
const {replyTo, clearReply} = useMessageDialogs()
|
const {replyTo, clearReply} = useMessageReplies()
|
||||||
|
|
||||||
if (!replyTo) {
|
if (!replyTo) {
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ import {atoms as a, platform, tokens, useTheme, web} from '#/alf'
|
|||||||
import {DateDivider} from '#/components/dms/DateDivider'
|
import {DateDivider} from '#/components/dms/DateDivider'
|
||||||
import {MessageItem} from '#/components/dms/MessageItem'
|
import {MessageItem} from '#/components/dms/MessageItem'
|
||||||
import {MessageOverlays} from '#/components/dms/MessageOverlays'
|
import {MessageOverlays} from '#/components/dms/MessageOverlays'
|
||||||
|
import {MessageRepliesProvider} from '#/components/dms/MessageReplies'
|
||||||
import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
|
import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
|
||||||
import {SystemMessageGroup} from '#/components/dms/SystemMessageGroup'
|
import {SystemMessageGroup} from '#/components/dms/SystemMessageGroup'
|
||||||
import {SystemMessageItem} from '#/components/dms/SystemMessageItem'
|
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
|
// 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
|
// 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 scrollToMessage = useNonReactiveCallback((messageId: string) => {
|
||||||
const index = renderItems.findIndex(
|
const index = renderItems.findIndex(
|
||||||
item =>
|
item =>
|
||||||
@@ -540,7 +542,7 @@ export function MessagesList({
|
|||||||
item.type === 'deleted-message') &&
|
item.type === 'deleted-message') &&
|
||||||
item.message.id === messageId,
|
item.message.id === messageId,
|
||||||
)
|
)
|
||||||
if (index === -1) return
|
if (index === -1) return false
|
||||||
|
|
||||||
ax.metric('chat:message:reply:tap', {convoId: convoState.convo.view.id})
|
ax.metric('chat:message:reply:tap', {convoId: convoState.convo.view.id})
|
||||||
flatListRef.current?.scrollToIndex({
|
flatListRef.current?.scrollToIndex({
|
||||||
@@ -548,6 +550,7 @@ export function MessagesList({
|
|||||||
viewPosition: 0.3,
|
viewPosition: 0.3,
|
||||||
animated: true,
|
animated: true,
|
||||||
})
|
})
|
||||||
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
const renderItem = ({item, index}: {item: RenderItem; index: number}) => {
|
const renderItem = ({item, index}: {item: RenderItem; index: number}) => {
|
||||||
@@ -601,124 +604,127 @@ export function MessagesList({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<InviteLinkDialogProvider convo={convoState.convo}>
|
<InviteLinkDialogProvider convo={convoState.convo}>
|
||||||
<MessageOverlays scrollToMessage={scrollToMessage}>
|
<MessageRepliesProvider scrollToMessage={scrollToMessage}>
|
||||||
<KeyboardGestureArea
|
<MessageOverlays>
|
||||||
interpolator="ios"
|
<KeyboardGestureArea
|
||||||
// HACKFIX: https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1419
|
interpolator="ios"
|
||||||
offset={Math.round(inputHeightJS)}
|
// HACKFIX: https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1419
|
||||||
// slightly too buggy unfortunately, enable when possible
|
offset={Math.round(inputHeightJS)}
|
||||||
// textInputNativeID={textInputId}
|
// slightly too buggy unfortunately, enable when possible
|
||||||
style={[a.flex_1]}>
|
// textInputNativeID={textInputId}
|
||||||
{/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */}
|
style={[a.flex_1]}>
|
||||||
<Animated.View style={[a.flex_1, animatedListStyle]}>
|
{/* 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
|
||||||
<MessagesListInfoPanel convo={convoState.convo} />
|
convo={convoState.convo}
|
||||||
)
|
/>
|
||||||
) : null}
|
) : (
|
||||||
</>
|
<MessagesListInfoPanel convo={convoState.convo} />
|
||||||
}
|
)
|
||||||
// native only (prop is not supported on web)
|
) : null}
|
||||||
renderScrollComponent={renderScrollComponent}
|
</>
|
||||||
contentContainerStyle={{
|
}
|
||||||
paddingBottom: platform({
|
// native only (prop is not supported on web)
|
||||||
// ios is slightly larger as the input has no top padding
|
renderScrollComponent={renderScrollComponent}
|
||||||
ios: tokens.space.lg,
|
contentContainerStyle={{
|
||||||
android: tokens.space.md,
|
paddingBottom: platform({
|
||||||
web: 0, // web uses ListFooterComponent instead for scroll reasons
|
// ios is slightly larger as the input has no top padding
|
||||||
}),
|
ios: tokens.space.lg,
|
||||||
}}
|
android: tokens.space.md,
|
||||||
ListFooterComponent={
|
web: 0, // web uses ListFooterComponent instead for scroll reasons
|
||||||
<View
|
}),
|
||||||
style={web({height: tokens.space.md + inputHeightJS})}
|
}}
|
||||||
/>
|
ListFooterComponent={
|
||||||
}
|
<View
|
||||||
style={[
|
style={web({height: tokens.space.md + inputHeightJS})}
|
||||||
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>
|
style={[
|
||||||
</Animated.View>
|
web({
|
||||||
)}
|
scrollbarWidth: 'thin',
|
||||||
</KeyboardStickyView>
|
scrollbarColor: `${t.palette.contrast_100} transparent`,
|
||||||
</KeyboardGestureArea>
|
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.show && (
|
||||||
<NewMessagesPill onPress={scrollToEndOnPress} />
|
<NewMessagesPill onPress={scrollToEndOnPress} />
|
||||||
)}
|
)}
|
||||||
</MessageOverlays>
|
</MessageOverlays>
|
||||||
|
</MessageRepliesProvider>
|
||||||
</InviteLinkDialogProvider>
|
</InviteLinkDialogProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bridges the composer to reply state. It's rendered inside `MessageOverlays`
|
* Picks the new vs legacy composer and mounts the reply preview alongside the
|
||||||
* so it can read the staged reply target via `useMessageDialogs`, inject it
|
* existing embed preview in the composer's children slot. The staged reply
|
||||||
* into the send call, then clear it. The reply preview is mounted alongside the
|
* itself is read and cleared inside the composer via `useMessageReplies`.
|
||||||
* existing embed preview in the composer's children slot.
|
|
||||||
*/
|
*/
|
||||||
function Composer({
|
function Composer({
|
||||||
textInputId,
|
textInputId,
|
||||||
|
|||||||
Reference in New Issue
Block a user