[🐴] Block Info (#4068)

* get the damn thing in there 😮‍💨

* more cleanup and little fixes

another nit

nit

small annoyance

add a comment

only use `scrollTo` when necessary

remove now unnecessary styles

* move padding out

* add unblock function

* rm need for moderationpts

* ?

* ??

* extract leaveconvoprompt

* move `setHasScrolled` to `onContentSizeChanged`

* account for block footer

* wrap up

nit

make sure recipient is loaded before showing

refactor to hide chat input

typo squigglie

add report dialog

finalize delete

implement custom animation

add configurable replace animation

add leave convo to block options

* correct functionality for report

* moev component to another file

* maybe...

* fix chat item

* improve

* remove unused gtmobile

* nit

* more cleanup

* more cleanup

* fix merge

* fix header

* few more changes

* nit

* remove old
zio/stable
Hailey 2024-05-17 14:21:15 -07:00 committed by GitHub
parent 1b47ea7367
commit d02e0884c4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 599 additions and 280 deletions

View File

@ -464,7 +464,10 @@ function MessagesTabNavigator() {
<MessagesTab.Screen
name="Messages"
getComponent={() => MessagesScreen}
options={{requireAuth: true}}
options={({route}) => ({
requireAuth: true,
animationTypeForReplace: route.params?.animation ?? 'push',
})}
/>
{commonScreens(MessagesTab as typeof HomeTab)}
</MessagesTab.Navigator>

View File

@ -172,6 +172,7 @@ export function Basic({
confirmButtonCta,
onConfirm,
confirmButtonColor,
showCancel = true,
}: React.PropsWithChildren<{
control: Dialog.DialogOuterProps['control']
title: string
@ -187,6 +188,7 @@ export function Basic({
*/
onConfirm: () => void
confirmButtonColor?: ButtonColor
showCancel?: boolean
}>) {
return (
<Outer control={control} testID="confirmModal">
@ -199,7 +201,7 @@ export function Basic({
color={confirmButtonColor}
testID="confirmBtn"
/>
<Cancel cta={cancelButtonCta} />
{showCancel && <Cancel cta={cancelButtonCta} />}
</Actions>
</Outer>
)

View File

@ -0,0 +1,62 @@
import React from 'react'
import {View} from 'react-native'
import {ModerationCause} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {listUriToHref} from 'lib/strings/url-helpers'
import {atoms as a, useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {DialogControlProps} from '#/components/Dialog'
import {InlineLinkText} from '#/components/Link'
import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography'
export function BlockedByListDialog({
control,
listBlocks,
}: {
control: DialogControlProps
listBlocks: ModerationCause[]
}) {
const {_} = useLingui()
const t = useTheme()
return (
<Prompt.Outer control={control} testID="blockedByListDialog">
<Prompt.TitleText>{_(msg`User blocked by list`)}</Prompt.TitleText>
<View style={[a.gap_sm, a.pb_lg]}>
<Text
selectable
style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high]}>
{_(
msg`This account is blocked by one or more of your moderation lists. To unblock, please visit the lists directly and remove this user.`,
)}{' '}
</Text>
<Text style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high]}>
{_(msg`Lists blocking this user:`)}{' '}
{listBlocks.map((block, i) =>
block.source.type === 'list' ? (
<React.Fragment key={block.source.list.uri}>
{i === 0 ? null : ', '}
<InlineLinkText
to={listUriToHref(block.source.list.uri)}
style={[a.text_md, a.leading_snug]}>
{block.source.list.name}
</InlineLinkText>
</React.Fragment>
) : null,
)}
</Text>
</View>
<Prompt.Actions>
<Prompt.Action cta={_(msg`I understand`)} onPress={() => {}} />
</Prompt.Actions>
<Dialog.Close />
</Prompt.Outer>
)
}

View File

@ -3,25 +3,25 @@ import {Keyboard, Pressable, View} from 'react-native'
import {
AppBskyActorDefs,
ChatBskyConvoDefs,
ModerationDecision,
ModerationCause,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from '#/lib/routes/types'
import {listUriToHref} from '#/lib/strings/url-helpers'
import {Shadow} from '#/state/cache/types'
import {
useConvoQuery,
useMarkAsReadMutation,
} from '#/state/queries/messages/conversation'
import {useLeaveConvo} from '#/state/queries/messages/leave-conversation'
import {useMuteConvo} from '#/state/queries/messages/mute-conversation'
import {useProfileBlockMutationQueue} from '#/state/queries/profile'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {BlockedByListDialog} from '#/components/dms/BlockedByListDialog'
import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt'
import {ReportConversationPrompt} from '#/components/dms/ReportConversationPrompt'
import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeft} from '#/components/icons/ArrowBoxLeft'
import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag'
@ -30,10 +30,8 @@ import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Perso
import {PersonCheck_Stroke2_Corner0_Rounded as PersonCheck} from '#/components/icons/PersonCheck'
import {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/PersonX'
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
import {InlineLinkText} from '#/components/Link'
import * as Menu from '#/components/Menu'
import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography'
import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '../icons/Bubble'
let ConvoMenu = ({
@ -44,7 +42,7 @@ let ConvoMenu = ({
showMarkAsRead,
hideTrigger,
triggerOpacity,
moderation,
blockInfo,
}: {
convo: ChatBskyConvoDefs.ConvoView
profile: Shadow<AppBskyActorDefs.ProfileViewBasic>
@ -53,7 +51,10 @@ let ConvoMenu = ({
showMarkAsRead?: boolean
hideTrigger?: boolean
triggerOpacity?: number
moderation: ModerationDecision
blockInfo: {
listBlocks: ModerationCause[]
userBlock?: ModerationCause
}
}): React.ReactNode => {
const navigation = useNavigation<NavigationProp>()
const {_} = useLingui()
@ -62,17 +63,9 @@ let ConvoMenu = ({
const reportControl = Prompt.usePromptControl()
const blockedByListControl = Prompt.usePromptControl()
const {mutate: markAsRead} = useMarkAsReadMutation()
const modui = moderation.ui('profileView')
const {listBlocks, userBlock} = React.useMemo(() => {
const blocks = modui.alerts.filter(alert => alert.type === 'blocking')
const listBlocks = blocks.filter(alert => alert.source.type === 'list')
const userBlock = blocks.find(alert => alert.source.type === 'user')
return {
listBlocks,
userBlock,
}
}, [modui])
const isBlocking = !!userBlock || !!listBlocks.length
const {listBlocks, userBlock} = blockInfo
const isBlocking = userBlock || !!listBlocks.length
const {data: convo} = useConvoQuery(initialConvo)
@ -108,17 +101,6 @@ let ConvoMenu = ({
}
}, [userBlock, listBlocks, blockedByListControl, queueBlock, queueUnblock])
const {mutate: leaveConvo} = useLeaveConvo(convo?.id, {
onSuccess: () => {
if (currentScreen === 'conversation') {
navigation.replace('Messages')
}
},
onError: () => {
Toast.show(_(msg`Could not leave chat`))
},
})
return (
<>
<Menu.Root control={control}>
@ -218,67 +200,19 @@ let ConvoMenu = ({
</Menu.Outer>
</Menu.Root>
<Prompt.Basic
<LeaveConvoPrompt
control={leaveConvoControl}
title={_(msg`Leave conversation`)}
description={_(
msg`Are you sure you want to leave this conversation? Your messages will be deleted for you, but not for the other participant.`,
)}
confirmButtonCta={_(msg`Leave`)}
confirmButtonColor="negative"
onConfirm={() => leaveConvo()}
convoId={convo.id}
currentScreen={currentScreen}
/>
<Prompt.Basic
control={reportControl}
title={_(msg`Report conversation`)}
description={_(
msg`To report a conversation, please report one of its messages via the conversation screen. This lets our moderators understand the context of your issue.`,
)}
confirmButtonCta={_(msg`I understand`)}
onConfirm={noop}
<ReportConversationPrompt control={reportControl} />
<BlockedByListDialog
control={blockedByListControl}
listBlocks={listBlocks}
/>
<Prompt.Outer control={blockedByListControl} testID="blockedByListDialog">
<Prompt.TitleText>{_(msg`User blocked by list`)}</Prompt.TitleText>
<View style={[a.gap_sm, a.pb_lg]}>
<Text
selectable
style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high]}>
{_(
msg`This account is blocked by one or more of your moderation lists. To unblock, please visit the lists directly and remove this user.`,
)}{' '}
</Text>
<Text style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high]}>
{_(msg`Lists blocking this user:`)}{' '}
{listBlocks.map((block, i) =>
block.source.type === 'list' ? (
<React.Fragment key={block.source.list.uri}>
{i === 0 ? null : ', '}
<InlineLinkText
to={listUriToHref(block.source.list.uri)}
style={[a.text_md, a.leading_snug]}>
{block.source.list.name}
</InlineLinkText>
</React.Fragment>
) : null,
)}
</Text>
</View>
<Prompt.Actions>
<Prompt.Cancel cta={_(msg`I understand`)} />
</Prompt.Actions>
<Dialog.Close />
</Prompt.Outer>
</>
)
}
ConvoMenu = React.memo(ConvoMenu)
export {ConvoMenu}
function noop() {}

View File

@ -0,0 +1,55 @@
import React from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from 'lib/routes/types'
import {isNative} from 'platform/detection'
import {useLeaveConvo} from 'state/queries/messages/leave-conversation'
import * as Toast from 'view/com/util/Toast'
import {DialogOuterProps} from '#/components/Dialog'
import * as Prompt from '#/components/Prompt'
export function LeaveConvoPrompt({
control,
convoId,
currentScreen,
}: {
control: DialogOuterProps['control']
convoId: string
currentScreen: 'list' | 'conversation'
}) {
const {_} = useLingui()
const navigation = useNavigation<NavigationProp>()
const {mutate: leaveConvo} = useLeaveConvo(convoId, {
onSuccess: () => {
if (currentScreen === 'conversation') {
navigation.replace(
'Messages',
isNative
? {
animation: 'pop',
}
: {},
)
}
},
onError: () => {
Toast.show(_(msg`Could not leave chat`))
},
})
return (
<Prompt.Basic
control={control}
title={_(msg`Leave conversation`)}
description={_(
msg`Are you sure you want to leave this conversation? Your messages will be deleted for you, but not for the other participant.`,
)}
confirmButtonCta={_(msg`Leave`)}
confirmButtonColor="negative"
onConfirm={leaveConvo}
/>
)
}

View File

@ -75,7 +75,7 @@ let MessageItem = ({
}, [message.text, message.facets])
return (
<View>
<View style={[isFromSelf ? a.mr_md : a.ml_md]}>
<ActionsWrapper isFromSelf={isFromSelf} message={message}>
<View
style={[

View File

@ -0,0 +1,131 @@
import React from 'react'
import {View} from 'react-native'
import {AppBskyActorDefs, ModerationCause} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useProfileShadow} from 'state/cache/profile-shadow'
import {useProfileBlockMutationQueue} from 'state/queries/profile'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import {Divider} from '#/components/Divider'
import {BlockedByListDialog} from '#/components/dms/BlockedByListDialog'
import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt'
import {ReportConversationPrompt} from '#/components/dms/ReportConversationPrompt'
import {Text} from '#/components/Typography'
export function MessagesListBlockedFooter({
recipient: initialRecipient,
convoId,
hasMessages,
blockInfo,
}: {
recipient: AppBskyActorDefs.ProfileViewBasic
convoId: string
hasMessages: boolean
blockInfo: {
listBlocks: ModerationCause[]
userBlock: ModerationCause | undefined
}
}) {
const t = useTheme()
const {gtMobile} = useBreakpoints()
const {_} = useLingui()
const recipient = useProfileShadow(initialRecipient)
const [__, queueUnblock] = useProfileBlockMutationQueue(recipient)
const leaveConvoControl = useDialogControl()
const reportControl = useDialogControl()
const blockedByListControl = useDialogControl()
const {listBlocks, userBlock} = blockInfo
const isBlocking = !!userBlock || !!listBlocks.length
const onUnblockPress = React.useCallback(() => {
if (listBlocks.length) {
blockedByListControl.open()
} else {
queueUnblock()
}
}, [blockedByListControl, listBlocks, queueUnblock])
return (
<View style={[hasMessages && a.pt_md, a.pb_xl, a.gap_lg]}>
<Divider />
<Text style={[a.text_md, a.font_bold, a.text_center]}>
{isBlocking ? (
<Trans>You have blocked this user</Trans>
) : (
<Trans>This user has blocked you</Trans>
)}
</Text>
<View style={[a.flex_row, a.justify_between, a.gap_lg, a.px_md]}>
<Button
label={_(msg`Leave chat`)}
color="secondary"
variant="solid"
size="small"
style={[a.flex_1]}
onPress={leaveConvoControl.open}>
<ButtonText style={{color: t.palette.negative_500}}>
<Trans>Leave chat</Trans>
</ButtonText>
</Button>
<Button
label={_(msg`Report`)}
color="secondary"
variant="solid"
size="small"
style={[a.flex_1]}
onPress={reportControl.open}>
<ButtonText style={{color: t.palette.negative_500}}>
<Trans>Report</Trans>
</ButtonText>
</Button>
{isBlocking && gtMobile && (
<Button
label={_(msg`Unblock`)}
color="secondary"
variant="solid"
size="small"
style={[a.flex_1]}
onPress={onUnblockPress}>
<ButtonText style={{color: t.palette.primary_500}}>
<Trans>Unblock</Trans>
</ButtonText>
</Button>
)}
</View>
{isBlocking && !gtMobile && (
<View style={[a.flex_row, a.justify_center, a.px_md]}>
<Button
label={_(msg`Unblock`)}
color="secondary"
variant="solid"
size="small"
style={[a.flex_1]}
onPress={onUnblockPress}>
<ButtonText style={{color: t.palette.primary_500}}>
<Trans>Unblock</Trans>
</ButtonText>
</Button>
</View>
)}
<LeaveConvoPrompt
control={leaveConvoControl}
currentScreen="conversation"
convoId={convoId}
/>
<ReportConversationPrompt control={reportControl} />
<BlockedByListDialog
control={blockedByListControl}
listBlocks={listBlocks}
/>
</View>
)
}

View File

@ -0,0 +1,194 @@
import React, {useCallback} from 'react'
import {TouchableOpacity, View} from 'react-native'
import {
AppBskyActorDefs,
ModerationCause,
ModerationDecision,
} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {BACK_HITSLOP} from 'lib/constants'
import {makeProfileLink} from 'lib/routes/links'
import {NavigationProp} from 'lib/routes/types'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {isWeb} from 'platform/detection'
import {useProfileShadow} from 'state/cache/profile-shadow'
import {isConvoActive, useConvo} from 'state/messages/convo'
import {PreviewableUserAvatar} from 'view/com/util/UserAvatar'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {ConvoMenu} from '#/components/dms/ConvoMenu'
import {Link} from '#/components/Link'
import {Text} from '#/components/Typography'
const PFP_SIZE = isWeb ? 40 : 34
export let MessagesListHeader = ({
profile,
moderation,
blockInfo,
}: {
profile?: AppBskyActorDefs.ProfileViewBasic
moderation?: ModerationDecision
blockInfo?: {
listBlocks: ModerationCause[]
userBlock?: ModerationCause
}
}): React.ReactNode => {
const t = useTheme()
const {_} = useLingui()
const {gtTablet} = useBreakpoints()
const navigation = useNavigation<NavigationProp>()
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.align_center,
a.gap_sm,
gtTablet ? a.pl_lg : a.pl_xl,
a.pr_lg,
a.py_sm,
]}>
{!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>
)}
{profile && moderation && blockInfo ? (
<HeaderReady
profile={profile}
moderation={moderation}
blockInfo={blockInfo}
/>
) : (
<>
<View style={[a.flex_row, a.align_center, a.gap_md, a.flex_1]}>
<View
style={[
{width: PFP_SIZE, height: PFP_SIZE},
a.rounded_full,
t.atoms.bg_contrast_25,
]}
/>
<View style={a.gap_xs}>
<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>
<View style={{width: 30}} />
</>
)}
</View>
)
}
MessagesListHeader = React.memo(MessagesListHeader)
function HeaderReady({
profile: profileUnshadowed,
moderation,
blockInfo,
}: {
profile: AppBskyActorDefs.ProfileViewBasic
moderation: ModerationDecision
blockInfo: {
listBlocks: ModerationCause[]
userBlock?: ModerationCause
}
}) {
const t = useTheme()
const convoState = useConvo()
const profile = useProfileShadow(profileUnshadowed)
const isDeletedAccount = profile?.handle === 'missing.invalid'
const displayName = isDeletedAccount
? 'Deleted Account'
: sanitizeDisplayName(
profile.displayName || profile.handle,
moderation.ui('displayName'),
)
return (
<>
<Link
style={[a.flex_row, a.align_center, a.gap_md, a.flex_1, a.pr_md]}
to={makeProfileLink(profile)}>
<PreviewableUserAvatar
size={PFP_SIZE}
profile={profile}
moderation={moderation.ui('avatar')}
disableHoverCard={moderation.blocked}
/>
<View style={a.flex_1}>
<Text
style={[a.text_md, a.font_bold, web(a.leading_normal)]}
numberOfLines={1}>
{displayName}
</Text>
{!isDeletedAccount && (
<Text
style={[
t.atoms.text_contrast_medium,
a.text_sm,
web([a.leading_normal, {marginTop: -2}]),
]}
numberOfLines={1}>
@{profile.handle}
</Text>
)}
</View>
</Link>
{isConvoActive(convoState) && (
<ConvoMenu
convo={convoState.convo}
profile={profile}
currentScreen="conversation"
blockInfo={blockInfo}
/>
)}
</>
)
}

View File

@ -0,0 +1,27 @@
import React from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {DialogControlProps} from '#/components/Dialog'
import * as Prompt from '#/components/Prompt'
export function ReportConversationPrompt({
control,
}: {
control: DialogControlProps
}) {
const {_} = useLingui()
return (
<Prompt.Basic
control={control}
title={_(msg`Report conversation`)}
description={_(
msg`To report a conversation, please report one of its messages via the conversation screen. This lets our moderators understand the context of your issue.`,
)}
confirmButtonCta={_(msg`I understand`)}
onConfirm={() => {}}
showCancel={false}
/>
)
}

View File

@ -72,7 +72,7 @@ export type MyProfileTabNavigatorParams = CommonNavigatorParams & {
}
export type MessagesTabNavigatorParams = CommonNavigatorParams & {
Messages: {pushToConversation?: string}
Messages: {pushToConversation?: string; animation?: 'push' | 'pop'}
}
export type FlatNavigatorParams = CommonNavigatorParams & {
@ -81,7 +81,7 @@ export type FlatNavigatorParams = CommonNavigatorParams & {
Feeds: undefined
Notifications: undefined
Hashtag: {tag: string; author?: string}
Messages: {pushToConversation?: string}
Messages: {pushToConversation?: string; animation?: 'push' | 'pop'}
}
export type AllNavigatorParams = CommonNavigatorParams & {
@ -96,7 +96,7 @@ export type AllNavigatorParams = CommonNavigatorParams & {
MyProfileTab: undefined
Hashtag: {tag: string; author?: string}
MessagesTab: undefined
Messages: undefined
Messages: {animation?: 'push' | 'pop'}
}
// NOTE

View File

@ -23,7 +23,7 @@ import {isWeb} from 'platform/detection'
import {List} from 'view/com/util/List'
import {MessageInput} from '#/screens/Messages/Conversation/MessageInput'
import {MessageListError} from '#/screens/Messages/Conversation/MessageListError'
import {atoms as a, useBreakpoints} from '#/alf'
import {atoms as a} from '#/alf'
import {MessageItem} from '#/components/dms/MessageItem'
import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
import {Loader} from '#/components/Loader'
@ -66,12 +66,17 @@ function onScrollToIndexFailed() {
export function MessagesList({
hasScrolled,
setHasScrolled,
blocked,
footer,
}: {
hasScrolled: boolean
setHasScrolled: React.Dispatch<React.SetStateAction<boolean>>
blocked?: boolean
footer?: React.ReactNode
}) {
const convo = useConvoActive()
const convoState = useConvoActive()
const {getAgent} = useAgent()
const flatListRef = useAnimatedRef<FlatList>()
const [showNewMessagesPill, setShowNewMessagesPill] = React.useState(false)
@ -81,7 +86,7 @@ export function MessagesList({
// the bottom.
const isAtBottom = useSharedValue(true)
// This will be used on web to assist in determing if we need to maintain the content offset
// 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
@ -126,11 +131,11 @@ export function MessagesList({
if (
hasScrolled &&
height - contentHeight.value > layoutHeight.value - 50 &&
convo.items.length - prevItemCount.current > 1
convoState.items.length - prevItemCount.current > 1
) {
newOffset = contentHeight.value - 50
setShowNewMessagesPill(true)
} else if (!hasScrolled && !convo.isFetchingHistory) {
} else if (!hasScrolled && !convoState.isFetchingHistory) {
setHasScrolled(true)
}
@ -141,12 +146,12 @@ export function MessagesList({
isMomentumScrolling.value = true
}
contentHeight.value = height
prevItemCount.current = convo.items.length
prevItemCount.current = convoState.items.length
},
[
hasScrolled,
convo.items.length,
convo.isFetchingHistory,
convoState.items.length,
convoState.isFetchingHistory,
setHasScrolled,
// all of these are stable
contentHeight,
@ -161,9 +166,9 @@ export function MessagesList({
const onStartReached = useCallback(() => {
if (hasScrolled) {
convo.fetchMessageHistory()
convoState.fetchMessageHistory()
}
}, [convo, hasScrolled])
}, [convoState, hasScrolled])
const onSendMessage = useCallback(
async (text: string) => {
@ -182,12 +187,12 @@ export function MessagesList({
return true
})
convo.sendMessage({
convoState.sendMessage({
text: rt.text,
facets: rt.facets,
})
},
[convo, getAgent],
[convoState, getAgent],
)
const onScroll = React.useCallback(
@ -225,11 +230,9 @@ export function MessagesList({
// -- Keyboard animation handling
const animatedKeyboard = useAnimatedKeyboard()
const {gtMobile} = useBreakpoints()
const {bottom: bottomInset} = useSafeAreaInsets()
const nativeBottomBarHeight = isIOS ? 42 : 60
const bottomOffset =
isWeb && gtMobile ? 0 : bottomInset + nativeBottomBarHeight
const bottomOffset = isWeb ? 0 : bottomInset + nativeBottomBarHeight
// On web, we don't want to do anything.
// On native, we want to scroll the list to the bottom every frame that the keyboard is opening. `scrollTo` runs
@ -268,11 +271,10 @@ export function MessagesList({
<ScrollProvider onScroll={onScroll} onMomentumEnd={onMomentumEnd}>
<List
ref={flatListRef}
data={convo.items}
data={convoState.items}
renderItem={renderItem}
keyExtractor={keyExtractor}
containWeb={true}
contentContainerStyle={[a.px_md]}
disableVirtualization={true}
// The extra two items account for the header and the footer components
initialNumToRender={isNative ? 32 : 62}
@ -289,14 +291,18 @@ export function MessagesList({
onScrollToIndexFailed={onScrollToIndexFailed}
scrollEventThrottle={100}
ListHeaderComponent={
<MaybeLoader isLoading={convo.isFetchingHistory} />
<MaybeLoader isLoading={convoState.isFetchingHistory} />
}
/>
</ScrollProvider>
{!blocked ? (
<MessageInput
onSendMessage={onSendMessage}
scrollToEnd={scrollToEndNow}
/>
) : (
footer
)}
{showNewMessagesPill && <NewMessagesPill />}
</Animated.View>
)

View File

@ -1,35 +1,28 @@
import React, {useCallback} from 'react'
import {TouchableOpacity, View} from 'react-native'
import {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 {useFocusEffect} from '@react-navigation/native'
import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {makeProfileLink} from '#/lib/routes/links'
import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types'
import {CommonNavigatorParams} 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 {useProfileShadow} from 'state/cache/profile-shadow'
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, web} from '#/alf'
import {ConvoMenu} from '#/components/dms/ConvoMenu'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {MessagesListBlockedFooter} from '#/components/dms/MessagesListBlockedFooter'
import {MessagesListHeader} from '#/components/dms/MessagesListHeader'
import {Error} from '#/components/Error'
import {Link} from '#/components/Link'
import {ListMaybePlaceholder} from '#/components/Lists'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {ClipClopGate} from '../gate'
type Props = NativeStackScreenProps<
@ -73,6 +66,11 @@ function Inner() {
const convoState = useConvo()
const {_} = useLingui()
const moderationOpts = useModerationOpts()
const {data: recipient} = useProfileQuery({
did: convoState.recipients?.[0].did,
})
// Because we want to give the list a chance to asynchronously scroll to the end before it is visible to the user,
// we use `hasScrolled` to determine when to render. With that said however, there is a chance that the chat will be
// empty. So, we also check for that possible state as well and render once we can.
@ -86,7 +84,7 @@ function Inner() {
if (convoState.status === ConvoStatus.Error) {
return (
<CenteredView style={a.flex_1} sideBorders>
<Header />
<MessagesListHeader />
<Error
title={_(msg`Something went wrong`)}
message={_(msg`We couldn't load this conversation`)}
@ -96,20 +94,21 @@ function Inner() {
)
}
/*
* Any other convo states (atm) are "ready" states
*/
return (
<CenteredView style={[a.flex_1]} sideBorders>
<Header profile={convoState.recipients?.[0]} />
{!readyToShow && <MessagesListHeader />}
<View style={[a.flex_1]}>
{isConvoActive(convoState) ? (
<MessagesList
{moderationOpts && recipient ? (
<InnerReady
moderationOpts={moderationOpts}
recipient={recipient}
hasScrolled={hasScrolled}
setHasScrolled={setHasScrolled}
/>
) : (
<ListMaybePlaceholder isLoading />
<>
<View style={[a.align_center, a.gap_sm, a.flex_1]} />
</>
)}
{!readyToShow && (
<View
@ -132,160 +131,55 @@ function Inner() {
)
}
const PFP_SIZE = isWeb ? 40 : 34
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.align_center,
a.gap_sm,
gtTablet ? a.pl_lg : a.pl_xl,
a.pr_lg,
a.py_sm,
]}>
{!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>
)}
{profile && moderationOpts ? (
<HeaderReady profile={profile} moderationOpts={moderationOpts} />
) : (
<>
<View style={[a.flex_row, a.align_center, a.gap_md, a.flex_1]}>
<View
style={[
{width: PFP_SIZE, height: PFP_SIZE},
a.rounded_full,
t.atoms.bg_contrast_25,
]}
/>
<View style={a.gap_xs}>
<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>
<View style={{width: 30}} />
</>
)}
</View>
)
}
Header = React.memo(Header)
function HeaderReady({
profile: profileUnshadowed,
function InnerReady({
moderationOpts,
recipient: recipientUnshadowed,
hasScrolled,
setHasScrolled,
}: {
profile: AppBskyActorDefs.ProfileViewBasic
moderationOpts: ModerationOpts
recipient: AppBskyActorDefs.ProfileViewBasic
hasScrolled: boolean
setHasScrolled: React.Dispatch<React.SetStateAction<boolean>>
}) {
const t = useTheme()
const convoState = useConvo()
const profile = useProfileShadow(profileUnshadowed)
const moderation = React.useMemo(
() => moderateProfile(profile, moderationOpts),
[profile, moderationOpts],
)
const recipient = useProfileShadow(recipientUnshadowed)
const isDeletedAccount = profile?.handle === 'missing.invalid'
const displayName = isDeletedAccount
? 'Deleted Account'
: sanitizeDisplayName(
profile.displayName || profile.handle,
moderation.ui('displayName'),
)
const moderation = React.useMemo(() => {
return moderateProfile(recipient, moderationOpts)
}, [recipient, moderationOpts])
const blockInfo = React.useMemo(() => {
const modui = moderation.ui('profileView')
const blocks = modui.alerts.filter(alert => alert.type === 'blocking')
const listBlocks = blocks.filter(alert => alert.source.type === 'list')
const userBlock = blocks.find(alert => alert.source.type === 'user')
return {
listBlocks,
userBlock,
}
}, [moderation])
return (
<>
<Link
style={[a.flex_row, a.align_center, a.gap_md, a.flex_1, a.pr_md]}
to={makeProfileLink(profile)}>
<PreviewableUserAvatar
size={PFP_SIZE}
profile={profile}
moderation={moderation.ui('avatar')}
disableHoverCard={moderation.blocked}
/>
<View style={a.flex_1}>
<Text
style={[a.text_md, a.font_bold, web(a.leading_normal)]}
numberOfLines={1}>
{displayName}
</Text>
{!isDeletedAccount && (
<Text
style={[
t.atoms.text_contrast_medium,
a.text_sm,
web([a.leading_normal, {marginTop: -2}]),
]}
numberOfLines={1}>
@{profile.handle}
</Text>
)}
</View>
</Link>
{isConvoActive(convoState) && (
<ConvoMenu
convo={convoState.convo}
profile={profile}
currentScreen="conversation"
<MessagesListHeader
profile={recipient}
moderation={moderation}
blockInfo={blockInfo}
/>
{isConvoActive(convoState) && (
<MessagesList
hasScrolled={hasScrolled}
setHasScrolled={setHasScrolled}
blocked={moderation?.blocked}
footer={
<MessagesListBlockedFooter
recipient={recipient}
convoId={convoState.convo.id}
hasMessages={convoState.items.length > 0}
blockInfo={blockInfo}
/>
}
/>
)}
</>

View File

@ -65,6 +65,17 @@ function ChatListItemReady({
[profile, moderationOpts],
)
const blockInfo = React.useMemo(() => {
const modui = moderation.ui('profileView')
const blocks = modui.alerts.filter(alert => alert.type === 'blocking')
const listBlocks = blocks.filter(alert => alert.source.type === 'list')
const userBlock = blocks.find(alert => alert.source.type === 'user')
return {
listBlocks,
userBlock,
}
}, [moderation])
const isDeletedAccount = profile.handle === 'missing.invalid'
const displayName = isDeletedAccount
? 'Deleted Account'
@ -241,7 +252,7 @@ function ChatListItemReady({
triggerOpacity={
!gtMobile || showActions || menuControl.isOpen ? 1 : 0
}
moderation={moderation}
blockInfo={blockInfo}
/>
</View>
</View>