about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/components/Error.tsx26
-rw-r--r--src/components/Lists.tsx4
-rw-r--r--src/components/dms/NewChat.tsx233
-rw-r--r--src/screens/Messages/Conversation/MessageItem.tsx12
-rw-r--r--src/screens/Messages/Conversation/MessagesList.tsx9
-rw-r--r--src/screens/Messages/List/index.tsx43
-rw-r--r--src/screens/Messages/Temp/query/query.ts79
-rw-r--r--src/view/screens/Settings/index.tsx2
8 files changed, 352 insertions, 56 deletions
diff --git a/src/components/Error.tsx b/src/components/Error.tsx
index bf689fc07..ee479cca9 100644
--- a/src/components/Error.tsx
+++ b/src/components/Error.tsx
@@ -17,12 +17,14 @@ export function Error({
   message,
   onRetry,
   onGoBack: onGoBackProp,
+  hideBackButton,
   sideBorders = true,
 }: {
   title?: string
   message?: string
   onRetry?: () => unknown
   onGoBack?: () => unknown
+  hideBackButton?: boolean
   sideBorders?: boolean
 }) {
   const navigation = useNavigation<NavigationProp>()
@@ -89,17 +91,19 @@ export function Error({
             </ButtonText>
           </Button>
         )}
-        <Button
-          variant="solid"
-          color={onRetry ? 'secondary' : 'primary'}
-          label={_(msg`Return to previous page`)}
-          onPress={onGoBack}
-          size="large"
-          style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}>
-          <ButtonText>
-            <Trans>Go Back</Trans>
-          </ButtonText>
-        </Button>
+        {!hideBackButton && (
+          <Button
+            variant="solid"
+            color={onRetry ? 'secondary' : 'primary'}
+            label={_(msg`Return to previous page`)}
+            onPress={onGoBack}
+            size="large"
+            style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}>
+            <ButtonText>
+              <Trans>Go Back</Trans>
+            </ButtonText>
+          </Button>
+        )}
       </View>
     </CenteredView>
   )
diff --git a/src/components/Lists.tsx b/src/components/Lists.tsx
index b5419697b..721e877be 100644
--- a/src/components/Lists.tsx
+++ b/src/components/Lists.tsx
@@ -134,6 +134,7 @@ let ListMaybePlaceholder = ({
   emptyType = 'page',
   onRetry,
   onGoBack,
+  hideBackButton,
   sideBorders,
 }: {
   isLoading: boolean
@@ -146,6 +147,7 @@ let ListMaybePlaceholder = ({
   emptyType?: 'page' | 'results'
   onRetry?: () => Promise<unknown>
   onGoBack?: () => void
+  hideBackButton?: boolean
   sideBorders?: boolean
 }): React.ReactNode => {
   const t = useTheme()
@@ -179,6 +181,7 @@ let ListMaybePlaceholder = ({
         onRetry={onRetry}
         onGoBack={onGoBack}
         sideBorders={sideBorders}
+        hideBackButton={hideBackButton}
       />
     )
   }
@@ -198,6 +201,7 @@ let ListMaybePlaceholder = ({
         }
         onRetry={onRetry}
         onGoBack={onGoBack}
+        hideBackButton={hideBackButton}
         sideBorders={sideBorders}
       />
     )
diff --git a/src/components/dms/NewChat.tsx b/src/components/dms/NewChat.tsx
new file mode 100644
index 000000000..bbe118f04
--- /dev/null
+++ b/src/components/dms/NewChat.tsx
@@ -0,0 +1,233 @@
+import React, {useCallback, useMemo, useRef, useState} from 'react'
+import {Keyboard, View} from 'react-native'
+import {AppBskyActorDefs, moderateProfile} from '@atproto/api'
+import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {sanitizeDisplayName} from '#/lib/strings/display-names'
+import {sanitizeHandle} from '#/lib/strings/handles'
+import {isWeb} from '#/platform/detection'
+import {useModerationOpts} from '#/state/queries/preferences'
+import {useActorAutocompleteQuery} from 'state/queries/actor-autocomplete'
+import {FAB} from '#/view/com/util/fab/FAB'
+import * as Toast from '#/view/com/util/Toast'
+import {UserAvatar} from '#/view/com/util/UserAvatar'
+import {atoms as a, useTheme, web} from '#/alf'
+import * as Dialog from '#/components/Dialog'
+import * as TextField from '#/components/forms/TextField'
+import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
+import {useGetChatFromMembers} from '../../screens/Messages/Temp/query/query'
+import {Button} from '../Button'
+import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '../icons/Envelope'
+import {ListMaybePlaceholder} from '../Lists'
+import {Text} from '../Typography'
+
+export function NewChat({onNewChat}: {onNewChat: (chatId: string) => void}) {
+  const control = Dialog.useDialogControl()
+  const t = useTheme()
+  const {_} = useLingui()
+
+  const {mutate: createChat} = useGetChatFromMembers({
+    onSuccess: data => {
+      onNewChat(data.chat.id)
+    },
+    onError: error => {
+      Toast.show(error.message)
+    },
+  })
+
+  const onCreateChat = useCallback(
+    (did: string) => {
+      control.close(() => createChat([did]))
+    },
+    [control, createChat],
+  )
+
+  return (
+    <>
+      <FAB
+        testID="newChatFAB"
+        onPress={control.open}
+        icon={<Envelope size="xl" fill={t.palette.white} />}
+        accessibilityRole="button"
+        accessibilityLabel={_(msg`New chat`)}
+        accessibilityHint=""
+      />
+
+      <Dialog.Outer
+        control={control}
+        testID="newChatDialog"
+        nativeOptions={{sheet: {snapPoints: ['100%']}}}>
+        <Dialog.Handle />
+        <SearchablePeopleList onCreateChat={onCreateChat} />
+      </Dialog.Outer>
+    </>
+  )
+}
+
+function SearchablePeopleList({
+  onCreateChat,
+}: {
+  onCreateChat: (did: string) => void
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const moderationOpts = useModerationOpts()
+  const control = Dialog.useDialogContext()
+  const listRef = useRef<BottomSheetFlatListMethods>(null)
+
+  const [searchText, setSearchText] = useState('')
+
+  const {
+    data: actorAutocompleteData,
+    isFetching,
+    isError,
+    refetch,
+  } = useActorAutocompleteQuery(searchText, true)
+
+  const renderItem = useCallback(
+    ({item: profile}: {item: AppBskyActorDefs.ProfileView}) => {
+      if (!moderationOpts) return null
+      const moderation = moderateProfile(profile, moderationOpts)
+      return (
+        <Button
+          label={profile.displayName || sanitizeHandle(profile.handle)}
+          onPress={() => onCreateChat(profile.did)}>
+          {({hovered, pressed}) => (
+            <View
+              style={[
+                a.flex_1,
+                a.px_md,
+                a.py_sm,
+                a.gap_md,
+                a.align_center,
+                a.flex_row,
+                a.rounded_sm,
+                pressed
+                  ? t.atoms.bg_contrast_25
+                  : hovered
+                  ? t.atoms.bg_contrast_50
+                  : t.atoms.bg,
+              ]}>
+              <UserAvatar
+                size={40}
+                avatar={profile.avatar}
+                moderation={moderation.ui('avatar')}
+                type={profile.associated?.labeler ? 'labeler' : 'user'}
+              />
+              <View style={{flex: 1}}>
+                <Text
+                  style={[t.atoms.text, a.font_bold, a.leading_snug]}
+                  numberOfLines={1}>
+                  {sanitizeDisplayName(
+                    profile.displayName || sanitizeHandle(profile.handle),
+                    moderation.ui('displayName'),
+                  )}
+                </Text>
+                <Text style={t.atoms.text_contrast_high} numberOfLines={1}>
+                  {sanitizeHandle(profile.handle, '@')}
+                </Text>
+              </View>
+            </View>
+          )}
+        </Button>
+      )
+    },
+    [
+      moderationOpts,
+      onCreateChat,
+      t.atoms.bg_contrast_25,
+      t.atoms.bg_contrast_50,
+      t.atoms.bg,
+      t.atoms.text,
+      t.atoms.text_contrast_high,
+    ],
+  )
+
+  const listHeader = useMemo(() => {
+    return (
+      <View style={[a.relative, a.mb_lg]}>
+        {/* cover top corners */}
+        <View
+          style={[
+            a.absolute,
+            a.inset_0,
+            {
+              borderBottomLeftRadius: 8,
+              borderBottomRightRadius: 8,
+            },
+            t.atoms.bg,
+          ]}
+        />
+        <Dialog.Close />
+        <Text
+          style={[
+            a.text_2xl,
+            a.font_bold,
+            a.leading_tight,
+            a.pb_lg,
+            web(a.pt_lg),
+          ]}>
+          <Trans>Start a new chat</Trans>
+        </Text>
+        <TextField.Root>
+          <TextField.Icon icon={Search} />
+          <TextField.Input
+            label={_(msg`Search profiles`)}
+            placeholder={_(msg`Search`)}
+            value={searchText}
+            onChangeText={text => {
+              setSearchText(text)
+              listRef.current?.scrollToOffset({offset: 0, animated: false})
+            }}
+            returnKeyType="search"
+            clearButtonMode="while-editing"
+            maxLength={50}
+            onKeyPress={({nativeEvent}) => {
+              if (nativeEvent.key === 'Escape') {
+                control.close()
+              }
+            }}
+            autoCorrect={false}
+            autoComplete="off"
+            autoCapitalize="none"
+          />
+        </TextField.Root>
+      </View>
+    )
+  }, [t.atoms.bg, _, control, searchText])
+
+  return (
+    <Dialog.InnerFlatList
+      ref={listRef}
+      data={actorAutocompleteData}
+      renderItem={renderItem}
+      ListHeaderComponent={
+        <>
+          {listHeader}
+          {searchText.length > 0 && !actorAutocompleteData?.length && (
+            <ListMaybePlaceholder
+              isLoading={isFetching}
+              isError={isError}
+              onRetry={refetch}
+              hideBackButton={true}
+              emptyType="results"
+              sideBorders={false}
+              emptyMessage={
+                isError
+                  ? _(msg`No search results found for "${searchText}".`)
+                  : _(msg`Could not load profiles. Please try again later.`)
+              }
+            />
+          )}
+        </>
+      }
+      stickyHeaderIndices={[0]}
+      keyExtractor={(item: AppBskyActorDefs.ProfileView) => item.did}
+      // @ts-expect-error web only
+      style={isWeb && {minHeight: '100vh'}}
+      onScrollBeginDrag={() => Keyboard.dismiss()}
+    />
+  )
+}
diff --git a/src/screens/Messages/Conversation/MessageItem.tsx b/src/screens/Messages/Conversation/MessageItem.tsx
index 74e65488e..822b17804 100644
--- a/src/screens/Messages/Conversation/MessageItem.tsx
+++ b/src/screens/Messages/Conversation/MessageItem.tsx
@@ -1,12 +1,16 @@
 import React from 'react'
 import {View} from 'react-native'
 
+import {useAgent} from '#/state/session'
 import {atoms as a, useTheme} from '#/alf'
 import {Text} from '#/components/Typography'
 import * as TempDmChatDefs from '#/temp/dm/defs'
 
 export function MessageItem({item}: {item: TempDmChatDefs.MessageView}) {
   const t = useTheme()
+  const {getAgent} = useAgent()
+
+  const fromMe = item.sender?.did === getAgent().session?.did
 
   return (
     <View
@@ -15,13 +19,17 @@ export function MessageItem({item}: {item: TempDmChatDefs.MessageView}) {
         a.px_md,
         a.my_xs,
         a.rounded_md,
+        fromMe ? a.self_end : a.self_start,
         {
-          backgroundColor: t.palette.primary_500,
+          backgroundColor: fromMe
+            ? t.palette.primary_500
+            : t.palette.contrast_50,
           maxWidth: '65%',
           borderRadius: 17,
         },
       ]}>
-      <Text style={[a.text_md, {lineHeight: 1.2, color: 'white'}]}>
+      <Text
+        style={[a.text_md, a.leading_snug, fromMe && {color: t.palette.white}]}>
         {item.text}
       </Text>
     </View>
diff --git a/src/screens/Messages/Conversation/MessagesList.tsx b/src/screens/Messages/Conversation/MessagesList.tsx
index aafed42af..e3b518f65 100644
--- a/src/screens/Messages/Conversation/MessagesList.tsx
+++ b/src/screens/Messages/Conversation/MessagesList.tsx
@@ -1,5 +1,6 @@
 import React, {useCallback, useMemo, useRef, useState} from 'react'
-import {Alert, FlatList, View, ViewToken} from 'react-native'
+import {FlatList, View, ViewToken} from 'react-native'
+import {Alert} from 'react-native'
 import {KeyboardAvoidingView} from 'react-native-keyboard-controller'
 
 import {isWeb} from 'platform/detection'
@@ -64,6 +65,7 @@ export function MessagesList({chatId}: {chatId: string}) {
   const totalMessages = useRef(10)
 
   // TODO later
+
   const [_, setShowSpinner] = useState(false)
 
   // Query Data
@@ -147,6 +149,8 @@ export function MessagesList({chatId}: {chatId: string}) {
       },
     )
     totalMessages.current = filtered.length
+
+    return filtered
   }, [chat])
 
   return (
@@ -162,7 +166,7 @@ export function MessagesList({chatId}: {chatId: string}) {
         contentContainerStyle={{paddingHorizontal: 10}}
         // In the future, we might want to adjust this value. Not very concerning right now as long as we are only
         // dealing with text. But whenever we have images or other media and things are taller, we will want to lower
-        // this...probably
+        // this...probably.
         initialNumToRender={20}
         // Same with the max to render per batch. Let's be safe for now though.
         maxToRenderPerBatch={25}
@@ -175,7 +179,6 @@ export function MessagesList({chatId}: {chatId: string}) {
         maintainVisibleContentPosition={{
           minIndexForVisible: 0,
         }}
-        // This is actually a header since we are inverted!
         ListFooterComponent={<MaybeLoader isLoading={false} />}
         removeClippedSubviews={true}
         ref={flatListRef}
diff --git a/src/screens/Messages/List/index.tsx b/src/screens/Messages/List/index.tsx
index b13ddd291..ff4e8e83e 100644
--- a/src/screens/Messages/List/index.tsx
+++ b/src/screens/Messages/List/index.tsx
@@ -1,4 +1,4 @@
-import React, {useCallback, useState} from 'react'
+import React, {useCallback, useMemo, useState} from 'react'
 import {View} from 'react-native'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
@@ -20,10 +20,11 @@ import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '
 import {Link} from '#/components/Link'
 import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
 import {Text} from '#/components/Typography'
+import {NewChat} from '../../../components/dms/NewChat'
 import {ClipClopGate} from '../gate'
 
 type Props = NativeStackScreenProps<MessagesTabNavigatorParams, 'MessagesList'>
-export function MessagesListScreen({}: Props) {
+export function MessagesListScreen({navigation}: Props) {
   const {_} = useLingui()
   const t = useTheme()
 
@@ -53,14 +54,14 @@ export function MessagesListScreen({}: Props) {
 
   const isError = !!error
 
-  const conversations = React.useMemo(() => {
+  const conversations = useMemo(() => {
     if (data?.pages) {
       return data.pages.flat()
     }
     return []
   }, [data])
 
-  const onRefresh = React.useCallback(async () => {
+  const onRefresh = useCallback(async () => {
     setIsPTRing(true)
     try {
       await refetch()
@@ -70,7 +71,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,26 +80,35 @@ export function MessagesListScreen({}: Props) {
     }
   }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage])
 
+  const onNewChat = useCallback(
+    (conversation: string) =>
+      navigation.navigate('MessagesConversation', {conversation}),
+    [navigation],
+  )
+
   const gate = useGate()
   if (!gate('dms')) return <ClipClopGate />
 
   if (conversations.length < 1) {
     return (
-      <ListMaybePlaceholder
-        isLoading={isLoading}
-        isError={isError}
-        emptyType="results"
-        emptyMessage={_(
-          msg`You have no messages yet. Start a conversation with someone!`,
-        )}
-        errorMessage={cleanError(error)}
-        onRetry={isError ? refetch : undefined}
-      />
+      <>
+        <ListMaybePlaceholder
+          isLoading={isLoading}
+          isError={isError}
+          emptyType="results"
+          emptyMessage={_(
+            msg`You have no messages yet. Start a conversation with someone!`,
+          )}
+          errorMessage={cleanError(error)}
+          onRetry={isError ? refetch : undefined}
+        />
+        <NewChat onNewChat={onNewChat} />
+      </>
     )
   }
 
   return (
-    <View>
+    <View style={a.flex_1}>
       <ViewHeader
         title={_(msg`Messages`)}
         showOnDesktop
@@ -106,6 +116,7 @@ export function MessagesListScreen({}: Props) {
         showBorder
         canGoBack={false}
       />
+      <NewChat onNewChat={onNewChat} />
       <List
         data={conversations}
         renderItem={({item}) => {
diff --git a/src/screens/Messages/Temp/query/query.ts b/src/screens/Messages/Temp/query/query.ts
index 2477dc569..26f9e625f 100644
--- a/src/screens/Messages/Temp/query/query.ts
+++ b/src/screens/Messages/Temp/query/query.ts
@@ -1,20 +1,24 @@
 import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
 
-import {useSession} from 'state/session'
-import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
+import {useAgent} from '#/state/session'
 import * as TempDmChatDefs from '#/temp/dm/defs'
 import * as TempDmChatGetChat from '#/temp/dm/getChat'
+import * as TempDmChatGetChatForMembers from '#/temp/dm/getChatForMembers'
 import * as TempDmChatGetChatLog from '#/temp/dm/getChatLog'
 import * as TempDmChatGetChatMessages from '#/temp/dm/getChatMessages'
+import {useDmServiceUrlStorage} from '../useDmServiceUrlStorage'
 
 /**
  * TEMPORARY, PLEASE DO NOT JUDGE ME REACT QUERY OVERLORDS 🙏
  * (and do not try this at home)
  */
 
-function createHeaders(did: string) {
+const useHeaders = () => {
+  const {getAgent} = useAgent()
   return {
-    Authorization: did,
+    get Authorization() {
+      return getAgent().session!.did
+    },
   }
 }
 
@@ -27,10 +31,8 @@ type Chat = {
 
 export function useChat(chatId: string) {
   const queryClient = useQueryClient()
-
+  const headers = useHeaders()
   const {serviceUrl} = useDmServiceUrlStorage()
-  const {currentAccount} = useSession()
-  const did = currentAccount?.did ?? ''
 
   return useQuery({
     queryKey: ['chat', chatId],
@@ -44,7 +46,7 @@ export function useChat(chatId: string) {
       const messagesResponse = await fetch(
         `${serviceUrl}/xrpc/temp.dm.getChatMessages?chatId=${chatId}`,
         {
-          headers: createHeaders(did),
+          headers,
         },
       )
 
@@ -56,7 +58,7 @@ export function useChat(chatId: string) {
       const chatResponse = await fetch(
         `${serviceUrl}/xrpc/temp.dm.getChat?chatId=${chatId}`,
         {
-          headers: createHeaders(did),
+          headers,
         },
       )
 
@@ -90,10 +92,8 @@ export function createTempId() {
 
 export function useSendMessageMutation(chatId: string) {
   const queryClient = useQueryClient()
-
+  const headers = useHeaders()
   const {serviceUrl} = useDmServiceUrlStorage()
-  const {currentAccount} = useSession()
-  const did = currentAccount?.did ?? ''
 
   return useMutation<
     TempDmChatDefs.Message,
@@ -108,7 +108,7 @@ export function useSendMessageMutation(chatId: string) {
         {
           method: 'POST',
           headers: {
-            ...createHeaders(did),
+            ...headers,
             'Content-Type': 'application/json',
           },
           body: JSON.stringify({
@@ -130,8 +130,10 @@ export function useSendMessageMutation(chatId: string) {
           ...prev,
           messages: [
             {
+              $type: 'temp.dm.defs#messageView',
               id: variables.tempId,
               text: variables.message,
+              sender: {did: headers.Authorization}, // TODO a real DID get
             },
             ...prev.messages,
           ],
@@ -165,10 +167,8 @@ export function useSendMessageMutation(chatId: string) {
 
 export function useChatLogQuery() {
   const queryClient = useQueryClient()
-
+  const headers = useHeaders()
   const {serviceUrl} = useDmServiceUrlStorage()
-  const {currentAccount} = useSession()
-  const did = currentAccount?.did ?? ''
 
   return useQuery({
     queryKey: ['chatLog'],
@@ -183,7 +183,7 @@ export function useChatLogQuery() {
             prevLog?.cursor ?? ''
           }`,
           {
-            headers: createHeaders(did),
+            headers,
           },
         )
 
@@ -193,13 +193,10 @@ export function useChatLogQuery() {
           (await response.json()) as TempDmChatGetChatLog.OutputSchema
 
         for (const log of json.logs) {
-          if (TempDmChatDefs.isLogDeleteMessage(log)) {
+          if (TempDmChatDefs.isLogCreateMessage(log)) {
             queryClient.setQueryData(['chat', log.chatId], (prev: Chat) => {
-              // What to do in this case
-              if (!prev) return
-
-              // HACK we don't know who the creator of a message is, so just filter by id for now
-              if (prev.messages.find(m => m.id === log.message.id)) return prev
+              // TODO hack filter out duplicates
+              if (prev?.messages.find(m => m.id === log.message.id)) return
 
               return {
                 ...prev,
@@ -217,3 +214,39 @@ export function useChatLogQuery() {
     refetchInterval: 5000,
   })
 }
+
+export function useGetChatFromMembers({
+  onSuccess,
+  onError,
+}: {
+  onSuccess?: (data: TempDmChatGetChatForMembers.OutputSchema) => void
+  onError?: (error: Error) => void
+}) {
+  const queryClient = useQueryClient()
+  const headers = useHeaders()
+  const {serviceUrl} = useDmServiceUrlStorage()
+
+  return useMutation({
+    mutationFn: async (members: string[]) => {
+      const response = await fetch(
+        `${serviceUrl}/xrpc/temp.dm.getChatForMembers?members=${members.join(
+          ',',
+        )}`,
+        {headers},
+      )
+
+      if (!response.ok) throw new Error('Failed to fetch chat')
+
+      return (await response.json()) as TempDmChatGetChatForMembers.OutputSchema
+    },
+    onSuccess: data => {
+      queryClient.setQueryData(['chat', data.chat.id], {
+        chatId: data.chat.id,
+        messages: [],
+        lastRev: data.chat.rev,
+      })
+      onSuccess?.(data)
+    },
+    onError,
+  })
+}
diff --git a/src/view/screens/Settings/index.tsx b/src/view/screens/Settings/index.tsx
index 470bace87..a0e4ff60f 100644
--- a/src/view/screens/Settings/index.tsx
+++ b/src/view/screens/Settings/index.tsx
@@ -791,7 +791,7 @@ export function SettingsScreen({}: Props) {
             <TextField.Input
               value={dmServiceUrl}
               onChangeText={(text: string) => {
-                if (text.endsWith('/')) {
+                if (text.length > 9 && text.endsWith('/')) {
                   text = text.slice(0, -1)
                 }
                 setDmServiceUrl(text)