about summary refs log tree commit diff
path: root/src/screens/Messages/Inbox.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/screens/Messages/Inbox.tsx')
-rw-r--r--src/screens/Messages/Inbox.tsx332
1 files changed, 332 insertions, 0 deletions
diff --git a/src/screens/Messages/Inbox.tsx b/src/screens/Messages/Inbox.tsx
new file mode 100644
index 000000000..3f3d5a8a8
--- /dev/null
+++ b/src/screens/Messages/Inbox.tsx
@@ -0,0 +1,332 @@
+import {useCallback, useMemo, useState} from 'react'
+import {View} from 'react-native'
+import {ChatBskyConvoDefs, ChatBskyConvoListConvos} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useFocusEffect, useNavigation} from '@react-navigation/native'
+import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query'
+
+import {useAppState} from '#/lib/hooks/useAppState'
+import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
+import {
+  CommonNavigatorParams,
+  NativeStackScreenProps,
+  NavigationProp,
+} 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 {useLeftConvos} from '#/state/queries/messages/leave-conversation'
+import {useListConvosQuery} from '#/state/queries/messages/list-conversations'
+import {useUpdateAllRead} from '#/state/queries/messages/update-all-read'
+import {FAB} from '#/view/com/util/fab/FAB'
+import {List} from '#/view/com/util/List'
+import * as Toast from '#/view/com/util/Toast'
+import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus'
+import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow'
+import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotateCounterClockwise'
+import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
+import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo'
+import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message'
+import * as Layout from '#/components/Layout'
+import {ListFooter} from '#/components/Lists'
+import {Loader} from '#/components/Loader'
+import {Text} from '#/components/Typography'
+import {RequestListItem} from './components/RequestListItem'
+
+type Props = NativeStackScreenProps<CommonNavigatorParams, 'MessagesInbox'>
+export function MessagesInboxScreen({}: Props) {
+  const {gtTablet} = useBreakpoints()
+
+  const listConvosQuery = useListConvosQuery({status: 'request'})
+  const {data} = listConvosQuery
+
+  const leftConvos = useLeftConvos()
+
+  const conversations = useMemo(() => {
+    if (data?.pages) {
+      const convos = data.pages
+        .flatMap(page => page.convos)
+        // filter out convos that are actively being left
+        .filter(convo => !leftConvos.includes(convo.id))
+
+      return convos
+    }
+    return []
+  }, [data, leftConvos])
+
+  const hasUnreadConvos = useMemo(() => {
+    return conversations.some(conversation => conversation.unreadCount > 0)
+  }, [conversations])
+
+  return (
+    <Layout.Screen testID="messagesInboxScreen">
+      <Layout.Header.Outer>
+        <Layout.Header.BackButton />
+        <Layout.Header.Content align={gtTablet ? 'left' : 'platform'}>
+          <Layout.Header.TitleText>
+            <Trans>Chat requests</Trans>
+          </Layout.Header.TitleText>
+        </Layout.Header.Content>
+        {hasUnreadConvos && gtTablet ? (
+          <MarkAsReadHeaderButton />
+        ) : (
+          <Layout.Header.Slot />
+        )}
+      </Layout.Header.Outer>
+      <RequestList
+        listConvosQuery={listConvosQuery}
+        conversations={conversations}
+        hasUnreadConvos={hasUnreadConvos}
+      />
+    </Layout.Screen>
+  )
+}
+
+function RequestList({
+  listConvosQuery,
+  conversations,
+  hasUnreadConvos,
+}: {
+  listConvosQuery: UseInfiniteQueryResult<
+    InfiniteData<ChatBskyConvoListConvos.OutputSchema>,
+    Error
+  >
+  conversations: ChatBskyConvoDefs.ConvoView[]
+  hasUnreadConvos: boolean
+}) {
+  const {_} = useLingui()
+  const t = useTheme()
+  const navigation = useNavigation<NavigationProp>()
+
+  // 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 initialNumToRender = useInitialNumToRender({minItemHeight: 130})
+  const [isPTRing, setIsPTRing] = useState(false)
+
+  const {
+    isLoading,
+    isFetchingNextPage,
+    hasNextPage,
+    fetchNextPage,
+    isError,
+    error,
+    refetch,
+  } = listConvosQuery
+
+  useRefreshOnFocus(refetch)
+
+  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])
+
+  if (conversations.length < 1) {
+    return (
+      <Layout.Center>
+        {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]}>
+                  <CircleInfoIcon
+                    width={48}
+                    fill={t.atoms.text_contrast_low.color}
+                  />
+                  <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) || _(msg`Failed to load conversations`)}
+                  </Text>
+
+                  <Button
+                    label={_(msg`Reload conversations`)}
+                    size="small"
+                    color="secondary_inverted"
+                    variant="solid"
+                    onPress={() => refetch()}>
+                    <ButtonText>
+                      <Trans>Retry</Trans>
+                    </ButtonText>
+                    <ButtonIcon icon={RetryIcon} position="right" />
+                  </Button>
+                </View>
+              </>
+            ) : (
+              <>
+                <View style={[a.pt_3xl, a.align_center]}>
+                  <MessageIcon width={48} fill={t.palette.primary_500} />
+                  <Text style={[a.pt_md, a.pb_sm, a.text_2xl, a.font_bold]}>
+                    <Trans comment="Title message shown in chat requests inbox when it's empty">
+                      Inbox zero!
+                    </Trans>
+                  </Text>
+                  <Text
+                    style={[
+                      a.text_md,
+                      a.pb_xl,
+                      a.text_center,
+                      a.leading_snug,
+                      t.atoms.text_contrast_medium,
+                    ]}>
+                    <Trans>
+                      You don't have any chat requests at the moment.
+                    </Trans>
+                  </Text>
+                  <Button
+                    variant="solid"
+                    color="secondary"
+                    size="small"
+                    label={_(msg`Go back`)}
+                    onPress={() => {
+                      if (navigation.canGoBack()) {
+                        navigation.goBack()
+                      } else {
+                        navigation.navigate('Messages', {animation: 'pop'})
+                      }
+                    }}>
+                    <ButtonIcon icon={ArrowLeftIcon} />
+                    <ButtonText>
+                      <Trans>Back to Chats</Trans>
+                    </ButtonText>
+                  </Button>
+                </View>
+              </>
+            )}
+          </>
+        )}
+      </Layout.Center>
+    )
+  }
+
+  return (
+    <>
+      <List
+        data={conversations}
+        renderItem={renderItem}
+        keyExtractor={keyExtractor}
+        refreshing={isPTRing}
+        onRefresh={onRefresh}
+        onEndReached={onEndReached}
+        ListFooterComponent={
+          <ListFooter
+            isFetchingNextPage={isFetchingNextPage}
+            error={cleanError(error)}
+            onRetry={fetchNextPage}
+            style={{borderColor: 'transparent'}}
+            hasNextPage={hasNextPage}
+          />
+        }
+        onEndReachedThreshold={isNative ? 1.5 : 0}
+        initialNumToRender={initialNumToRender}
+        windowSize={11}
+        desktopFixedHeight
+        sideBorders={false}
+      />
+      {hasUnreadConvos && <MarkAllReadFAB />}
+    </>
+  )
+}
+
+function keyExtractor(item: ChatBskyConvoDefs.ConvoView) {
+  return item.id
+}
+
+function renderItem({item}: {item: ChatBskyConvoDefs.ConvoView}) {
+  return <RequestListItem convo={item} />
+}
+
+function MarkAllReadFAB() {
+  const {_} = useLingui()
+  const t = useTheme()
+  const {mutate: markAllRead} = useUpdateAllRead('request', {
+    onMutate: () => {
+      Toast.show(_(msg`Marked all as read`), 'check')
+    },
+    onError: () => {
+      Toast.show(_(msg`Failed to mark all requests as read`), 'xmark')
+    },
+  })
+
+  return (
+    <FAB
+      testID="markAllAsReadFAB"
+      onPress={() => markAllRead()}
+      icon={<CheckIcon size="lg" fill={t.palette.white} />}
+      accessibilityRole="button"
+      accessibilityLabel={_(msg`Mark all as read`)}
+      accessibilityHint=""
+    />
+  )
+}
+
+function MarkAsReadHeaderButton() {
+  const {_} = useLingui()
+  const {mutate: markAllRead} = useUpdateAllRead('request', {
+    onMutate: () => {
+      Toast.show(_(msg`Marked all as read`), 'check')
+    },
+    onError: () => {
+      Toast.show(_(msg`Failed to mark all requests as read`), 'xmark')
+    },
+  })
+
+  return (
+    <Button
+      label={_(msg`Mark all as read`)}
+      size="small"
+      color="secondary"
+      variant="solid"
+      onPress={() => markAllRead()}>
+      <ButtonIcon icon={CheckIcon} />
+      <ButtonText>
+        <Trans>Mark all as read</Trans>
+      </ButtonText>
+    </Button>
+  )
+}