bsky-app/src/screens/Messages/Conversation/index.tsx
Hailey b15b49a48f
[🐴] Remove keyboard controller lib (#4038)
* remove library

* implement using just reanimated

* always return false for `keyboardIsOpening` on web

* undo comment

* handle input focus scroll more elegantly

* add back minimal shell toggle on mobile web

* adjust initialnumtorender

* oops

* nit
2024-05-16 09:32:10 -07:00

287 lines
8.2 KiB
TypeScript

import React, {useCallback} from 'react'
import {TouchableOpacity, View} from 'react-native'
import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useFocusEffect, useNavigation} from '@react-navigation/native'
import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types'
import {useGate} from '#/lib/statsig/statsig'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useCurrentConvoId} from '#/state/messages/current-convo-id'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useProfileQuery} from '#/state/queries/profile'
import {BACK_HITSLOP} from 'lib/constants'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {isWeb} from 'platform/detection'
import {ConvoProvider, isConvoActive, useConvo} from 'state/messages/convo'
import {ConvoStatus} from 'state/messages/convo/types'
import {useSetMinimalShellMode} from 'state/shell'
import {PreviewableUserAvatar} from 'view/com/util/UserAvatar'
import {CenteredView} from 'view/com/util/Views'
import {MessagesList} from '#/screens/Messages/Conversation/MessagesList'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {ConvoMenu} from '#/components/dms/ConvoMenu'
import {Error} from '#/components/Error'
import {ListMaybePlaceholder} from '#/components/Lists'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {ClipClopGate} from '../gate'
type Props = NativeStackScreenProps<
CommonNavigatorParams,
'MessagesConversation'
>
export function MessagesConversationScreen({route}: Props) {
const gate = useGate()
const {gtMobile} = useBreakpoints()
const setMinimalShellMode = useSetMinimalShellMode()
const convoId = route.params.conversation
const {setCurrentConvoId} = useCurrentConvoId()
useFocusEffect(
useCallback(() => {
setCurrentConvoId(convoId)
if (isWeb && !gtMobile) {
setMinimalShellMode(true)
}
return () => {
setCurrentConvoId(undefined)
setMinimalShellMode(false)
}
}, [gtMobile, convoId, setCurrentConvoId, setMinimalShellMode]),
)
if (!gate('dms')) return <ClipClopGate />
return (
<ConvoProvider convoId={convoId}>
<Inner />
</ConvoProvider>
)
}
function Inner() {
const t = useTheme()
const convoState = useConvo()
const {_} = useLingui()
const [hasInitiallyRendered, setHasInitiallyRendered] = React.useState(false)
// HACK: Because we need to scroll to the bottom of the list once initial items are added to the list, we also have
// to take into account that scrolling to the end of the list on native will happen asynchronously. This will cause
// a little flicker when the items are first renedered at the top and immediately scrolled to the bottom. to prevent
// this, we will wait until the first render has completed to remove the loading overlay.
React.useEffect(() => {
if (
!hasInitiallyRendered &&
isConvoActive(convoState) &&
!convoState.isFetchingHistory
) {
setTimeout(() => {
setHasInitiallyRendered(true)
}, 15)
}
}, [convoState, hasInitiallyRendered])
if (convoState.status === ConvoStatus.Error) {
return (
<CenteredView style={a.flex_1} sideBorders>
<Header />
<Error
title={_(msg`Something went wrong`)}
message={_(msg`We couldn't load this conversation`)}
onRetry={() => convoState.error.retry()}
/>
</CenteredView>
)
}
/*
* Any other convo states (atm) are "ready" states
*/
return (
<CenteredView style={[a.flex_1]} sideBorders>
<Header profile={convoState.recipients?.[0]} />
<View style={[a.flex_1]}>
{isConvoActive(convoState) ? (
<MessagesList />
) : (
<ListMaybePlaceholder isLoading />
)}
{!hasInitiallyRendered && (
<View
style={[
a.absolute,
a.z_10,
a.w_full,
a.h_full,
a.justify_center,
a.align_center,
t.atoms.bg,
]}>
<View style={[{marginBottom: 75}]}>
<Loader size="xl" />
</View>
</View>
)}
</View>
</CenteredView>
)
}
let Header = ({
profile: initialProfile,
}: {
profile?: AppBskyActorDefs.ProfileViewBasic
}): React.ReactNode => {
const t = useTheme()
const {_} = useLingui()
const {gtTablet} = useBreakpoints()
const navigation = useNavigation<NavigationProp>()
const moderationOpts = useModerationOpts()
const {data: profile} = useProfileQuery({did: initialProfile?.did})
const onPressBack = useCallback(() => {
if (isWeb) {
navigation.replace('Messages')
} else {
navigation.goBack()
}
}, [navigation])
return (
<View
style={[
t.atoms.bg,
t.atoms.border_contrast_low,
a.border_b,
a.flex_row,
a.justify_between,
a.align_start,
a.gap_lg,
a.pl_xl,
a.pr_lg,
a.py_md,
]}>
{!gtTablet ? (
<TouchableOpacity
testID="conversationHeaderBackBtn"
onPress={onPressBack}
hitSlop={BACK_HITSLOP}
style={{width: 30, height: 30}}
accessibilityRole="button"
accessibilityLabel={_(msg`Back`)}
accessibilityHint="">
<FontAwesomeIcon
size={18}
icon="angle-left"
style={{
marginTop: 6,
}}
color={t.atoms.text.color}
/>
</TouchableOpacity>
) : (
<View style={{width: 30}} />
)}
{profile && moderationOpts ? (
<HeaderReady profile={profile} moderationOpts={moderationOpts} />
) : (
<>
<View style={[a.align_center, a.gap_sm, a.flex_1]}>
<View
style={[
{width: 32, height: 32},
a.rounded_full,
t.atoms.bg_contrast_25,
]}
/>
<View
style={[
{width: 120, height: 16},
a.rounded_xs,
t.atoms.bg_contrast_25,
a.mt_xs,
]}
/>
<View
style={[
{width: 175, height: 12},
a.rounded_xs,
t.atoms.bg_contrast_25,
]}
/>
</View>
<View style={{width: 30}} />
</>
)}
</View>
)
}
Header = React.memo(Header)
function HeaderReady({
profile: profileUnshadowed,
moderationOpts,
}: {
profile: AppBskyActorDefs.ProfileViewBasic
moderationOpts: ModerationOpts
}) {
const t = useTheme()
const convoState = useConvo()
const profile = useProfileShadow(profileUnshadowed)
const moderation = React.useMemo(
() => moderateProfile(profile, moderationOpts),
[profile, moderationOpts],
)
const isDeletedAccount = profile?.handle === 'missing.invalid'
const displayName = isDeletedAccount
? 'Deleted Account'
: sanitizeDisplayName(
profile.displayName || profile.handle,
moderation.ui('displayName'),
)
return (
<>
<View style={[a.align_center, a.gap_sm, a.flex_1]}>
<View style={[a.align_center]}>
<PreviewableUserAvatar
size={32}
profile={profile}
moderation={moderation.ui('avatar')}
disableHoverCard={moderation.blocked}
/>
<Text
style={[a.text_lg, a.font_bold, a.pt_sm, a.pb_2xs]}
numberOfLines={1}>
{displayName}
</Text>
{!isDeletedAccount && (
<Text style={[t.atoms.text_contrast_medium]} numberOfLines={1}>
@{profile.handle}
</Text>
)}
</View>
</View>
{isConvoActive(convoState) && (
<ConvoMenu
convo={convoState.convo}
profile={profile}
currentScreen="conversation"
moderation={moderation}
/>
)}
</>
)
}