import {useCallback, useEffect, useRef, useState} from 'react'
import {type LayoutChangeEvent, View} from 'react-native'
import {useKeyboardHandler} from 'react-native-keyboard-controller'
import Animated, {
runOnJS,
scrollTo,
useAnimatedRef,
useAnimatedStyle,
useSharedValue,
} from 'react-native-reanimated'
import {type ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/hook/commonTypes'
import {
type $Typed,
type AppBskyEmbedRecord,
AppBskyRichtextFacet,
RichText,
} from '@atproto/api'
import {useHideBottomBarBorderForScreen} from '#/lib/hooks/useHideBottomBarBorder'
import {ScrollProvider} from '#/lib/ScrollContext'
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
import {
convertBskyAppUrlIfNeeded,
isBskyPostUrl,
} from '#/lib/strings/url-helpers'
import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
import {isWeb} from '#/platform/detection'
import {
type ActiveConvoStates,
isConvoActive,
useConvoActive,
} from '#/state/messages/convo'
import {
type ConvoItem,
type ConvoState,
ConvoStatus,
} from '#/state/messages/convo/types'
import {useGetPost} from '#/state/queries/post'
import {useAgent} from '#/state/session'
import {useShellLayout} from '#/state/shell/shell-layout'
import {
EmojiPicker,
type EmojiPickerState,
} from '#/view/com/composer/text-input/web/EmojiPicker'
import {List, type ListMethods} from '#/view/com/util/List'
import {ChatDisabled} from '#/screens/Messages/components/ChatDisabled'
import {MessageInput} from '#/screens/Messages/components/MessageInput'
import {MessageListError} from '#/screens/Messages/components/MessageListError'
import {ChatEmptyPill} from '#/components/dms/ChatEmptyPill'
import {MessageItem} from '#/components/dms/MessageItem'
import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {ChatStatusInfo} from './ChatStatusInfo'
import {MessageInputEmbed, useMessageEmbed} from './MessageInputEmbed'
function MaybeLoader({isLoading}: {isLoading: boolean}) {
return (
{isLoading && }
)
}
function renderItem({item}: {item: ConvoItem}) {
if (item.type === 'message' || item.type === 'pending-message') {
return
} else if (item.type === 'deleted-message') {
return Deleted message
} else if (item.type === 'error') {
return
}
return null
}
function keyExtractor(item: ConvoItem) {
return item.key
}
function onScrollToIndexFailed() {
// Placeholder function. You have to give FlatList something or else it will error.
}
export function MessagesList({
hasScrolled,
setHasScrolled,
blocked,
footer,
hasAcceptOverride,
}: {
hasScrolled: boolean
setHasScrolled: React.Dispatch>
blocked?: boolean
footer?: React.ReactNode
hasAcceptOverride?: boolean
}) {
const convoState = useConvoActive()
const agent = useAgent()
const getPost = useGetPost()
const {embedUri, setEmbed} = useMessageEmbed()
useHideBottomBarBorderForScreen()
const flatListRef = useAnimatedRef()
const [newMessagesPill, setNewMessagesPill] = useState({
show: false,
startContentOffset: 0,
})
const [emojiPickerState, setEmojiPickerState] = useState({
isOpen: false,
pos: {top: 0, left: 0, right: 0, bottom: 0, nextFocusRef: null},
})
// We need to keep track of when the scroll offset is at the bottom of the list to know when to scroll as new items
// are added to the list. For example, if the user is scrolled up to 1iew older messages, we don't want to scroll to
// the bottom.
const isAtBottom = useSharedValue(true)
// This will be used on web to assist in determining if we need to maintain the content offset
const isAtTop = useSharedValue(true)
// Used to keep track of the current content height. We'll need this in `onScroll` so we know when to start allowing
// onStartReached to fire.
const prevContentHeight = useRef(0)
const prevItemCount = useRef(0)
// -- Keep track of background state and positioning for new pill
const layoutHeight = useSharedValue(0)
const didBackground = useRef(false)
useEffect(() => {
if (convoState.status === ConvoStatus.Backgrounded) {
didBackground.current = true
}
}, [convoState.status])
// -- Scroll handling
// Every time the content size changes, that means one of two things is happening:
// 1. New messages are being added from the log or from a message you have sent
// 2. Old messages are being prepended to the top
//
// The first time that the content size changes is when the initial items are rendered. Because we cannot rely on
// `initialScrollIndex`, we need to immediately scroll to the bottom of the list. That scroll will not be animated.
//
// Subsequent resizes will only scroll to the bottom if the user is at the bottom of the list (within 100 pixels of
// the bottom). Therefore, any new messages that come in or are sent will result in an animated scroll to end. However
// we will not scroll whenever new items get prepended to the top.
const onContentSizeChange = useCallback(
(_: number, height: number) => {
// Because web does not have `maintainVisibleContentPosition` support, we will need to manually scroll to the
// previous off whenever we add new content to the previous offset whenever we add new content to the list.
if (isWeb && isAtTop.get() && hasScrolled) {
flatListRef.current?.scrollToOffset({
offset: height - prevContentHeight.current,
animated: false,
})
}
// This number _must_ be the height of the MaybeLoader component
if (height > 50 && isAtBottom.get()) {
// If the size of the content is changing by more than the height of the screen, then we don't
// want to scroll further than the start of all the new content. Since we are storing the previous offset,
// we can just scroll the user to that offset and add a little bit of padding. We'll also show the pill
// that can be pressed to immediately scroll to the end.
if (
didBackground.current &&
hasScrolled &&
height - prevContentHeight.current > layoutHeight.get() - 50 &&
convoState.items.length - prevItemCount.current > 1
) {
flatListRef.current?.scrollToOffset({
offset: prevContentHeight.current - 65,
animated: true,
})
setNewMessagesPill({
show: true,
startContentOffset: prevContentHeight.current - 65,
})
} else {
flatListRef.current?.scrollToOffset({
offset: height,
animated: hasScrolled && height > prevContentHeight.current,
})
// HACK Unfortunately, we need to call `setHasScrolled` after a brief delay,
// because otherwise there is too much of a delay between the time the content
// scrolls and the time the screen appears, causing a flicker.
// We cannot actually use a synchronous scroll here, because `onContentSizeChange`
// is actually async itself - all the info has to come across the bridge first.
if (!hasScrolled && !convoState.isFetchingHistory) {
setTimeout(() => {
setHasScrolled(true)
}, 100)
}
}
}
prevContentHeight.current = height
prevItemCount.current = convoState.items.length
didBackground.current = false
},
[
hasScrolled,
setHasScrolled,
convoState.isFetchingHistory,
convoState.items.length,
// these are stable
flatListRef,
isAtTop,
isAtBottom,
layoutHeight,
],
)
const onStartReached = useCallback(() => {
if (hasScrolled && prevContentHeight.current > layoutHeight.get()) {
convoState.fetchMessageHistory()
}
}, [convoState, hasScrolled, layoutHeight])
const onScroll = useCallback(
(e: ReanimatedScrollEvent) => {
'worklet'
layoutHeight.set(e.layoutMeasurement.height)
const bottomOffset = e.contentOffset.y + e.layoutMeasurement.height
// Most apps have a little bit of space the user can scroll past while still automatically scrolling ot the bottom
// when a new message is added, hence the 100 pixel offset
isAtBottom.set(e.contentSize.height - 100 < bottomOffset)
isAtTop.set(e.contentOffset.y <= 1)
if (
newMessagesPill.show &&
(e.contentOffset.y > newMessagesPill.startContentOffset + 200 ||
isAtBottom.get())
) {
runOnJS(setNewMessagesPill)({
show: false,
startContentOffset: 0,
})
}
},
[layoutHeight, newMessagesPill, isAtBottom, isAtTop],
)
// -- Keyboard animation handling
const {footerHeight} = useShellLayout()
const keyboardHeight = useSharedValue(0)
const keyboardIsOpening = useSharedValue(false)
// In some cases - like when the emoji piker opens - we don't want to animate the scroll in the list onLayout event.
// We use this value to keep track of when we want to disable the animation.
const layoutScrollWithoutAnimation = useSharedValue(false)
useKeyboardHandler(
{
onStart: e => {
'worklet'
// Immediate updates - like opening the emoji picker - will have a duration of zero. In those cases, we should
// just update the height here instead of having the `onMove` event do it (that event will not fire!)
if (e.duration === 0) {
layoutScrollWithoutAnimation.set(true)
keyboardHeight.set(e.height)
} else {
keyboardIsOpening.set(true)
}
},
onMove: e => {
'worklet'
keyboardHeight.set(e.height)
if (e.height > footerHeight.get()) {
scrollTo(flatListRef, 0, 1e7, false)
}
},
onEnd: e => {
'worklet'
keyboardHeight.set(e.height)
if (e.height > footerHeight.get()) {
scrollTo(flatListRef, 0, 1e7, false)
}
keyboardIsOpening.set(false)
},
},
[footerHeight],
)
const animatedListStyle = useAnimatedStyle(() => ({
marginBottom: Math.max(keyboardHeight.get(), footerHeight.get()),
}))
const animatedStickyViewStyle = useAnimatedStyle(() => ({
transform: [
{translateY: -Math.max(keyboardHeight.get(), footerHeight.get())},
],
}))
// -- Message sending
const onSendMessage = useCallback(
async (text: string) => {
let rt = new RichText({text: text.trimEnd()}, {cleanNewlines: true})
// detect facets without resolution first - this is used to see if there's
// any post links in the text that we can embed. We do this first because
// we want to remove the post link from the text, re-trim, then detect facets
rt.detectFacetsWithoutResolution()
let embed: $Typed | undefined
if (embedUri) {
try {
const post = await getPost({uri: embedUri})
if (post) {
embed = {
$type: 'app.bsky.embed.record',
record: {
uri: post.uri,
cid: post.cid,
},
}
// look for the embed uri in the facets, so we can remove it from the text
const postLinkFacet = rt.facets?.find(facet => {
return facet.features.find(feature => {
if (AppBskyRichtextFacet.isLink(feature)) {
if (isBskyPostUrl(feature.uri)) {
const url = convertBskyAppUrlIfNeeded(feature.uri)
const [_0, _1, _2, rkey] = url.split('/').filter(Boolean)
// this might have a handle instead of a DID
// so just compare the rkey - not particularly dangerous
return post.uri.endsWith(rkey)
}
}
return false
})
})
if (postLinkFacet) {
const isAtStart = postLinkFacet.index.byteStart === 0
const isAtEnd =
postLinkFacet.index.byteEnd === rt.unicodeText.graphemeLength
// remove the post link from the text
if (isAtStart || isAtEnd) {
rt.delete(
postLinkFacet.index.byteStart,
postLinkFacet.index.byteEnd,
)
}
rt = new RichText({text: rt.text.trim()}, {cleanNewlines: true})
}
}
} catch (error) {
logger.error('Failed to get post as quote for DM', {error})
}
}
await rt.detectFacets(agent)
rt = shortenLinks(rt)
rt = stripInvalidMentions(rt)
if (!hasScrolled) {
setHasScrolled(true)
}
convoState.sendMessage({
text: rt.text,
facets: rt.facets,
embed,
})
},
[agent, convoState, embedUri, getPost, hasScrolled, setHasScrolled],
)
// -- List layout changes (opening emoji keyboard, etc.)
const onListLayout = useCallback(
(e: LayoutChangeEvent) => {
layoutHeight.set(e.nativeEvent.layout.height)
if (isWeb || !keyboardIsOpening.get()) {
flatListRef.current?.scrollToEnd({
animated: !layoutScrollWithoutAnimation.get(),
})
layoutScrollWithoutAnimation.set(false)
}
},
[
flatListRef,
keyboardIsOpening,
layoutScrollWithoutAnimation,
layoutHeight,
],
)
const scrollToEndOnPress = useCallback(() => {
flatListRef.current?.scrollToOffset({
offset: prevContentHeight.current,
animated: true,
})
}, [flatListRef])
const onOpenEmojiPicker = useCallback((pos: any) => {
setEmojiPickerState({isOpen: true, pos})
}, [])
return (
<>
{/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */}
}
/>
{convoState.status === ConvoStatus.Disabled ? (
) : blocked ? (
footer
) : (
)}
{isWeb && (
setEmojiPickerState(prev => ({...prev, isOpen: false}))}
/>
)}
{newMessagesPill.show && }
>
)
}
type FooterState = 'loading' | 'new-chat' | 'request' | 'standard'
function getFooterState(
convoState: ActiveConvoStates,
hasAcceptOverride?: boolean,
): FooterState {
if (convoState.items.length === 0) {
if (convoState.isFetchingHistory) {
return 'loading'
} else {
return 'new-chat'
}
}
if (convoState.convo.status === 'request' && !hasAcceptOverride) {
return 'request'
}
return 'standard'
}
function ConversationFooter({
convoState,
hasAcceptOverride,
children,
}: {
convoState: ConvoState
hasAcceptOverride?: boolean
children?: React.ReactNode // message input
}) {
if (!isConvoActive(convoState)) {
return null
}
const footerState = getFooterState(convoState, hasAcceptOverride)
switch (footerState) {
case 'loading':
return null
case 'new-chat':
return (
<>
{children}
>
)
case 'request':
return
case 'standard':
return children
}
}