Files
bsky-social-app/src/screens/Messages/Conversation.tsx
T
2026-06-14 16:56:52 +03:00

302 lines
9.4 KiB
TypeScript

import {useCallback, useEffect, useMemo, useState} from 'react'
import {type LayoutChangeEvent, View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {ChatBskyConvoDefs, moderateProfile} from '@atproto/api'
import {
ScrollEdgeEffect,
ScrollEdgeEffectProvider,
} from '@bsky.app/expo-scroll-edge-effect'
import {Trans, useLingui} from '@lingui/react/macro'
import {
type RouteProp,
useFocusEffect,
useIsFocused,
useNavigation,
useRoute,
} from '@react-navigation/native'
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {useViewportZoomLock} from '#/lib/hooks/useViewportZoomLock'
import {
type CommonNavigatorParams,
type NavigationProp,
} from '#/lib/routes/types'
import {useMaybeProfileShadow} from '#/state/cache/profile-shadow'
import {useEmail} from '#/state/email-verification'
import {ConvoProvider, isConvoActive, useConvo} from '#/state/messages/convo'
import {ConvoStatus} from '#/state/messages/convo/types'
import {useCurrentConvoId} from '#/state/messages/current-convo-id'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useConvoQuery} from '#/state/queries/messages/conversation'
import {useMarkJoinRequestsRead} from '#/state/queries/messages/mark-join-request-read'
import {useSession} from '#/state/session'
import {MessagesList} from '#/screens/Messages/components/MessagesList'
import {atoms as a, web} from '#/alf'
import {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen'
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
import {
EmailDialogScreenID,
useEmailDialogControl,
} from '#/components/dialogs/EmailDialog'
import {MessagesListBlockedFooter} from '#/components/dms/MessagesListBlockedFooter'
import {MessagesListHeader} from '#/components/dms/MessagesListHeader'
import {type ConvoWithDetails, parseConvoView} from '#/components/dms/util'
import {Error} from '#/components/Error'
import * as Layout from '#/components/Layout'
import {IS_LIQUID_GLASS} from '#/env'
import {ChatDisabled} from './components/ChatDisabled'
import {ChatEnded} from './components/ChatEnded'
import {ChatLocked} from './components/ChatLocked'
import {RequestStatus} from './components/RequestStatus'
type Props = NativeStackScreenProps<
CommonNavigatorParams,
'MessagesConversation'
>
export function MessagesConversationScreen(props: Props) {
const {t: l} = useLingui()
const aaCopy = useAgeAssuranceCopy()
return (
<AgeRestrictedScreen
screenTitle={l`Conversation`}
infoText={aaCopy.chatsInfoText}>
<MessagesConversationScreenInner {...props} />
</AgeRestrictedScreen>
)
}
export function MessagesConversationScreenInner({route}: Props) {
const convoId = route.params.conversation
const {setCurrentConvoId} = useCurrentConvoId()
useFocusEffect(
useCallback(() => {
setCurrentConvoId(convoId)
return () => {
setCurrentConvoId(undefined)
}
}, [convoId, setCurrentConvoId]),
)
return (
<Layout.Screen
minimalShell
testID="convoScreen"
noInsetTop={IS_LIQUID_GLASS}
style={web([{minHeight: 0}, a.flex_1])}>
<ScrollEdgeEffectProvider>
<ConvoProvider key={convoId} convoId={convoId}>
<Inner convoId={convoId} />
</ConvoProvider>
</ScrollEdgeEffectProvider>
</Layout.Screen>
)
}
function Inner({convoId}: {convoId: string}) {
const convoState = useConvo()
const {t: l} = useLingui()
const {currentAccount} = useSession()
const isFocused = useIsFocused()
const {top: topInset} = useSafeAreaInsets()
const {data: convoData} = useConvoQuery({convoId})
useViewportZoomLock({enabled: isFocused})
const convo = convoData
? parseConvoView(convoData, currentAccount?.did)
: null
const [hasScrolled, setHasScrolled] = useState(false)
// Any time that we re-render the `Initializing` state, we have to reset `hasScrolled` to false. After entering this
// state, we know that we're resetting the list of messages and need to re-scroll to the bottom when they get added.
const [prevState, setPrevState] = useState(convoState.status)
if (prevState !== convoState.status) {
setPrevState(convoState.status)
if (convoState.status === ConvoStatus.Initializing) {
setHasScrolled(false)
}
}
if (convoState.status === ConvoStatus.Error) {
return (
<>
<Layout.Center
style={[a.w_full, IS_LIQUID_GLASS && {paddingTop: topInset}]}>
<MessagesListHeader convo={convo} />
</Layout.Center>
<Error
title={l`Something went wrong`}
message={l`We couldn't load this conversation`}
onRetry={() => convoState.error.retry()}
/>
</>
)
}
return (
<Layout.Center style={[a.flex_1]}>
<View style={[a.flex_1]}>
<InnerReady
convo={convo}
hasScrolled={hasScrolled}
setHasScrolled={setHasScrolled}
isActive={isConvoActive(convoState)}
isDisabled={convoState.status === ConvoStatus.Disabled}
/>
</View>
</Layout.Center>
)
}
function InnerReady({
hasScrolled,
setHasScrolled,
convo,
isActive,
isDisabled,
}: {
hasScrolled: boolean
setHasScrolled: React.Dispatch<React.SetStateAction<boolean>>
convo: ConvoWithDetails | null
isActive: boolean
isDisabled: boolean
}) {
const navigation = useNavigation<NavigationProp>()
const {top: topInset} = useSafeAreaInsets()
const [headerHeight, setHeaderHeight] = useState(0)
const onHeaderLayout = (e: LayoutChangeEvent) => {
setHeaderHeight(e.nativeEvent.layout.height)
}
const {params} =
useRoute<RouteProp<CommonNavigatorParams, 'MessagesConversation'>>()
const {needsEmailVerification} = useEmail()
const emailDialogControl = useEmailDialogControl()
const unreadRequestCount =
convo?.kind === 'group' && ChatBskyConvoDefs.isGroupConvo(convo.view.kind)
? (convo.view.kind.unreadJoinRequestCount ?? 0)
: 0
const {mutate: markJoinRequestsRead} = useMarkJoinRequestsRead(convo?.view.id)
/**
* Must be non-reactive, otherwise the update to open the global dialog will
* cause a re-render loop.
*/
const maybeBlockForEmailVerification = useNonReactiveCallback(() => {
if (needsEmailVerification) {
/*
* HACKFIX
*
* Load bearing timeout, to bump this state update until the after the
* `navigator.addListener('state')` handler closes elements from
* `shell/index.*.tsx` - sfn & esb
*/
setTimeout(() =>
emailDialogControl.open({
id: EmailDialogScreenID.Verify,
instructions: [
<Trans key="pre-compose">
Before you can message another user, you must first verify your
email.
</Trans>,
],
onCloseWithoutVerifying: () => {
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.navigate('Messages', {animation: 'pop'})
}
},
}),
)
}
})
useEffect(() => {
maybeBlockForEmailVerification()
}, [maybeBlockForEmailVerification])
const primaryMember = useMaybeProfileShadow(convo?.primaryMember)
const moderationOpts = useModerationOpts()
const primaryMemberModeration = useMemo(() => {
if (!primaryMember || !moderationOpts) return null
return moderateProfile(primaryMember, moderationOpts)
}, [primaryMember, moderationOpts])
const header = <MessagesListHeader convo={convo} />
let footer: React.ReactNode = null
if (isDisabled) {
footer = <ChatDisabled />
} else if (
convo &&
primaryMember &&
primaryMemberModeration &&
(convo.kind === 'group'
? primaryMemberModeration?.blockCause?.type === 'blocking'
: primaryMemberModeration?.blocked)
) {
footer = (
<MessagesListBlockedFooter
recipient={primaryMember}
convoId={convo.view.id}
isGroup={convo.kind === 'group'}
moderation={primaryMemberModeration}
/>
)
} else if (convo?.kind === 'group') {
if (convo.details.lockStatus === 'locked') {
footer = <ChatLocked convo={convo} />
} else if (convo.details.lockStatus === 'locked-permanently') {
footer = <ChatEnded convo={convo} />
}
}
return (
<>
{IS_LIQUID_GLASS ? (
<ScrollEdgeEffect
edge="top"
effect="soft"
style={[a.absolute, a.w_full, a.z_10, {paddingTop: topInset}]}
onLayout={onHeaderLayout}>
{header}
</ScrollEdgeEffect>
) : (
<View onLayout={onHeaderLayout}>{header}</View>
)}
{isActive && convo?.kind === 'group' && unreadRequestCount > 0 ? (
<RequestStatus
top={headerHeight}
count={unreadRequestCount}
onDismiss={() => {
markJoinRequestsRead()
}}
onPress={() => {
markJoinRequestsRead()
navigation.navigate('MessagesJoinRequests', {
conversation: convo.view.id,
})
}}
/>
) : null}
{isActive && (
<MessagesList
hasScrolled={hasScrolled}
setHasScrolled={setHasScrolled}
hasAcceptOverride={!!params.accept}
transparentHeaderHeight={IS_LIQUID_GLASS ? headerHeight : 0}
footer={footer}
/>
)}
</>
)
}