* add send via chat button to post dropdown (cherry picked from commit d8458c0bc344f993266f7bc7e325d47e40619648) * let usePostQuery take uris with DIDs (cherry picked from commit 16b577ce749fd07e1d5f8461e8ca71c5b874a936) * add embed preview in composer (cherry picked from commit 795ceb98d55b6a3ab5b83187a582f9656d71db69) * rm log (cherry picked from commit 374d6b8869459f08d8442a3a47d67149e8d9ddd4) * remove params properly, or at least as close to (cherry picked from commit c20e0062c2ca4d9c2b28324eee5e713a1a3ab251) * show images in preview (cherry picked from commit 5bb617a3ce00f67bfc79784b2f81ef8dcb5bfc25) * Register embed immediately (cherry picked from commit ee120d5438a2c91c8980288665576d6a29b4c7e7) * Add hover to match embeds (cherry picked from commit 5297a5b06e499f46a9f6da510124610005db2448) * Update post dropdown copy (cherry picked from commit bc7e9f6a4303926a53c5c889f1f1b136faf20491) * Embed preview style tweaks (cherry picked from commit 9e3ccb0f25ac2f3ce6af538bb29112a3e96e01b1) * use hydrated posts from API and just use postembed component (cherry picked from commit cc0b84db87ca812d76cc69f46170ae84cfdde4ef) * fix type error (cherry picked from commit 9c49b940e1248e8a7c3b64190c5cb20750043619) * undo needless export (cherry picked from commit 1186701c997c50c0b29a809637cb9bc061b8c0a0) * fix overflow (cherry picked from commit 8868d5075062d0199c8ef6946fabde27e46ea378) --------- Co-authored-by: Eric Bailey <git@esb.lol>
444 lines
16 KiB
TypeScript
444 lines
16 KiB
TypeScript
import React, {useCallback, useRef} from 'react'
|
|
import {FlatList, LayoutChangeEvent, View} from 'react-native'
|
|
import {
|
|
KeyboardStickyView,
|
|
useKeyboardHandler,
|
|
} from 'react-native-keyboard-controller'
|
|
import {
|
|
runOnJS,
|
|
scrollTo,
|
|
useAnimatedRef,
|
|
useAnimatedStyle,
|
|
useSharedValue,
|
|
} from 'react-native-reanimated'
|
|
import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/reanimated2/hook/commonTypes'
|
|
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
|
import {AppBskyEmbedRecord, AppBskyRichtextFacet, RichText} from '@atproto/api'
|
|
|
|
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 {isConvoActive, useConvoActive} from '#/state/messages/convo'
|
|
import {ConvoItem, ConvoStatus} from '#/state/messages/convo/types'
|
|
import {useGetPost} from '#/state/queries/post'
|
|
import {useAgent} from '#/state/session'
|
|
import {clamp} from 'lib/numbers'
|
|
import {ScrollProvider} from 'lib/ScrollContext'
|
|
import {isWeb} from 'platform/detection'
|
|
import {List} from 'view/com/util/List'
|
|
import {ChatDisabled} from '#/screens/Messages/Conversation/ChatDisabled'
|
|
import {MessageInput} from '#/screens/Messages/Conversation/MessageInput'
|
|
import {MessageListError} from '#/screens/Messages/Conversation/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 {MessageInputEmbed, useMessageEmbed} from './MessageInputEmbed'
|
|
|
|
function MaybeLoader({isLoading}: {isLoading: boolean}) {
|
|
return (
|
|
<View
|
|
style={{
|
|
height: 50,
|
|
width: '100%',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
}}>
|
|
{isLoading && <Loader size="xl" />}
|
|
</View>
|
|
)
|
|
}
|
|
|
|
function renderItem({item}: {item: ConvoItem}) {
|
|
if (item.type === 'message' || item.type === 'pending-message') {
|
|
return <MessageItem item={item} />
|
|
} else if (item.type === 'deleted-message') {
|
|
return <Text>Deleted message</Text>
|
|
} else if (item.type === 'error') {
|
|
return <MessageListError item={item} />
|
|
}
|
|
|
|
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,
|
|
}: {
|
|
hasScrolled: boolean
|
|
setHasScrolled: React.Dispatch<React.SetStateAction<boolean>>
|
|
blocked?: boolean
|
|
footer?: React.ReactNode
|
|
}) {
|
|
const convoState = useConvoActive()
|
|
const agent = useAgent()
|
|
const getPost = useGetPost()
|
|
const {embedUri, setEmbed} = useMessageEmbed()
|
|
|
|
const flatListRef = useAnimatedRef<FlatList>()
|
|
|
|
const [newMessagesPill, setNewMessagesPill] = React.useState({
|
|
show: false,
|
|
startContentOffset: 0,
|
|
})
|
|
|
|
// 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 = React.useRef(false)
|
|
React.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.value && hasScrolled) {
|
|
flatListRef.current?.scrollToOffset({
|
|
offset: height - prevContentHeight.current,
|
|
animated: false,
|
|
})
|
|
}
|
|
|
|
// This number _must_ be the height of the MaybeLoader component
|
|
if (height > 50 && isAtBottom.value) {
|
|
// 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.value - 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.value,
|
|
isAtBottom.value,
|
|
layoutHeight.value,
|
|
],
|
|
)
|
|
|
|
const onStartReached = useCallback(() => {
|
|
if (hasScrolled && prevContentHeight.current > layoutHeight.value) {
|
|
convoState.fetchMessageHistory()
|
|
}
|
|
}, [convoState, hasScrolled, layoutHeight.value])
|
|
|
|
const onScroll = React.useCallback(
|
|
(e: ReanimatedScrollEvent) => {
|
|
'worklet'
|
|
layoutHeight.value = 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.value = e.contentSize.height - 100 < bottomOffset
|
|
isAtTop.value = e.contentOffset.y <= 1
|
|
|
|
if (
|
|
newMessagesPill.show &&
|
|
(e.contentOffset.y > newMessagesPill.startContentOffset + 200 ||
|
|
isAtBottom.value)
|
|
) {
|
|
runOnJS(setNewMessagesPill)({
|
|
show: false,
|
|
startContentOffset: 0,
|
|
})
|
|
}
|
|
},
|
|
[layoutHeight, newMessagesPill, isAtBottom, isAtTop],
|
|
)
|
|
|
|
// -- Keyboard animation handling
|
|
const {bottom: bottomInset} = useSafeAreaInsets()
|
|
const bottomOffset = isWeb ? 0 : clamp(60 + bottomInset, 60, 75)
|
|
|
|
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.value = true
|
|
keyboardHeight.value = e.height
|
|
} else {
|
|
keyboardIsOpening.value = true
|
|
}
|
|
},
|
|
onMove: e => {
|
|
'worklet'
|
|
keyboardHeight.value = e.height
|
|
if (e.height > bottomOffset) {
|
|
scrollTo(flatListRef, 0, 1e7, false)
|
|
}
|
|
},
|
|
onEnd: () => {
|
|
'worklet'
|
|
keyboardIsOpening.value = false
|
|
},
|
|
})
|
|
|
|
const animatedListStyle = useAnimatedStyle(() => ({
|
|
marginBottom:
|
|
keyboardHeight.value > bottomOffset ? keyboardHeight.value : bottomOffset,
|
|
}))
|
|
|
|
// -- 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: AppBskyEmbedRecord.Main | 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) {
|
|
// remove the post link from the text
|
|
rt.delete(
|
|
postLinkFacet.index.byteStart,
|
|
postLinkFacet.index.byteEnd,
|
|
)
|
|
|
|
// re-trim the text, now that we've removed the post link
|
|
//
|
|
// if the post link is at the start of the text, we don't want to leave a leading space
|
|
// so trim on both sides
|
|
if (postLinkFacet.index.byteStart === 0) {
|
|
rt = new RichText({text: rt.text.trim()}, {cleanNewlines: true})
|
|
} else {
|
|
// otherwise just trim the end
|
|
rt = new RichText(
|
|
{text: rt.text.trimEnd()},
|
|
{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 = React.useCallback(
|
|
(e: LayoutChangeEvent) => {
|
|
layoutHeight.value = e.nativeEvent.layout.height
|
|
|
|
if (isWeb || !keyboardIsOpening.value) {
|
|
flatListRef.current?.scrollToEnd({
|
|
animated: !layoutScrollWithoutAnimation.value,
|
|
})
|
|
layoutScrollWithoutAnimation.value = false
|
|
}
|
|
},
|
|
[
|
|
flatListRef,
|
|
keyboardIsOpening.value,
|
|
layoutScrollWithoutAnimation,
|
|
layoutHeight,
|
|
],
|
|
)
|
|
|
|
const scrollToEndOnPress = React.useCallback(() => {
|
|
flatListRef.current?.scrollToOffset({
|
|
offset: prevContentHeight.current,
|
|
animated: true,
|
|
})
|
|
}, [flatListRef])
|
|
|
|
return (
|
|
<>
|
|
{/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */}
|
|
<ScrollProvider onScroll={onScroll}>
|
|
<List
|
|
ref={flatListRef}
|
|
data={convoState.items}
|
|
renderItem={renderItem}
|
|
keyExtractor={keyExtractor}
|
|
containWeb={true}
|
|
// Prevents wrong position in Firefox when sending a message
|
|
// as well as scroll getting stuck on Chome when scrolling upwards.
|
|
disableContentVisibility={true}
|
|
disableVirtualization={true}
|
|
style={animatedListStyle}
|
|
// The extra two items account for the header and the footer components
|
|
initialNumToRender={isNative ? 32 : 62}
|
|
maxToRenderPerBatch={isWeb ? 32 : 62}
|
|
keyboardDismissMode="on-drag"
|
|
keyboardShouldPersistTaps="handled"
|
|
maintainVisibleContentPosition={{
|
|
minIndexForVisible: 0,
|
|
}}
|
|
removeClippedSubviews={false}
|
|
sideBorders={false}
|
|
onContentSizeChange={onContentSizeChange}
|
|
onLayout={onListLayout}
|
|
onStartReached={onStartReached}
|
|
onScrollToIndexFailed={onScrollToIndexFailed}
|
|
scrollEventThrottle={100}
|
|
ListHeaderComponent={
|
|
<MaybeLoader isLoading={convoState.isFetchingHistory} />
|
|
}
|
|
/>
|
|
</ScrollProvider>
|
|
<KeyboardStickyView offset={{closed: -bottomOffset, opened: 0}}>
|
|
{convoState.status === ConvoStatus.Disabled ? (
|
|
<ChatDisabled />
|
|
) : blocked ? (
|
|
footer
|
|
) : (
|
|
<>
|
|
{isConvoActive(convoState) &&
|
|
!convoState.isFetchingHistory &&
|
|
convoState.items.length === 0 && <ChatEmptyPill />}
|
|
<MessageInput
|
|
onSendMessage={onSendMessage}
|
|
hasEmbed={!!embedUri}
|
|
setEmbed={setEmbed}>
|
|
<MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} />
|
|
</MessageInput>
|
|
</>
|
|
)}
|
|
</KeyboardStickyView>
|
|
|
|
{newMessagesPill.show && <NewMessagesPill onPress={scrollToEndOnPress} />}
|
|
</>
|
|
)
|
|
}
|