import { HeaderTitle, HeaderTitleProps, PlatformPressable } from '@react-navigation/elements' import { CommonActions, RouteProp, StaticScreenProps, useNavigation, useTheme as useNavigationTheme, useRoute, } from '@react-navigation/native' import React, { useCallback, useEffect, useRef, useState } from 'react' import { FlatList, Platform, StyleSheet, useWindowDimensions, View } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { Badge, Icon } from '../components' import { ConversationStartBanner } from '../components/conversation/conversation_start_banner' import { JumpToBottomButton } from '../components/conversation/jump_to_bottom_button' import { MessageForm } from '../components/conversation/message_form' import { MessageList } from '../components/conversation/message_list' import { ConversationDisabledBanner, LeaderMessagesDisabledBanner, MemberMessagesDisabledBanner, } from '../components/conversation/messages_disabled_banners' import { TypingIndicator } from '../components/conversation/typing_indicator' import { KeyboardView } from '../components/display/keyboard_view' import { useHeaderSlotReserve } from '../components/display/platform_modal_header_buttons' import BlankState from '../components/primitive/blank_state_primitive' import { ConversationContextProvider } from '../contexts/conversation_context' import { useMessageAvailableMembersAction } from '../hooks/direct_messages/use_message_available_members_action' import { useConversation } from '../hooks/use_conversation' import { useConversationJoltEvents } from '../hooks/use_conversation_jolt_events' import { dropMessagesWindowIfBehind, useConversationMessages, } from '../hooks/use_conversation_messages' import { useConversationMessagesJoltEvents } from '../hooks/use_conversation_messages_jolt_events' import { useMarkLatestMessageRead } from '../hooks/use_mark_latest_message_read' import { analyticsEvents, normalizeAnalyticsMetadata, usePublishProductAnalyticsEvent, } from '../hooks/use_product_analytics' import { useRestAwayFromDismissEdge } from '../hooks/use_rest_away_from_dismiss_edge' import { useUnarchiveConversationAction } from '../hooks/use_unarchive_conversation_action' import { useVideoModerationJoltEvents } from '../hooks/use_video_moderation_jolt_events' import { ConversationResource } from '../types/resources/conversation' import { ConversationBadgeResource } from '../types/resources/conversation_badge' import { MessageResource } from '../types/resources/message' import { Log } from '../utils' export type ConversationRouteProps = { conversation_id: number reply_root_id?: string | null initial_message_id?: string | null reply_root_author_name?: string chat_group_graph_id?: string clear_input?: boolean editing_message_id?: number | null title?: string subtitle?: string badge?: ConversationBadgeResource deleted?: boolean muted?: boolean } export type ConversationScreenProps = StaticScreenProps export function ConversationScreen({ route }: ConversationScreenProps) { const { conversation_id, reply_root_id } = route.params const { data: conversation } = useConversation({ conversation_id }) usePublishProductAnalyticsEvent(analyticsEvents.conversation_show_opened, { reply_root_id, ...normalizeAnalyticsMetadata(conversation), }) return ( ) } function ConversationScreenContent({ route }: ConversationScreenProps) { const styles = useStyles() const navigation = useNavigation() const { conversation_id, editing_message_id, reply_root_id, reply_root_author_name, initial_message_id, } = route.params const { data: conversation } = useConversation(route.params) const { messages, behind, fetchNextPage, fetchNewer, fetchLiveWindow, hasNextPage } = useConversationMessages({ conversation_id, reply_root_id, anchor: initial_message_id, }) useConversationJoltEvents({ conversationId: conversation_id }) useConversationMessagesJoltEvents({ conversationId: conversation_id }) const { markHandledByMessageForm } = useVideoModerationJoltEvents() useEnsureConversationsRouteExists() useMarkLatestMessageRead({ conversation, messages, enabled: !behind }) const noMessages = messages.length === 0 const { repliesDisabled, memberAbility, badges, title } = conversation const canReply = memberAbility?.canReply const showLeaderDisabledReplyBanner = canReply && repliesDisabled const currentlyEditingMessage = messages.find(m => String(m.id) === String(editing_message_id)) const replyRootAuthorFirstName = reply_root_author_name?.split(' ')[0] const replyHeaderTitle = replyRootAuthorFirstName ? `Reply to ${replyRootAuthorFirstName}` : 'Reply' // Prefer the membership for optimistic updates. const muted = conversation.conversationMembership?.muted ?? conversation.muted const listRef = useRef(null) const [showJumpToBottomButton, setShowJumpToBottomButton] = useState(false) const trackScroll = (event: any) => { const offsetY = event.nativeEvent.contentOffset.y setShowJumpToBottomButton(offsetY > 200) } const { restOffset, onLayout, onContentSizeChange, onMomentumScrollEnd, onScrollEndDrag } = useRestAwayFromDismissEdge({ listRef, enabled: Platform.OS === 'ios', }) const [anchorMessageId, setAnchorMessageId] = useState(initial_message_id) const releaseAnchor = useCallback(() => setAnchorMessageId(null), []) const handleReturnToBottom = useCallback( (animated = true) => { releaseAnchor() listRef.current?.scrollToOffset({ offset: restOffset, animated }) }, [releaseAnchor, restOffset] ) const logWindowFetchFailure = (error: unknown) => Log.error('conversation window fetch failed', error) const returnToLatest = async () => { if (behind) await fetchLiveWindow() handleReturnToBottom(false) } useEffect(() => { if (reply_root_id) { navigation.setParams({ title: replyHeaderTitle, }) } else { navigation.setParams({ title: title ?? undefined, badge: badges?.[0], deleted: conversation?.deleted, muted, }) } }, [navigation, title, badges, conversation?.deleted, reply_root_id, replyHeaderTitle, muted]) useEffect(() => { if (!initial_message_id) return navigation.setParams({ initial_message_id: null }) }, [initial_message_id, navigation]) // A window left behind the live edge would make the next open from the list start // mid-history with no pill, so we only keep windows that are live. useEffect(() => { return () => dropMessagesWindowIfBehind({ conversation_id, reply_root_id }) }, [conversation_id, reply_root_id]) if (!conversation || conversation.deleted) { return ( This conversation has been deleted ) } return ( {noMessages ? ( ) : ( fetchNextPage()} onStartReached={() => fetchNewer().catch(logWindowFetchFailure)} onScroll={trackScroll} onLayout={onLayout} onContentSizeChange={onContentSizeChange} onScrollEndDrag={onScrollEndDrag} onMomentumScrollEnd={onMomentumScrollEnd} /> )} behind ? returnToLatest().catch(logWindowFetchFailure) : handleReturnToBottom() } visible={showJumpToBottomButton || behind} /> {!noMessages && } {showLeaderDisabledReplyBanner && } ) } interface ConversationBottomBarProps { conversation: ConversationResource canReply: boolean | undefined replyRootAuthorFirstName: string | undefined replyRootId: string | null | undefined currentlyEditingMessage: MessageResource | undefined onFlaggedInMessageForm?: (id: string) => void onBeforeSubmit?: () => void } function ConversationBottomBar({ conversation, canReply, replyRootAuthorFirstName, replyRootId, currentlyEditingMessage, onFlaggedInMessageForm, onBeforeSubmit, }: ConversationBottomBarProps) { const messageLink = useMessageAvailableMembersAction(conversation) const unarchiveAction = useUnarchiveConversationAction(conversation) if (conversation.disabled) return ( ) if (!canReply) return return ( ) } interface ConversationScreenTitleProps extends HeaderTitleProps { conversation_id: number badge?: ConversationBadgeResource deleted?: boolean muted?: boolean } export const ConversationScreenTitle = ({ conversation_id, badge, children, style, deleted, muted, }: ConversationScreenTitleProps) => { const styles = usePressableHeaderStyle() const navigation = useNavigation() const resourceType = badge?.pcoResourceType || '' const productName = badge?.appName const name = badge?.text || undefined return ( { if (deleted) return navigation.navigate('ConversationDetails', { conversation_id }) }} > {children} {muted && } {!deleted && } ) } const usePressableHeaderStyle = () => { const { width } = useWindowDimensions() const slotReserve = useHeaderSlotReserve() const headerContainerStyle = { maxWidth: width - slotReserve * 2 } return StyleSheet.create({ container: { alignItems: Platform.select({ android: 'flex-start', default: 'center' }), flex: 1, ...headerContainerStyle, }, titleWrapper: { alignItems: 'center', columnGap: 4, flexDirection: 'row', flexShrink: 1, }, titleTextContainer: { flexShrink: 1, minWidth: 0, }, badge: { alignSelf: Platform.select({ android: 'flex-start', default: 'center' }), marginTop: 2, }, }) } const useStyles = () => { const navigationTheme = useNavigationTheme() const { bottom } = useSafeAreaInsets() return StyleSheet.create({ container: { flex: 1, justifyContent: 'center', backgroundColor: navigationTheme.colors.card, paddingBottom: bottom, }, emptyState: { flex: 1, justifyContent: 'center', }, }) } /** * useEnsureConversationsRouteExists */ const useEnsureConversationsRouteExists = () => { const navigation = useNavigation() const { params } = useRoute>() useEffect(() => { const navigationState = navigation.getState() const routes = navigationState?.routes || [] const conversationsRoute = routes.find(r => r.name === 'Conversations') if (conversationsRoute) return navigation.dispatch(state => { return CommonActions.reset({ ...state, routes: [ { name: 'Conversations', params: { chat_group_graph_id: params?.chat_group_graph_id } }, ...routes, ], index: state.index + 1, }) }) }, [navigation, params?.chat_group_graph_id]) }