diff options
Diffstat (limited to 'src/screens/Messages/List/index.tsx')
-rw-r--r-- | src/screens/Messages/List/index.tsx | 422 |
1 files changed, 296 insertions, 126 deletions
diff --git a/src/screens/Messages/List/index.tsx b/src/screens/Messages/List/index.tsx index c4490aa5c..ce8f52af9 100644 --- a/src/screens/Messages/List/index.tsx +++ b/src/screens/Messages/List/index.tsx @@ -1,31 +1,59 @@ -import React, {useCallback, useState} from 'react' +/* eslint-disable react/prop-types */ + +import React, {useCallback, useMemo, useState} from 'react' import {View} from 'react-native' -import {msg} from '@lingui/macro' +import {ChatBskyConvoDefs} from '@atproto-labs/api' +import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {NativeStackScreenProps} from '@react-navigation/native-stack' -import {useInfiniteQuery} from '@tanstack/react-query' +import {sha256} from 'js-sha256' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' import {MessagesTabNavigatorParams} from '#/lib/routes/types' import {useGate} from '#/lib/statsig/statsig' import {cleanError} from '#/lib/strings/errors' import {logger} from '#/logger' -import {useAgent} from '#/state/session' +import {isNative} from '#/platform/detection' +import {useListConvos} from '#/state/queries/messages/list-converations' +import {useSession} from '#/state/session' import {List} from '#/view/com/util/List' +import {TimeElapsed} from '#/view/com/util/TimeElapsed' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' import {ViewHeader} from '#/view/com/util/ViewHeader' -import {useTheme} from '#/alf' -import {atoms as a} from '#/alf' +import {CenteredView} from '#/view/com/util/Views' +import {ScrollView} 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 {ConvoMenu} from '#/components/dms/ConvoMenu' +import {NewChat} from '#/components/dms/NewChat' +import * as TextField from '#/components/forms/TextField' +import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus' +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, ListMaybePlaceholder} from '#/components/Lists' +import {useMenuControl} from '#/components/Menu' import {Text} from '#/components/Typography' import {ClipClopGate} from '../gate' +import {useDmServiceUrlStorage} from '../Temp/useDmServiceUrlStorage' -type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'MessagesList'> -export function MessagesListScreen({}: Props) { +type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'Messages'> +export function MessagesScreen({navigation}: Props) { const {_} = useLingui() const t = useTheme() + const newChatControl = useDialogControl() + const {gtMobile} = useBreakpoints() + + // TEMP + const {serviceUrl, setServiceUrl} = useDmServiceUrlStorage() + const hasValidServiceUrl = useMemo(() => { + const hash = sha256(serviceUrl) + return ( + hash === + 'a32318b49dd3fe6aa6a35c66c13fcc4c1cb6202b24f5a852d9a2279acee4169f' + ) + }, [serviceUrl]) const renderButton = useCallback(() => { return ( @@ -49,18 +77,20 @@ export function MessagesListScreen({}: Props) { fetchNextPage, error, refetch, - } = usePlaceholderConversations() + } = useListConvos({refetchInterval: 15_000}) + + useRefreshOnFocus(refetch) const isError = !!error - const conversations = React.useMemo(() => { + const conversations = useMemo(() => { if (data?.pages) { - return data.pages.flat() + return data.pages.flatMap(page => page.convos) } return [] }, [data]) - const onRefresh = React.useCallback(async () => { + const onRefresh = useCallback(async () => { setIsPTRing(true) try { await refetch() @@ -70,7 +100,7 @@ export function MessagesListScreen({}: Props) { setIsPTRing(false) }, [refetch, setIsPTRing]) - const onEndReached = React.useCallback(async () => { + const onEndReached = useCallback(async () => { if (isFetchingNextPage || !hasNextPage || isError) return try { await fetchNextPage() @@ -79,85 +109,104 @@ export function MessagesListScreen({}: Props) { } }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage]) + const onNewChat = useCallback( + (conversation: string) => + navigation.navigate('MessagesConversation', {conversation}), + [navigation], + ) + + const onNavigateToSettings = useCallback(() => { + navigation.navigate('MessagesSettings') + }, [navigation]) + + const renderItem = useCallback( + ({item}: {item: ChatBskyConvoDefs.ConvoView}) => { + return <ChatListItem key={item.id} convo={item} /> + }, + [], + ) + const gate = useGate() if (!gate('dms')) return <ClipClopGate /> + if (!hasValidServiceUrl) { + return ( + <ScrollView contentContainerStyle={a.p_lg}> + <View> + <TextField.LabelText>Service URL</TextField.LabelText> + <TextField.Root> + <TextField.Input + value={serviceUrl} + onChangeText={text => setServiceUrl(text)} + autoCapitalize="none" + keyboardType="url" + label="https://" + /> + </TextField.Root> + </View> + </ScrollView> + ) + } + if (conversations.length < 1) { return ( - <ListMaybePlaceholder - isLoading={isLoading} - isError={isError} - emptyType="results" - emptyMessage={_( - msg`You have no messages yet. Start a conversation with someone!`, + <View style={a.flex_1}> + {gtMobile ? ( + <CenteredView sideBorders> + <DesktopHeader + newChatControl={newChatControl} + onNavigateToSettings={onNavigateToSettings} + /> + </CenteredView> + ) : ( + <ViewHeader + title={_(msg`Messages`)} + renderButton={renderButton} + showBorder + canGoBack={false} + /> )} - errorMessage={cleanError(error)} - onRetry={isError ? refetch : undefined} - /> + {!isError && <NewChat onNewChat={onNewChat} control={newChatControl} />} + <ListMaybePlaceholder + isLoading={isLoading} + isError={isError} + emptyType="results" + emptyTitle={_(msg`No messages yet`)} + emptyMessage={_( + msg`You have no messages yet. Start a conversation with someone!`, + )} + errorMessage={cleanError(error)} + onRetry={isError ? refetch : undefined} + hideBackButton + /> + </View> ) } return ( - <View> - <ViewHeader - title={_(msg`Messages`)} - showOnDesktop - renderButton={renderButton} - showBorder - canGoBack={false} - /> + <View style={a.flex_1}> + {!gtMobile && ( + <ViewHeader + title={_(msg`Messages`)} + renderButton={renderButton} + showBorder + canGoBack={false} + /> + )} + <NewChat onNewChat={onNewChat} control={newChatControl} /> <List data={conversations} - renderItem={({item}) => { - return ( - <Link - to={`/messages/${item.profile.handle}`} - style={[a.flex_1, a.pl_md, a.py_sm, a.gap_md, a.pr_2xl]}> - <PreviewableUserAvatar profile={item.profile} size={44} /> - <View style={[a.flex_1]}> - <View - style={[ - a.flex_row, - a.align_center, - a.justify_between, - a.gap_lg, - a.flex_1, - ]}> - <Text numberOfLines={1}> - <Text style={item.unread && a.font_bold}> - {item.profile.displayName || item.profile.handle} - </Text>{' '} - <Text style={t.atoms.text_contrast_medium}> - @{item.profile.handle} - </Text> - </Text> - {item.unread && ( - <View - style={[ - a.ml_2xl, - {backgroundColor: t.palette.primary_500}, - a.rounded_full, - {height: 7, width: 7}, - ]} - /> - )} - </View> - <Text - numberOfLines={2} - style={[ - a.text_sm, - item.unread ? a.font_bold : t.atoms.text_contrast_medium, - ]}> - {item.lastMessage} - </Text> - </View> - </Link> - ) - }} - keyExtractor={item => item.profile.did} + renderItem={renderItem} + keyExtractor={item => item.id} refreshing={isPTRing} onRefresh={onRefresh} onEndReached={onEndReached} + ListHeaderComponent={ + <DesktopHeader + newChatControl={newChatControl} + onNavigateToSettings={onNavigateToSettings} + /> + } ListFooterComponent={ <ListFooter isFetchingNextPage={isFetchingNextPage} @@ -169,61 +218,182 @@ export function MessagesListScreen({}: Props) { onEndReachedThreshold={3} initialNumToRender={initialNumToRender} windowSize={11} + // @ts-ignore our .web version only -sfn + desktopFixedHeight /> </View> ) } -function usePlaceholderConversations() { - const {getAgent} = useAgent() - - return useInfiniteQuery({ - queryKey: ['messages'], - queryFn: async () => { - const people = await getAgent().getProfiles({actors: PLACEHOLDER_PEOPLE}) - return people.data.profiles.map(profile => ({ - profile, - unread: Math.random() > 0.5, - lastMessage: getRandomPost(), - })) - }, - initialPageParam: undefined, - getNextPageParam: () => undefined, - }) +function ChatListItem({convo}: {convo: ChatBskyConvoDefs.ConvoView}) { + const t = useTheme() + const {_} = useLingui() + const {currentAccount} = useSession() + const menuControl = useMenuControl() + + let lastMessage = _(msg`No messages yet`) + let lastMessageSentAt: string | null = null + if (ChatBskyConvoDefs.isMessageView(convo.lastMessage)) { + if (convo.lastMessage.sender?.did === currentAccount?.did) { + lastMessage = _(msg`You: ${convo.lastMessage.text}`) + } else { + lastMessage = convo.lastMessage.text + } + lastMessageSentAt = convo.lastMessage.sentAt + } + if (ChatBskyConvoDefs.isDeletedMessageView(convo.lastMessage)) { + lastMessage = _(msg`Message deleted`) + } + + const otherUser = convo.members.find( + member => member.did !== currentAccount?.did, + ) + + if (!otherUser) { + return null + } + + return ( + <Link + to={`/messages/${convo.id}`} + style={a.flex_1} + onLongPress={isNative ? menuControl.open : undefined}> + {({hovered, pressed}) => ( + <View + style={[ + a.flex_row, + a.flex_1, + a.pl_md, + a.py_sm, + a.gap_md, + a.pr_2xl, + (hovered || pressed) && t.atoms.bg_contrast_25, + ]}> + <View pointerEvents="none"> + <PreviewableUserAvatar profile={otherUser} size={42} /> + </View> + <View style={[a.flex_1]}> + <Text + numberOfLines={1} + style={[a.text_md, web([a.leading_normal, {marginTop: -4}])]}> + <Text + style={[t.atoms.text, convo.unreadCount > 0 && a.font_bold]}> + {otherUser.displayName || otherUser.handle} + </Text>{' '} + {lastMessageSentAt ? ( + <TimeElapsed timestamp={lastMessageSentAt}> + {({timeElapsed}) => ( + <Text style={t.atoms.text_contrast_medium}> + @{otherUser.handle} · {timeElapsed} + </Text> + )} + </TimeElapsed> + ) : ( + <Text style={t.atoms.text_contrast_medium}> + @{otherUser.handle} + </Text> + )} + </Text> + <Text + numberOfLines={2} + style={[ + a.text_sm, + a.leading_snug, + convo.unreadCount > 0 + ? a.font_bold + : t.atoms.text_contrast_medium, + ]}> + {lastMessage} + </Text> + </View> + {convo.unreadCount > 0 && ( + <View + style={[ + a.flex_0, + a.ml_md, + a.mt_sm, + a.rounded_full, + { + backgroundColor: convo.muted + ? t.palette.contrast_200 + : t.palette.primary_500, + height: 7, + width: 7, + }, + ]} + /> + )} + <ConvoMenu + convo={convo} + profile={otherUser} + control={menuControl} + // TODO(sam) show on hover on web + // tricky because it captures the mouse event + hideTrigger + currentScreen="list" + /> + </View> + )} + </Link> + ) } -const PLACEHOLDER_PEOPLE = [ - 'pfrazee.com', - 'haileyok.com', - 'danabra.mov', - 'esb.lol', - 'samuel.bsky.team', -] - -function getRandomPost() { - const num = Math.floor(Math.random() * 10) - switch (num) { - case 0: - return 'hello' - case 1: - return 'lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua' - case 2: - return 'banger post' - case 3: - return 'lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua' - case 4: - return 'lol look at this bug' - case 5: - return 'wow' - case 6: - return "that's pretty cool, wow!" - case 7: - return 'I think this is a bug' - case 8: - return 'Hello World!' - case 9: - return 'DMs when???' - default: - return 'this is unlikely' +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, + t.atoms.border_contrast_low, + a.border_b, + a.flex_row, + a.align_center, + a.justify_between, + a.gap_lg, + a.px_lg, + a.py_sm, + ]}> + <Text style={[a.text_2xl, a.font_bold]}> + <Trans>Messages</Trans> + </Text> + <View style={[a.flex_row, a.align_center, a.gap_md]}> + <Button + label={_(msg`Message settings`)} + color="secondary" + size="large" + variant="ghost" + style={[{height: 'auto', width: 'auto'}, a.px_sm, a.py_sm]} + onPress={onNavigateToSettings}> + <ButtonIcon icon={SettingsSlider} /> + </Button> + {gtTablet && ( + <Button + label={_(msg`New chat`)} + color="primary" + size="large" + variant="solid" + style={[{height: 'auto', width: 'auto'}, a.px_md, a.py_sm]} + onPress={newChatControl.open}> + <ButtonIcon icon={Plus} position="right" /> + <ButtonText> + <Trans>New chat</Trans> + </ButtonText> + </Button> + )} + </View> + </View> + ) } |