diff options
Diffstat (limited to 'src/screens/Messages/List')
-rw-r--r-- | src/screens/Messages/List/ChatListItem.tsx | 378 | ||||
-rw-r--r-- | src/screens/Messages/List/index.tsx | 345 |
2 files changed, 0 insertions, 723 deletions
diff --git a/src/screens/Messages/List/ChatListItem.tsx b/src/screens/Messages/List/ChatListItem.tsx deleted file mode 100644 index 11c071082..000000000 --- a/src/screens/Messages/List/ChatListItem.tsx +++ /dev/null @@ -1,378 +0,0 @@ -import React, {useCallback, useState} from 'react' -import {GestureResponderEvent, View} from 'react-native' -import { - AppBskyActorDefs, - AppBskyEmbedRecord, - ChatBskyConvoDefs, - moderateProfile, - ModerationOpts, -} from '@atproto/api' -import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {useHaptics} from '#/lib/haptics' -import {decrementBadgeCount} from '#/lib/notifications/notifications' -import {logEvent} from '#/lib/statsig/statsig' -import {sanitizeDisplayName} from '#/lib/strings/display-names' -import { - postUriToRelativePath, - toBskyAppUrl, - toShortUrl, -} from '#/lib/strings/url-helpers' -import {isNative} from '#/platform/detection' -import {useProfileShadow} from '#/state/cache/profile-shadow' -import {useModerationOpts} from '#/state/preferences/moderation-opts' -import {useSession} from '#/state/session' -import {TimeElapsed} from '#/view/com/util/TimeElapsed' -import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' -import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' -import * as tokens from '#/alf/tokens' -import {ConvoMenu} from '#/components/dms/ConvoMenu' -import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2' -import {Link} from '#/components/Link' -import {useMenuControl} from '#/components/Menu' -import {PostAlerts} from '#/components/moderation/PostAlerts' -import {Text} from '#/components/Typography' - -export let ChatListItem = ({ - convo, -}: { - convo: ChatBskyConvoDefs.ConvoView -}): React.ReactNode => { - const {currentAccount} = useSession() - const moderationOpts = useModerationOpts() - - const otherUser = convo.members.find( - member => member.did !== currentAccount?.did, - ) - - if (!otherUser || !moderationOpts) { - return null - } - - return ( - <ChatListItemReady - convo={convo} - profile={otherUser} - moderationOpts={moderationOpts} - /> - ) -} - -ChatListItem = React.memo(ChatListItem) - -function ChatListItemReady({ - convo, - profile: profileUnshadowed, - moderationOpts, -}: { - convo: ChatBskyConvoDefs.ConvoView - profile: AppBskyActorDefs.ProfileViewBasic - moderationOpts: ModerationOpts -}) { - const t = useTheme() - const {_} = useLingui() - const {currentAccount} = useSession() - const menuControl = useMenuControl() - const {gtMobile} = useBreakpoints() - const profile = useProfileShadow(profileUnshadowed) - const moderation = React.useMemo( - () => moderateProfile(profile, moderationOpts), - [profile, moderationOpts], - ) - const playHaptic = useHaptics() - - 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' - : sanitizeDisplayName( - profile.displayName || profile.handle, - moderation.ui('displayName'), - ) - - const isDimStyle = convo.muted || moderation.blocked || isDeletedAccount - - const {lastMessage, lastMessageSentAt} = React.useMemo(() => { - let lastMessage = _(msg`No messages yet`) - let lastMessageSentAt: string | null = null - - if (ChatBskyConvoDefs.isMessageView(convo.lastMessage)) { - const isFromMe = convo.lastMessage.sender?.did === currentAccount?.did - - if (convo.lastMessage.text) { - if (isFromMe) { - lastMessage = _(msg`You: ${convo.lastMessage.text}`) - } else { - lastMessage = convo.lastMessage.text - } - } else if (convo.lastMessage.embed) { - const defaultEmbeddedContentMessage = _( - msg`(contains embedded content)`, - ) - - if (AppBskyEmbedRecord.isView(convo.lastMessage.embed)) { - const embed = convo.lastMessage.embed - - if (AppBskyEmbedRecord.isViewRecord(embed.record)) { - const record = embed.record - const path = postUriToRelativePath(record.uri, { - handle: record.author.handle, - }) - const href = path ? toBskyAppUrl(path) : undefined - const short = href - ? toShortUrl(href) - : defaultEmbeddedContentMessage - if (isFromMe) { - lastMessage = _(msg`You: ${short}`) - } else { - lastMessage = short - } - } - } else { - if (isFromMe) { - lastMessage = _(msg`You: ${defaultEmbeddedContentMessage}`) - } else { - lastMessage = defaultEmbeddedContentMessage - } - } - } - - lastMessageSentAt = convo.lastMessage.sentAt - } - if (ChatBskyConvoDefs.isDeletedMessageView(convo.lastMessage)) { - lastMessage = isDeletedAccount - ? _(msg`Conversation deleted`) - : _(msg`Message deleted`) - } - - return { - lastMessage, - lastMessageSentAt, - } - }, [_, convo.lastMessage, currentAccount?.did, isDeletedAccount]) - - const [showActions, setShowActions] = useState(false) - - const onMouseEnter = useCallback(() => { - setShowActions(true) - }, []) - - const onMouseLeave = useCallback(() => { - setShowActions(false) - }, []) - - const onFocus = useCallback<React.FocusEventHandler>(e => { - if (e.nativeEvent.relatedTarget == null) return - setShowActions(true) - }, []) - - const onPress = useCallback( - (e: GestureResponderEvent) => { - decrementBadgeCount(convo.unreadCount) - if (isDeletedAccount) { - e.preventDefault() - menuControl.open() - return false - } else { - logEvent('chat:open', {logContext: 'ChatsList'}) - } - }, - [convo.unreadCount, isDeletedAccount, menuControl], - ) - - const onLongPress = useCallback(() => { - playHaptic() - menuControl.open() - }, [playHaptic, menuControl]) - - return ( - <View - // @ts-expect-error web only - onMouseEnter={onMouseEnter} - onMouseLeave={onMouseLeave} - onFocus={onFocus} - onBlur={onMouseLeave} - style={[a.relative]}> - <View - style={[ - a.z_10, - a.absolute, - {top: tokens.space.md, left: tokens.space.lg}, - ]}> - <PreviewableUserAvatar - profile={profile} - size={52} - moderation={moderation.ui('avatar')} - /> - </View> - - <Link - to={`/messages/${convo.id}`} - label={displayName} - accessibilityHint={ - !isDeletedAccount - ? _(msg`Go to conversation with ${profile.handle}`) - : _( - msg`This conversation is with a deleted or a deactivated account. Press for options.`, - ) - } - accessibilityActions={ - isNative - ? [ - {name: 'magicTap', label: _(msg`Open conversation options`)}, - {name: 'longpress', label: _(msg`Open conversation options`)}, - ] - : undefined - } - onPress={onPress} - onLongPress={isNative ? onLongPress : undefined} - onAccessibilityAction={onLongPress}> - {({hovered, pressed, focused}) => ( - <View - style={[ - a.flex_row, - isDeletedAccount ? a.align_center : a.align_start, - a.flex_1, - a.px_lg, - a.py_md, - a.gap_md, - (hovered || pressed || focused) && t.atoms.bg_contrast_25, - t.atoms.border_contrast_low, - ]}> - {/* Avatar goes here */} - <View style={{width: 52, height: 52}} /> - - <View style={[a.flex_1, a.justify_center, web({paddingRight: 45})]}> - <View style={[a.w_full, a.flex_row, a.align_end, a.pb_2xs]}> - <Text - numberOfLines={1} - style={[{maxWidth: '85%'}, web([a.leading_normal])]}> - <Text - emoji - style={[ - a.text_md, - t.atoms.text, - a.font_bold, - {lineHeight: 21}, - isDimStyle && t.atoms.text_contrast_medium, - ]}> - {displayName} - </Text> - </Text> - {lastMessageSentAt && ( - <TimeElapsed timestamp={lastMessageSentAt}> - {({timeElapsed}) => ( - <Text - style={[ - a.text_sm, - {lineHeight: 21}, - t.atoms.text_contrast_medium, - web({whiteSpace: 'preserve nowrap'}), - ]}> - {' '} - · {timeElapsed} - </Text> - )} - </TimeElapsed> - )} - {(convo.muted || moderation.blocked) && ( - <Text - style={[ - a.text_sm, - {lineHeight: 21}, - t.atoms.text_contrast_medium, - web({whiteSpace: 'preserve nowrap'}), - ]}> - {' '} - ·{' '} - <BellStroke - size="xs" - style={[t.atoms.text_contrast_medium]} - /> - </Text> - )} - </View> - - {!isDeletedAccount && ( - <Text - numberOfLines={1} - style={[a.text_sm, t.atoms.text_contrast_medium, a.pb_xs]}> - @{profile.handle} - </Text> - )} - - <Text - emoji - numberOfLines={2} - style={[ - a.text_sm, - a.leading_snug, - convo.unreadCount > 0 - ? a.font_bold - : t.atoms.text_contrast_high, - isDimStyle && t.atoms.text_contrast_medium, - ]}> - {lastMessage} - </Text> - - <PostAlerts - modui={moderation.ui('contentList')} - size="lg" - style={[a.pt_xs]} - /> - </View> - - {convo.unreadCount > 0 && ( - <View - style={[ - a.absolute, - a.rounded_full, - { - backgroundColor: isDimStyle - ? t.palette.contrast_200 - : t.palette.primary_500, - height: 7, - width: 7, - top: 15, - right: 12, - }, - ]} - /> - )} - </View> - )} - </Link> - - <ConvoMenu - convo={convo} - profile={profile} - control={menuControl} - currentScreen="list" - showMarkAsRead={convo.unreadCount > 0} - hideTrigger={isNative} - blockInfo={blockInfo} - style={[ - a.absolute, - a.h_full, - a.self_end, - a.justify_center, - { - right: tokens.space.lg, - opacity: !gtMobile || showActions || menuControl.isOpen ? 1 : 0, - }, - ]} - /> - </View> - ) -} diff --git a/src/screens/Messages/List/index.tsx b/src/screens/Messages/List/index.tsx deleted file mode 100644 index efd717f0b..000000000 --- a/src/screens/Messages/List/index.tsx +++ /dev/null @@ -1,345 +0,0 @@ -import React, {useCallback, useEffect, useMemo, useState} from 'react' -import {View} from 'react-native' -import {ChatBskyConvoDefs} from '@atproto/api' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useFocusEffect} from '@react-navigation/native' -import {NativeStackScreenProps} from '@react-navigation/native-stack' - -import {useAppState} from '#/lib/hooks/useAppState' -import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' -import {MessagesTabNavigatorParams} from '#/lib/routes/types' -import {cleanError} from '#/lib/strings/errors' -import {logger} from '#/logger' -import {isNative} from '#/platform/detection' -import {MESSAGE_SCREEN_POLL_INTERVAL} from '#/state/messages/convo/const' -import {useMessagesEventBus} from '#/state/messages/events' -import {useListConvosQuery} from '#/state/queries/messages/list-converations' -import {List} from '#/view/com/util/List' -import {ViewHeader} from '#/view/com/util/ViewHeader' -import {CenteredView} from '#/view/com/util/Views' -import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' -import {Button, ButtonIcon, ButtonText} from '#/components/Button' -import {DialogControlProps, useDialogControl} from '#/components/Dialog' -import {NewChat} from '#/components/dms/dialogs/NewChatDialog' -import {MessagesNUX} from '#/components/dms/MessagesNUX' -import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus' -import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Retry} from '#/components/icons/ArrowRotateCounterClockwise' -import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' -import {Message_Stroke2_Corner0_Rounded as Message} from '#/components/icons/Message' -import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' -import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '#/components/icons/SettingsSlider' -import {Link} from '#/components/Link' -import {ListFooter} from '#/components/Lists' -import {Loader} from '#/components/Loader' -import {Text} from '#/components/Typography' -import {ChatListItem} from './ChatListItem' - -type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'Messages'> - -function renderItem({item}: {item: ChatBskyConvoDefs.ConvoView}) { - return <ChatListItem convo={item} /> -} - -function keyExtractor(item: ChatBskyConvoDefs.ConvoView) { - return item.id -} - -export function MessagesScreen({navigation, route}: Props) { - const {_} = useLingui() - const t = useTheme() - const newChatControl = useDialogControl() - const {gtMobile} = useBreakpoints() - const pushToConversation = route.params?.pushToConversation - - // Whenever we have `pushToConversation` set, it means we pressed a notification for a chat without being on - // this tab. We should immediately push to the conversation after pressing the notification. - // After we push, reset with `setParams` so that this effect will fire next time we press a notification, even if - // the conversation is the same as before - useEffect(() => { - if (pushToConversation) { - navigation.navigate('MessagesConversation', { - conversation: pushToConversation, - }) - navigation.setParams({pushToConversation: undefined}) - } - }, [navigation, pushToConversation]) - - // Request the poll interval to be 10s (or whatever the MESSAGE_SCREEN_POLL_INTERVAL is set to in the future) - // but only when the screen is active - const messagesBus = useMessagesEventBus() - const state = useAppState() - const isActive = state === 'active' - useFocusEffect( - useCallback(() => { - if (isActive) { - const unsub = messagesBus.requestPollInterval( - MESSAGE_SCREEN_POLL_INTERVAL, - ) - return () => unsub() - } - }, [messagesBus, isActive]), - ) - - const renderButton = useCallback(() => { - return ( - <Link - to="/messages/settings" - label={_(msg`Chat settings`)} - size="small" - variant="ghost" - color="secondary" - shape="square" - style={[a.justify_center]}> - <SettingsSlider size="md" style={[t.atoms.text_contrast_medium]} /> - </Link> - ) - }, [_, t]) - - const initialNumToRender = useInitialNumToRender({minItemHeight: 80}) - const [isPTRing, setIsPTRing] = useState(false) - - const { - data, - isLoading, - isFetchingNextPage, - hasNextPage, - fetchNextPage, - isError, - error, - refetch, - } = useListConvosQuery() - - useRefreshOnFocus(refetch) - - const conversations = useMemo(() => { - if (data?.pages) { - return data.pages.flatMap(page => page.convos) - } - return [] - }, [data]) - - const onRefresh = useCallback(async () => { - setIsPTRing(true) - try { - await refetch() - } catch (err) { - logger.error('Failed to refresh conversations', {message: err}) - } - setIsPTRing(false) - }, [refetch, setIsPTRing]) - - const onEndReached = useCallback(async () => { - if (isFetchingNextPage || !hasNextPage || isError) return - try { - await fetchNextPage() - } catch (err) { - logger.error('Failed to load more conversations', {message: err}) - } - }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage]) - - const onNewChat = useCallback( - (conversation: string) => - navigation.navigate('MessagesConversation', {conversation}), - [navigation], - ) - - const onNavigateToSettings = useCallback(() => { - navigation.navigate('MessagesSettings') - }, [navigation]) - - if (conversations.length < 1) { - return ( - <View style={a.flex_1}> - <MessagesNUX /> - - <CenteredView sideBorders={gtMobile} style={[a.h_full_vh]}> - {gtMobile ? ( - <DesktopHeader - newChatControl={newChatControl} - onNavigateToSettings={onNavigateToSettings} - /> - ) : ( - <ViewHeader - title={_(msg`Messages`)} - renderButton={renderButton} - showBorder - canGoBack={false} - /> - )} - - {isLoading ? ( - <View style={[a.align_center, a.pt_3xl, web({paddingTop: '10vh'})]}> - <Loader size="xl" /> - </View> - ) : ( - <> - {isError ? ( - <> - <View style={[a.pt_3xl, a.align_center]}> - <CircleInfo - width={48} - fill={t.atoms.border_contrast_low.borderColor} - /> - <Text style={[a.pt_md, a.pb_sm, a.text_2xl, a.font_bold]}> - <Trans>Whoops!</Trans> - </Text> - <Text - style={[ - a.text_md, - a.pb_xl, - a.text_center, - a.leading_snug, - t.atoms.text_contrast_medium, - {maxWidth: 360}, - ]}> - {cleanError(error)} - </Text> - - <Button - label={_(msg`Reload conversations`)} - size="large" - color="secondary" - variant="solid" - onPress={() => refetch()}> - <ButtonText>Retry</ButtonText> - <ButtonIcon icon={Retry} position="right" /> - </Button> - </View> - </> - ) : ( - <> - <View style={[a.pt_3xl, a.align_center]}> - <Message width={48} fill={t.palette.primary_500} /> - <Text style={[a.pt_md, a.pb_sm, a.text_2xl, a.font_bold]}> - <Trans>Nothing here</Trans> - </Text> - <Text - style={[ - a.text_md, - a.pb_xl, - a.text_center, - a.leading_snug, - t.atoms.text_contrast_medium, - ]}> - <Trans>You have no conversations yet. Start one!</Trans> - </Text> - </View> - </> - )} - </> - )} - </CenteredView> - - {!isLoading && !isError && ( - <NewChat onNewChat={onNewChat} control={newChatControl} /> - )} - </View> - ) - } - - return ( - <View style={a.flex_1}> - <MessagesNUX /> - {!gtMobile && ( - <ViewHeader - title={_(msg`Messages`)} - renderButton={renderButton} - showBorder - canGoBack={false} - /> - )} - <NewChat onNewChat={onNewChat} control={newChatControl} /> - <List - data={conversations} - renderItem={renderItem} - keyExtractor={keyExtractor} - refreshing={isPTRing} - onRefresh={onRefresh} - onEndReached={onEndReached} - ListHeaderComponent={ - <DesktopHeader - newChatControl={newChatControl} - onNavigateToSettings={onNavigateToSettings} - /> - } - ListFooterComponent={ - <ListFooter - isFetchingNextPage={isFetchingNextPage} - error={cleanError(error)} - onRetry={fetchNextPage} - style={{borderColor: 'transparent'}} - hasNextPage={hasNextPage} - showEndMessage={true} - endMessageText={_(msg`No more conversations to show`)} - /> - } - onEndReachedThreshold={isNative ? 1.5 : 0} - initialNumToRender={initialNumToRender} - windowSize={11} - // @ts-ignore our .web version only -sfn - desktopFixedHeight - /> - </View> - ) -} - -function DesktopHeader({ - newChatControl, - onNavigateToSettings, -}: { - newChatControl: DialogControlProps - onNavigateToSettings: () => void -}) { - const t = useTheme() - const {_} = useLingui() - const {gtMobile, gtTablet} = useBreakpoints() - - if (!gtMobile) { - return null - } - - return ( - <View - style={[ - t.atoms.bg, - a.flex_row, - a.align_center, - a.justify_between, - a.gap_lg, - a.px_lg, - a.pr_md, - a.py_sm, - a.border_b, - t.atoms.border_contrast_low, - ]}> - <Text style={[a.text_2xl, a.font_bold]}> - <Trans>Messages</Trans> - </Text> - <View style={[a.flex_row, a.align_center, a.gap_sm]}> - <Button - label={_(msg`Message settings`)} - color="secondary" - size="small" - variant="ghost" - shape="square" - onPress={onNavigateToSettings}> - <SettingsSlider size="md" style={[t.atoms.text_contrast_medium]} /> - </Button> - {gtTablet && ( - <Button - label={_(msg`New chat`)} - color="primary" - size="small" - variant="solid" - onPress={newChatControl.open}> - <ButtonIcon icon={Plus} position="left" /> - <ButtonText> - <Trans>New chat</Trans> - </ButtonText> - </Button> - )} - </View> - </View> - ) -} |