Files
bsky-social-app/src/screens/Messages/components/MessagesList.tsx
T
2026-06-24 11:27:10 -05:00

874 lines
29 KiB
TypeScript

import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from 'react'
import {type LayoutChangeEvent, type ScrollViewProps, View} from 'react-native'
import {
KeyboardChatScrollView,
type KeyboardChatScrollViewProps,
KeyboardGestureArea,
} from 'react-native-keyboard-controller'
import Animated, {
FadeIn,
runOnJS,
type ScrollEvent,
type SharedValue,
useAnimatedRef,
useAnimatedStyle,
useDerivedValue,
useSharedValue,
withTiming,
} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {
type $Typed,
type AppBskyEmbedRecord,
AppBskyRichtextFacet,
ChatBskyConvoDefs,
type ChatBskyEmbedJoinLink,
ChatBskyGroupDefs,
RichText,
} 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'
import {
convertBskyAppUrlIfNeeded,
getChatInviteCodeFromUrl,
isBskyPostUrl,
} from '#/lib/strings/url-helpers'
import {logger} from '#/logger'
import {
type ActiveConvoStates,
isConvoActive,
useConvoActive,
} from '#/state/messages/convo'
import {type ConvoState, ConvoStatus} from '#/state/messages/convo/types'
import {useGetJoinLinkPreview} from '#/state/queries/join-links'
import {useGetPost} from '#/state/queries/post'
import {createEmbedViewRecordFromPost} from '#/state/queries/postgate/util'
import {useAgent, useSession} from '#/state/session'
import {List, type ListMethods} from '#/view/com/util/List'
import {MessageComposer} from '#/screens/Messages/components/MessageComposer'
import {MessageListError} from '#/screens/Messages/components/MessageListError'
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'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_ANDROID, IS_NATIVE, IS_WEB} from '#/env'
import {ChatStatusInfo} from './ChatStatusInfo'
import {groupSystemMessages, type RenderItem} from './groupSystemMessages'
import {InviteLinkDialogProvider} from './InviteLinkDialogProvider'
import {
type MessageEmbedState,
MessageInputEmbed,
useMessageEmbed,
} from './MessageInputEmbed'
import {MessageInputReply} from './MessageInputReply'
import {MessagesListGroupInfoPanel} from './MessagesListGroupInfoPanel'
import {MessagesListInfoPanel} from './MessagesListInfoPanel'
import {KeyboardStickyView} from './vendor/KeyboardStickyView'
function MaybeLoader({isLoading}: {isLoading: boolean}) {
return (
<View
style={{
height: 50,
width: '100%',
alignItems: 'center',
justifyContent: 'center',
}}>
{isLoading && <Loader size="xl" />}
</View>
)
}
function keyExtractor(item: RenderItem) {
return item.key
}
function getNeighborMessage(
items: RenderItem[],
index: number,
): ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView | null {
const neighbor = items[index]
if (!neighbor) return null
if (
neighbor.type === 'message' ||
neighbor.type === 'pending-message' ||
neighbor.type === 'deleted-message'
) {
if (
ChatBskyConvoDefs.isMessageView(neighbor.message) ||
ChatBskyConvoDefs.isDeletedMessageView(neighbor.message)
) {
return neighbor.message
}
}
return null
}
function onScrollToIndexFailed() {
// Placeholder function. You have to give FlatList something or else it will error.
}
export function MessagesList({
hasScrolled,
setHasScrolled,
footer,
hasAcceptOverride,
transparentHeaderHeight,
}: {
hasScrolled: boolean
setHasScrolled: React.Dispatch<React.SetStateAction<boolean>>
footer?: React.ReactNode
hasAcceptOverride?: boolean
transparentHeaderHeight?: number
}) {
const ax = useAnalytics()
const convoState = useConvoActive()
const agent = useAgent()
const {hasSession, currentAccount} = useSession()
const getPost = useGetPost()
const getJoinLinkPreview = useGetJoinLinkPreview()
const {embed: messageEmbed, setEmbed} = useMessageEmbed()
const t = useTheme()
const textInputId = 'chat-input-' + useId()
const flatListRef = useAnimatedRef<ListMethods>()
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(
() => new Set(),
)
const onToggleGroup = (key: string) => {
setExpandedGroups(prev => {
const next = new Set(prev)
if (next.has(key)) {
next.delete(key)
} else {
next.add(key)
}
return next
})
}
const renderItems = groupSystemMessages(convoState.items)
const [newMessagesPill, setNewMessagesPill] = useState({
show: false,
startContentOffset: 0,
})
const listOpacity = useSharedValue(0)
useEffect(() => {
if (hasScrolled) {
listOpacity.set(withTiming(1, {duration: 200}))
} else {
listOpacity.set(0)
}
}, [hasScrolled, listOpacity])
const inputHeightUI = useSharedValue(0)
const [inputHeightJS, setInputHeightJS] = useState(0)
const onInputLayout = useCallback(
(event: LayoutChangeEvent) => {
const inputHeight = event.nativeEvent.layout.height
inputHeightUI.set(inputHeight)
setInputHeightJS(inputHeight)
},
[inputHeightUI],
)
// 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)
// Tracks whether the initial scroll-to-bottom has been triggered. Separated from isAtBottom so that contentInset
// (which causes an early onScroll with negative offset) can't prevent the first scroll.
// Reset when hasScrolled goes back to false (e.g. convo re-initialization after backgrounding).
// `didInitialScroll` is the reactive mirror of the ref so the reveal effect below can depend on it; the ref
// itself stays as the synchronous re-entry guard inside onContentSizeChange.
const hasInitiallyScrolled = useRef(false)
const [didInitialScroll, setDidInitialScroll] = useState(false)
const prevHasScrolled = useRef(hasScrolled)
useLayoutEffect(() => {
if (prevHasScrolled.current && !hasScrolled) {
hasInitiallyScrolled.current = false
setDidInitialScroll(false)
}
prevHasScrolled.current = hasScrolled
}, [hasScrolled])
// Reveal the list once history has finished loading. We can't reveal earlier because the list isn't inverted -
// we must scroll to the bottom (newest message) before fading in, or the user sees a flash of top-anchored content.
// This is purely state-driven so it doesn't depend on a layout callback firing: a firehose-delivered message can
// dedupe against the fetched history and produce no content-size change, in which case nothing would otherwise
// reveal the list and it would stay hidden forever (APP-2238). Either the initial scroll has run, or there's
// nothing to scroll (empty convo) - both are safe to reveal once !isFetchingHistory.
useEffect(() => {
if (hasScrolled || convoState.isFetchingHistory) return
if (didInitialScroll || renderItems.length === 0) {
const raf = requestAnimationFrame(() => setHasScrolled(true))
return () => cancelAnimationFrame(raf)
}
}, [
convoState.isFetchingHistory,
hasScrolled,
didInitialScroll,
renderItems.length,
setHasScrolled,
])
// -- 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 (IS_WEB && isAtTop.get() && hasScrolled) {
flatListRef.current?.scrollToOffset({
offset: height - prevContentHeight.current,
animated: false,
})
}
// Initial scroll to bottom — unconditional, not gated on isAtBottom. This is separated because contentInset
// can cause an early onScroll with a negative offset that sets isAtBottom to false before we get here.
// Empty convos take this path too (once history is done). Revealing the list is handled by the effect above,
// which fires once history finishes - we just record that the scroll has happened.
if (
!hasInitiallyScrolled.current &&
(renderItems.length > 0 || !convoState.isFetchingHistory)
) {
hasInitiallyScrolled.current = true
setDidInitialScroll(true)
flatListRef.current?.scrollToOffset({offset: height, animated: false})
prevContentHeight.current = height
prevItemCount.current = renderItems.length
return
}
// Subsequent: auto-scroll only if user is at the bottom
if (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 &&
renderItems.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,
// only animate when new items were appended - pure layout growth
// (e.g. the composer spacer getting its height on web) should
// snap instantly rather than visibly scrolling
animated:
hasScrolled &&
height > prevContentHeight.current &&
renderItems.length > prevItemCount.current,
})
}
}
prevContentHeight.current = height
prevItemCount.current = renderItems.length
didBackground.current = false
},
[
hasScrolled,
convoState.isFetchingHistory,
renderItems.length,
// these are stable
flatListRef,
isAtTop,
isAtBottom,
layoutHeight,
],
)
const onStartReached = useCallback(() => {
void convoState.fetchMessageHistory()
}, [convoState])
const onScroll = useCallback(
(e: ScrollEvent) => {
'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 {bottom: bottomInset} = useSafeAreaInsets()
// -- Message sending
const onSendMessage = useCallback(
async (
text: string,
embedState?: MessageEmbedState,
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
// 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<AppBskyEmbedRecord.Main>
| $Typed<ChatBskyEmbedJoinLink.Main>
| undefined
let embedView:
| $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).
const stripLinkFacet = (predicate: (uri: string) => boolean) => {
const linkFacet = rt.facets?.find(facet =>
facet.features.find(
feature =>
AppBskyRichtextFacet.isLink(feature) && predicate(feature.uri),
),
)
if (linkFacet) {
const isAtStart = linkFacet.index.byteStart === 0
const isAtEnd =
linkFacet.index.byteEnd === rt.unicodeText.graphemeLength
if (isAtStart || isAtEnd) {
rt.delete(linkFacet.index.byteStart, linkFacet.index.byteEnd)
}
rt = new RichText({text: rt.text.trim()}, {cleanNewlines: true})
}
}
if (embedState?.type === 'post') {
try {
const post = await getPost({uri: embedState.uri})
if (post) {
embed = {
$type: 'app.bsky.embed.record',
record: {
uri: post.uri,
cid: post.cid,
},
}
embedView = {
$type: 'app.bsky.embed.record#view',
record: createEmbedViewRecordFromPost(post),
}
stripLinkFacet(uri => {
if (!isBskyPostUrl(uri)) return false
const url = convertBskyAppUrlIfNeeded(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)
})
}
} catch (error) {
logger.error('Failed to get post as quote for DM', {error})
}
} else if (embedState?.type === 'invite') {
const code = embedState.code
embed = {
$type: 'chat.bsky.embed.joinLink',
code,
}
const joinLinkPreview = await getJoinLinkPreview({code, hasSession})
if (joinLinkPreview) {
embedView = {
$type: 'chat.bsky.embed.joinLink#view',
joinLinkPreview,
}
}
stripLinkFacet(uri => getChatInviteCodeFromUrl(uri) === code)
}
if (reply) {
replyTo = {messageId: reply.id}
}
await rt.detectFacets(agent)
rt = shortenLinks(rt)
rt = stripInvalidMentions(rt)
if (!hasScrolled) {
setHasScrolled(true)
}
convoState.sendMessage(
{
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,
isOwner: convoState.convo.primaryMember?.did === currentAccount?.did,
})
}
if (
embedView?.$type === 'chat.bsky.embed.joinLink#view' &&
ChatBskyGroupDefs.isJoinLinkPreviewView(embedView.joinLinkPreview)
) {
ax.metric('groupchat:inviteLink:shared', {
convoId: embedView.joinLinkPreview.convoId,
method: 'dm',
})
}
},
[
agent,
convoState,
getPost,
getJoinLinkPreview,
hasSession,
hasScrolled,
setHasScrolled,
ax,
currentAccount?.did,
],
)
const scrollToEndOnPress = useCallback(() => {
flatListRef.current?.scrollToOffset({
offset: prevContentHeight.current,
animated: true,
})
}, [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 (
<MessageItem
item={item}
isGroupChat={convoState.convo.kind === 'group'}
prevMessage={getNeighborMessage(renderItems, index - 1)}
nextMessage={getNeighborMessage(renderItems, index + 1)}
relatedProfiles={convoState.relatedProfiles}
/>
)
} else if (item.type === 'deleted-message') {
return <Text>Deleted message</Text>
} else if (item.type === 'system-message') {
return (
<SystemMessageItem
item={item}
relatedProfiles={convoState.relatedProfiles}
/>
)
} else if (item.type === 'system-message-group') {
return (
<SystemMessageGroup
item={item}
expanded={expandedGroups.has(item.key)}
onToggle={onToggleGroup}
relatedProfiles={convoState.relatedProfiles}
/>
)
} else if (item.type === 'system-message-date-divider') {
return <DateDivider date={item.sentAt} />
} else if (item.type === 'error') {
return <MessageListError item={item} />
}
return null
}
const renderScrollComponent = useCallback(
(props: ScrollViewProps) => (
<ChatScrollComponent {...props} inputHeight={inputHeightUI} />
),
[inputHeightUI],
)
const animatedListStyle = useAnimatedStyle(() => ({
opacity: listOpacity.get(),
}))
return (
<InviteLinkDialogProvider convo={convoState.convo}>
<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}
messageEmbed={messageEmbed}
setEmbed={setEmbed}
loading={loading}
/>
)}
</ConversationFooter>
</Animated.View>
)}
</KeyboardStickyView>
</KeyboardGestureArea>
{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,
}: {
textInputId: string
onSendMessage: (
message: string,
embed?: MessageEmbedState,
replyTo?: $Typed<ChatBskyConvoDefs.MessageView>,
) => Promise<void>
messageEmbed: MessageEmbedState | undefined
setEmbed: (embedUrl: string | undefined) => void
loading?: boolean
}) {
const handleSendMessage = useNonReactiveCallback(
(
message: string,
embed?: MessageEmbedState,
replyTo?: $Typed<ChatBskyConvoDefs.MessageView>,
) => {
void onSendMessage(message, embed, replyTo)
},
)
return (
<MessageComposer
textInputId={textInputId}
onSendMessage={handleSendMessage}
messageEmbed={messageEmbed}
setEmbed={setEmbed}
loading={loading}>
<MessageInputReply />
<MessageInputEmbed embed={messageEmbed} setEmbed={setEmbed} />
</MessageComposer>
)
}
/** Note: native only */
function ChatScrollComponent({
ref,
inputHeight,
...props
}: ScrollViewProps & {
ref?: React.RefObject<KeyboardChatScrollViewProps>
inputHeight: SharedValue<number>
}) {
const scrollEdgeRef = useScrollEdgeEffectRef()
const {bottom: bottomInset} = useSafeAreaInsets()
const offset = platform({
ios: bottomInset - tokens.space.lg,
android: bottomInset,
default: 0,
})
const inputOffset = platform({
ios: bottomInset - tokens.space.lg,
android: bottomInset,
default: 0,
})
const extraContentPadding = useDerivedValue(
() => inputHeight.get() + inputOffset,
)
return (
<KeyboardChatScrollView
ref={mergeRefs([scrollEdgeRef, ref])}
automaticallyAdjustContentInsets={false}
keyboardDismissMode="interactive"
keyboardLiftBehavior="always"
extraContentPadding={extraContentPadding}
offset={offset}
{...props}
/>
)
}
type FooterState = 'loading' | 'new-chat' | 'request' | 'standard'
function getFooterState(
convoState: ActiveConvoStates,
hasAcceptOverride?: boolean,
): FooterState {
const isRequest =
convoState.convo.view.status === 'request' && !hasAcceptOverride
// For group chats, the request footer is driven purely off status: the owner
// is always 'accepted' so never sees it, while members the owner added are
// 'request' until they accept. This holds even before any messages load.
if (convoState.convo.kind === 'group' && isRequest) {
return 'request'
}
if (convoState.items.length === 0) {
if (convoState.isFetchingHistory) {
return 'loading'
} else {
return 'new-chat'
}
}
// For direct chats, only show the request footer once there's a message. The
// viewer's status stays 'request' until they send their first message, so an
// empty direct request is one the viewer started themselves (show the
// composer), whereas any message present must be an incoming one from the
// other user (show the accept/reject footer).
if (isRequest) {
return 'request'
}
return 'standard'
}
function ConversationFooter({
convoState,
hasAcceptOverride,
children,
}: {
convoState: ConvoState
hasAcceptOverride?: boolean
children?: ((props: {loading?: boolean}) => React.ReactNode) | React.ReactNode
}) {
if (!isConvoActive(convoState)) {
return null
}
const footerState = getFooterState(convoState, hasAcceptOverride)
const renderChildren = (loading?: boolean) =>
typeof children === 'function' ? children({loading}) : children
switch (footerState) {
case 'loading':
return renderChildren(true)
case 'new-chat':
// new chat pill goes here - removed for now
return renderChildren()
case 'request':
return <ChatStatusInfo convoState={convoState} />
case 'standard':
return renderChildren()
}
}