diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/Navigation.tsx | 5 | ||||
-rw-r--r-- | src/components/Prompt.tsx | 4 | ||||
-rw-r--r-- | src/components/dms/BlockedByListDialog.tsx | 62 | ||||
-rw-r--r-- | src/components/dms/ConvoMenu.tsx | 104 | ||||
-rw-r--r-- | src/components/dms/LeaveConvoPrompt.tsx | 55 | ||||
-rw-r--r-- | src/components/dms/MessageItem.tsx | 2 | ||||
-rw-r--r-- | src/components/dms/MessagesListBlockedFooter.tsx | 131 | ||||
-rw-r--r-- | src/components/dms/MessagesListHeader.tsx | 194 | ||||
-rw-r--r-- | src/components/dms/ReportConversationPrompt.tsx | 27 | ||||
-rw-r--r-- | src/lib/routes/types.ts | 6 | ||||
-rw-r--r-- | src/screens/Messages/Conversation/MessagesList.tsx | 50 | ||||
-rw-r--r-- | src/screens/Messages/Conversation/index.tsx | 228 | ||||
-rw-r--r-- | src/screens/Messages/List/ChatListItem.tsx | 13 |
13 files changed, 600 insertions, 281 deletions
diff --git a/src/Navigation.tsx b/src/Navigation.tsx index f68f8ed66..7abfaec08 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -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> diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx index 92e848e8e..d05cab5ab 100644 --- a/src/components/Prompt.tsx +++ b/src/components/Prompt.tsx @@ -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> ) diff --git a/src/components/dms/BlockedByListDialog.tsx b/src/components/dms/BlockedByListDialog.tsx new file mode 100644 index 000000000..a27701605 --- /dev/null +++ b/src/components/dms/BlockedByListDialog.tsx @@ -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> + ) +} diff --git a/src/components/dms/ConvoMenu.tsx b/src/components/dms/ConvoMenu.tsx index cf1dbc171..0e5cd12bf 100644 --- a/src/components/dms/ConvoMenu.tsx +++ b/src/components/dms/ConvoMenu.tsx @@ -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() {} diff --git a/src/components/dms/LeaveConvoPrompt.tsx b/src/components/dms/LeaveConvoPrompt.tsx new file mode 100644 index 000000000..1c42dbca0 --- /dev/null +++ b/src/components/dms/LeaveConvoPrompt.tsx @@ -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} + /> + ) +} diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx index f456fa474..c5ff81091 100644 --- a/src/components/dms/MessageItem.tsx +++ b/src/components/dms/MessageItem.tsx @@ -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={[ diff --git a/src/components/dms/MessagesListBlockedFooter.tsx b/src/components/dms/MessagesListBlockedFooter.tsx new file mode 100644 index 000000000..a018b8623 --- /dev/null +++ b/src/components/dms/MessagesListBlockedFooter.tsx @@ -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> + ) +} diff --git a/src/components/dms/MessagesListHeader.tsx b/src/components/dms/MessagesListHeader.tsx new file mode 100644 index 000000000..a6dff4032 --- /dev/null +++ b/src/components/dms/MessagesListHeader.tsx @@ -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} + /> + )} + </> + ) +} diff --git a/src/components/dms/ReportConversationPrompt.tsx b/src/components/dms/ReportConversationPrompt.tsx new file mode 100644 index 000000000..610cfbcf9 --- /dev/null +++ b/src/components/dms/ReportConversationPrompt.tsx @@ -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} + /> + ) +} diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index 31133cb1b..5011aafd7 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -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 diff --git a/src/screens/Messages/Conversation/MessagesList.tsx b/src/screens/Messages/Conversation/MessagesList.tsx index d36fac8ae..ef0cc55d2 100644 --- a/src/screens/Messages/Conversation/MessagesList.tsx +++ b/src/screens/Messages/Conversation/MessagesList.tsx @@ -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> - <MessageInput - onSendMessage={onSendMessage} - scrollToEnd={scrollToEndNow} - /> + {!blocked ? ( + <MessageInput + onSendMessage={onSendMessage} + scrollToEnd={scrollToEndNow} + /> + ) : ( + footer + )} {showNewMessagesPill && <NewMessagesPill />} </Animated.View> ) diff --git a/src/screens/Messages/Conversation/index.tsx b/src/screens/Messages/Conversation/index.tsx index 2c42ed16d..0fe4138bb 100644 --- a/src/screens/Messages/Conversation/index.tsx +++ b/src/screens/Messages/Conversation/index.tsx @@ -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 isDeletedAccount = profile?.handle === 'missing.invalid' - const displayName = isDeletedAccount - ? 'Deleted Account' - : sanitizeDisplayName( - profile.displayName || profile.handle, - moderation.ui('displayName'), - ) + const recipient = useProfileShadow(recipientUnshadowed) + + 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> - + <MessagesListHeader + profile={recipient} + moderation={moderation} + blockInfo={blockInfo} + /> {isConvoActive(convoState) && ( - <ConvoMenu - convo={convoState.convo} - profile={profile} - currentScreen="conversation" - moderation={moderation} + <MessagesList + hasScrolled={hasScrolled} + setHasScrolled={setHasScrolled} + blocked={moderation?.blocked} + footer={ + <MessagesListBlockedFooter + recipient={recipient} + convoId={convoState.convo.id} + hasMessages={convoState.items.length > 0} + blockInfo={blockInfo} + /> + } /> )} </> diff --git a/src/screens/Messages/List/ChatListItem.tsx b/src/screens/Messages/List/ChatListItem.tsx index a7b7e0680..791dc82c0 100644 --- a/src/screens/Messages/List/ChatListItem.tsx +++ b/src/screens/Messages/List/ChatListItem.tsx @@ -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> |