about summary refs log tree commit diff
path: root/src/screens/Messages
diff options
context:
space:
mode:
Diffstat (limited to 'src/screens/Messages')
-rw-r--r--src/screens/Messages/Conversation/MessageInput.tsx65
-rw-r--r--src/screens/Messages/Conversation/MessageItem.tsx29
-rw-r--r--src/screens/Messages/Conversation/MessagesList.tsx193
-rw-r--r--src/screens/Messages/Conversation/index.tsx10
-rw-r--r--src/screens/Messages/List/index.tsx2
-rw-r--r--src/screens/Messages/Temp/query/query.ts219
-rw-r--r--src/screens/Messages/Temp/useDmServiceUrlStorage.tsx64
7 files changed, 577 insertions, 5 deletions
diff --git a/src/screens/Messages/Conversation/MessageInput.tsx b/src/screens/Messages/Conversation/MessageInput.tsx
new file mode 100644
index 000000000..bd73594ce
--- /dev/null
+++ b/src/screens/Messages/Conversation/MessageInput.tsx
@@ -0,0 +1,65 @@
+import React from 'react'
+import {Pressable, TextInput, View} from 'react-native'
+
+import {atoms as a, useTheme} from '#/alf'
+import {Text} from '#/components/Typography'
+
+export function MessageInput({
+  onSendMessage,
+  onFocus,
+  onBlur,
+}: {
+  onSendMessage: (message: string) => void
+  onFocus: () => void
+  onBlur: () => void
+}) {
+  const t = useTheme()
+  const [message, setMessage] = React.useState('')
+
+  const inputRef = React.useRef<TextInput>(null)
+
+  const onSubmit = React.useCallback(() => {
+    onSendMessage(message)
+    setMessage('')
+    setTimeout(() => {
+      inputRef.current?.focus()
+    }, 100)
+  }, [message, onSendMessage])
+
+  return (
+    <View
+      style={[
+        a.flex_row,
+        a.py_sm,
+        a.px_sm,
+        a.rounded_full,
+        a.mt_sm,
+        t.atoms.bg_contrast_25,
+      ]}>
+      <TextInput
+        accessibilityLabel="Text input field"
+        accessibilityHint="Write a message"
+        value={message}
+        onChangeText={setMessage}
+        placeholder="Write a message"
+        style={[a.flex_1, a.text_sm, a.px_sm]}
+        onSubmitEditing={onSubmit}
+        onFocus={onFocus}
+        onBlur={onBlur}
+        placeholderTextColor={t.palette.contrast_500}
+        ref={inputRef}
+      />
+      <Pressable
+        accessibilityRole="button"
+        style={[
+          a.rounded_full,
+          a.align_center,
+          a.justify_center,
+          {height: 30, width: 30, backgroundColor: t.palette.primary_500},
+        ]}
+        onPress={onSubmit}>
+        <Text style={a.text_md}>🐴</Text>
+      </Pressable>
+    </View>
+  )
+}
diff --git a/src/screens/Messages/Conversation/MessageItem.tsx b/src/screens/Messages/Conversation/MessageItem.tsx
new file mode 100644
index 000000000..74e65488e
--- /dev/null
+++ b/src/screens/Messages/Conversation/MessageItem.tsx
@@ -0,0 +1,29 @@
+import React from 'react'
+import {View} from 'react-native'
+
+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()
+
+  return (
+    <View
+      style={[
+        a.py_sm,
+        a.px_md,
+        a.my_xs,
+        a.rounded_md,
+        {
+          backgroundColor: t.palette.primary_500,
+          maxWidth: '65%',
+          borderRadius: 17,
+        },
+      ]}>
+      <Text style={[a.text_md, {lineHeight: 1.2, color: 'white'}]}>
+        {item.text}
+      </Text>
+    </View>
+  )
+}
diff --git a/src/screens/Messages/Conversation/MessagesList.tsx b/src/screens/Messages/Conversation/MessagesList.tsx
new file mode 100644
index 000000000..aafed42af
--- /dev/null
+++ b/src/screens/Messages/Conversation/MessagesList.tsx
@@ -0,0 +1,193 @@
+import React, {useCallback, useMemo, useRef, useState} from 'react'
+import {Alert, FlatList, View, ViewToken} from 'react-native'
+import {KeyboardAvoidingView} from 'react-native-keyboard-controller'
+
+import {isWeb} from 'platform/detection'
+import {MessageInput} from '#/screens/Messages/Conversation/MessageInput'
+import {MessageItem} from '#/screens/Messages/Conversation/MessageItem'
+import {
+  useChat,
+  useChatLogQuery,
+  useSendMessageMutation,
+} from '#/screens/Messages/Temp/query/query'
+import {Loader} from '#/components/Loader'
+import {Text} from '#/components/Typography'
+import * as TempDmChatDefs from '#/temp/dm/defs'
+
+function MaybeLoader({isLoading}: {isLoading: boolean}) {
+  return (
+    <View
+      style={{
+        height: 50,
+        width: '100%',
+        alignItems: 'center',
+        justifyContent: 'center',
+      }}>
+      {isLoading && <Loader size="xl" />}
+    </View>
+  )
+}
+
+function renderItem({
+  item,
+}: {
+  item: TempDmChatDefs.MessageView | TempDmChatDefs.DeletedMessage
+}) {
+  if (TempDmChatDefs.isMessageView(item)) return <MessageItem item={item} />
+
+  if (TempDmChatDefs.isDeletedMessage(item)) return <Text>Deleted message</Text>
+
+  return null
+}
+
+// TODO rm
+// TEMP: This is a temporary function to generate unique keys for mutation placeholders
+const generateUniqueKey = () => `_${Math.random().toString(36).substr(2, 9)}`
+
+function onScrollToEndFailed() {
+  // Placeholder function. You have to give FlatList something or else it will error.
+}
+
+export function MessagesList({chatId}: {chatId: string}) {
+  const flatListRef = useRef<FlatList>(null)
+
+  // Whenever we reach the end (visually the top), we don't want to keep calling it. We will set `isFetching` to true
+  // once the request for new posts starts. Then, we will change it back to false after the content size changes.
+  const isFetching = useRef(false)
+
+  // We use this to know if we should scroll after a new clop is added to the list
+  const isAtBottom = useRef(false)
+
+  // Because the viewableItemsChanged callback won't have access to the updated state, we use a ref to store the
+  // total number of clops
+  // TODO this needs to be set to whatever the initial number of messages is
+  const totalMessages = useRef(10)
+
+  // TODO later
+  const [_, setShowSpinner] = useState(false)
+
+  // Query Data
+  const {data: chat} = useChat(chatId)
+  const {mutate: sendMessage} = useSendMessageMutation(chatId)
+  useChatLogQuery()
+
+  const [onViewableItemsChanged, viewabilityConfig] = useMemo(() => {
+    return [
+      (info: {viewableItems: Array<ViewToken>; changed: Array<ViewToken>}) => {
+        const firstVisibleIndex = info.viewableItems[0]?.index
+
+        isAtBottom.current = Number(firstVisibleIndex) < 2
+      },
+      {
+        itemVisiblePercentThreshold: 50,
+        minimumViewTime: 10,
+      },
+    ]
+  }, [])
+
+  const onContentSizeChange = useCallback(() => {
+    if (isAtBottom.current) {
+      flatListRef.current?.scrollToOffset({offset: 0, animated: true})
+    }
+
+    isFetching.current = false
+    setShowSpinner(false)
+  }, [])
+
+  const onEndReached = useCallback(() => {
+    if (isFetching.current) return
+    isFetching.current = true
+    setShowSpinner(true)
+
+    // Eventually we will add more here when we hit the top through RQuery
+    // We wouldn't actually use a timeout, but there would be a delay while loading
+    setTimeout(() => {
+      // Do something
+      setShowSpinner(false)
+    }, 1000)
+  }, [])
+
+  const onInputFocus = useCallback(() => {
+    if (!isAtBottom.current) {
+      flatListRef.current?.scrollToOffset({offset: 0, animated: true})
+    }
+  }, [])
+
+  const onSendMessage = useCallback(
+    async (message: string) => {
+      if (!message) return
+
+      try {
+        sendMessage({
+          message,
+          tempId: generateUniqueKey(),
+        })
+      } catch (e: any) {
+        Alert.alert(e.toString())
+      }
+    },
+    [sendMessage],
+  )
+
+  const onInputBlur = useCallback(() => {}, [])
+
+  const messages = useMemo(() => {
+    if (!chat) return []
+
+    const filtered = chat.messages.filter(
+      (
+        message,
+      ): message is
+        | TempDmChatDefs.MessageView
+        | TempDmChatDefs.DeletedMessage => {
+        return (
+          TempDmChatDefs.isMessageView(message) ||
+          TempDmChatDefs.isDeletedMessage(message)
+        )
+      },
+    )
+    totalMessages.current = filtered.length
+  }, [chat])
+
+  return (
+    <KeyboardAvoidingView
+      style={{flex: 1, marginBottom: isWeb ? 20 : 85}}
+      behavior="padding"
+      keyboardVerticalOffset={70}
+      contentContainerStyle={{flex: 1}}>
+      <FlatList
+        data={messages}
+        keyExtractor={item => item.id}
+        renderItem={renderItem}
+        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
+        initialNumToRender={20}
+        // Same with the max to render per batch. Let's be safe for now though.
+        maxToRenderPerBatch={25}
+        inverted={true}
+        onEndReached={onEndReached}
+        onScrollToIndexFailed={onScrollToEndFailed}
+        onContentSizeChange={onContentSizeChange}
+        onViewableItemsChanged={onViewableItemsChanged}
+        viewabilityConfig={viewabilityConfig}
+        maintainVisibleContentPosition={{
+          minIndexForVisible: 0,
+        }}
+        // This is actually a header since we are inverted!
+        ListFooterComponent={<MaybeLoader isLoading={false} />}
+        removeClippedSubviews={true}
+        ref={flatListRef}
+        keyboardDismissMode="none"
+      />
+      <View style={{paddingHorizontal: 10}}>
+        <MessageInput
+          onSendMessage={onSendMessage}
+          onFocus={onInputFocus}
+          onBlur={onInputBlur}
+        />
+      </View>
+    </KeyboardAvoidingView>
+  )
+}
diff --git a/src/screens/Messages/Conversation/index.tsx b/src/screens/Messages/Conversation/index.tsx
index 239425a2f..efa64f5f8 100644
--- a/src/screens/Messages/Conversation/index.tsx
+++ b/src/screens/Messages/Conversation/index.tsx
@@ -1,5 +1,4 @@
 import React from 'react'
-import {View} from 'react-native'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {NativeStackScreenProps} from '@react-navigation/native-stack'
@@ -7,6 +6,8 @@ import {NativeStackScreenProps} from '@react-navigation/native-stack'
 import {CommonNavigatorParams} from '#/lib/routes/types'
 import {useGate} from '#/lib/statsig/statsig'
 import {ViewHeader} from '#/view/com/util/ViewHeader'
+import {CenteredView} from 'view/com/util/Views'
+import {MessagesList} from '#/screens/Messages/Conversation/MessagesList'
 import {ClipClopGate} from '../gate'
 
 type Props = NativeStackScreenProps<
@@ -16,17 +17,18 @@ type Props = NativeStackScreenProps<
 export function MessagesConversationScreen({route}: Props) {
   const chatId = route.params.conversation
   const {_} = useLingui()
-
   const gate = useGate()
+
   if (!gate('dms')) return <ClipClopGate />
 
   return (
-    <View>
+    <CenteredView style={{flex: 1}} sideBorders>
       <ViewHeader
         title={_(msg`Chat with ${chatId}`)}
         showOnDesktop
         showBorder
       />
-    </View>
+      <MessagesList chatId={chatId} />
+    </CenteredView>
   )
 }
diff --git a/src/screens/Messages/List/index.tsx b/src/screens/Messages/List/index.tsx
index c4490aa5c..b13ddd291 100644
--- a/src/screens/Messages/List/index.tsx
+++ b/src/screens/Messages/List/index.tsx
@@ -111,7 +111,7 @@ export function MessagesListScreen({}: Props) {
         renderItem={({item}) => {
           return (
             <Link
-              to={`/messages/${item.profile.handle}`}
+              to={`/messages/3kqzb4mytxk2v`}
               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]}>
diff --git a/src/screens/Messages/Temp/query/query.ts b/src/screens/Messages/Temp/query/query.ts
new file mode 100644
index 000000000..2477dc569
--- /dev/null
+++ b/src/screens/Messages/Temp/query/query.ts
@@ -0,0 +1,219 @@
+import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
+
+import {useSession} from 'state/session'
+import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
+import * as TempDmChatDefs from '#/temp/dm/defs'
+import * as TempDmChatGetChat from '#/temp/dm/getChat'
+import * as TempDmChatGetChatLog from '#/temp/dm/getChatLog'
+import * as TempDmChatGetChatMessages from '#/temp/dm/getChatMessages'
+
+/**
+ * TEMPORARY, PLEASE DO NOT JUDGE ME REACT QUERY OVERLORDS 🙏
+ * (and do not try this at home)
+ */
+
+function createHeaders(did: string) {
+  return {
+    Authorization: did,
+  }
+}
+
+type Chat = {
+  chatId: string
+  messages: TempDmChatGetChatMessages.OutputSchema['messages']
+  lastCursor?: string
+  lastRev?: string
+}
+
+export function useChat(chatId: string) {
+  const queryClient = useQueryClient()
+
+  const {serviceUrl} = useDmServiceUrlStorage()
+  const {currentAccount} = useSession()
+  const did = currentAccount?.did ?? ''
+
+  return useQuery({
+    queryKey: ['chat', chatId],
+    queryFn: async () => {
+      const currentChat = queryClient.getQueryData(['chat', chatId])
+
+      if (currentChat) {
+        return currentChat as Chat
+      }
+
+      const messagesResponse = await fetch(
+        `${serviceUrl}/xrpc/temp.dm.getChatMessages?chatId=${chatId}`,
+        {
+          headers: createHeaders(did),
+        },
+      )
+
+      if (!messagesResponse.ok) throw new Error('Failed to fetch messages')
+
+      const messagesJson =
+        (await messagesResponse.json()) as TempDmChatGetChatMessages.OutputSchema
+
+      const chatResponse = await fetch(
+        `${serviceUrl}/xrpc/temp.dm.getChat?chatId=${chatId}`,
+        {
+          headers: createHeaders(did),
+        },
+      )
+
+      if (!chatResponse.ok) throw new Error('Failed to fetch chat')
+
+      const chatJson =
+        (await chatResponse.json()) as TempDmChatGetChat.OutputSchema
+
+      const newChat = {
+        chatId,
+        messages: messagesJson.messages,
+        lastCursor: messagesJson.cursor,
+        lastRev: chatJson.chat.rev,
+      } satisfies Chat
+
+      queryClient.setQueryData(['chat', chatId], newChat)
+
+      return newChat
+    },
+  })
+}
+
+interface SendMessageMutationVariables {
+  message: string
+  tempId: string
+}
+
+export function createTempId() {
+  return Math.random().toString(36).substring(7).toString()
+}
+
+export function useSendMessageMutation(chatId: string) {
+  const queryClient = useQueryClient()
+
+  const {serviceUrl} = useDmServiceUrlStorage()
+  const {currentAccount} = useSession()
+  const did = currentAccount?.did ?? ''
+
+  return useMutation<
+    TempDmChatDefs.Message,
+    Error,
+    SendMessageMutationVariables,
+    unknown
+  >({
+    // eslint-disable-next-line @typescript-eslint/no-unused-vars
+    mutationFn: async ({message, tempId}) => {
+      const response = await fetch(
+        `${serviceUrl}/xrpc/temp.dm.sendMessage?chatId=${chatId}`,
+        {
+          method: 'POST',
+          headers: {
+            ...createHeaders(did),
+            'Content-Type': 'application/json',
+          },
+          body: JSON.stringify({
+            chatId,
+            message: {
+              text: message,
+            },
+          }),
+        },
+      )
+
+      if (!response.ok) throw new Error('Failed to send message')
+
+      return response.json()
+    },
+    onMutate: async variables => {
+      queryClient.setQueryData(['chat', chatId], (prev: Chat) => {
+        return {
+          ...prev,
+          messages: [
+            {
+              id: variables.tempId,
+              text: variables.message,
+            },
+            ...prev.messages,
+          ],
+        }
+      })
+    },
+    onSuccess: (result, variables) => {
+      queryClient.setQueryData(['chat', chatId], (prev: Chat) => {
+        return {
+          ...prev,
+          messages: prev.messages.map(m =>
+            m.id === variables.tempId
+              ? {
+                  ...m,
+                  id: result.id,
+                }
+              : m,
+          ),
+        }
+      })
+    },
+    onError: (_, variables) => {
+      console.log(_)
+      queryClient.setQueryData(['chat', chatId], (prev: Chat) => ({
+        ...prev,
+        messages: prev.messages.filter(m => m.id !== variables.tempId),
+      }))
+    },
+  })
+}
+
+export function useChatLogQuery() {
+  const queryClient = useQueryClient()
+
+  const {serviceUrl} = useDmServiceUrlStorage()
+  const {currentAccount} = useSession()
+  const did = currentAccount?.did ?? ''
+
+  return useQuery({
+    queryKey: ['chatLog'],
+    queryFn: async () => {
+      const prevLog = queryClient.getQueryData([
+        'chatLog',
+      ]) as TempDmChatGetChatLog.OutputSchema
+
+      try {
+        const response = await fetch(
+          `${serviceUrl}/xrpc/temp.dm.getChatLog?cursor=${
+            prevLog?.cursor ?? ''
+          }`,
+          {
+            headers: createHeaders(did),
+          },
+        )
+
+        if (!response.ok) throw new Error('Failed to fetch chat log')
+
+        const json =
+          (await response.json()) as TempDmChatGetChatLog.OutputSchema
+
+        for (const log of json.logs) {
+          if (TempDmChatDefs.isLogDeleteMessage(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
+
+              return {
+                ...prev,
+                messages: [log.message, ...prev.messages],
+              }
+            })
+          }
+        }
+
+        return json
+      } catch (e) {
+        console.log(e)
+      }
+    },
+    refetchInterval: 5000,
+  })
+}
diff --git a/src/screens/Messages/Temp/useDmServiceUrlStorage.tsx b/src/screens/Messages/Temp/useDmServiceUrlStorage.tsx
new file mode 100644
index 000000000..3679858f4
--- /dev/null
+++ b/src/screens/Messages/Temp/useDmServiceUrlStorage.tsx
@@ -0,0 +1,64 @@
+import React from 'react'
+import {useAsyncStorage} from '@react-native-async-storage/async-storage'
+
+/**
+ * TEMP: REMOVE BEFORE RELEASE
+ *
+ * Clip clop trivia:
+ *
+ * A little known fact about the term "clip clop" is that it may refer to a unit of time. It is unknown what the exact
+ * length of a clip clop is, but it is generally agreed that it is approximately 9 minutes and 30 seconds, or 570
+ * seconds.
+ *
+ * The term "clip clop" may also be used in other contexts, although it is unknown what all of these contexts may be.
+ * Recently, the term has been used among many young adults to refer to a type of social media functionality, although
+ * the exact nature of this functionality is also unknown. It is believed that the term may have originated from a
+ * popular video game, but this has not been confirmed.
+ *
+ */
+
+const DmServiceUrlStorageContext = React.createContext<{
+  serviceUrl: string
+  setServiceUrl: (value: string) => void
+}>({
+  serviceUrl: '',
+  setServiceUrl: () => {},
+})
+
+export const useDmServiceUrlStorage = () =>
+  React.useContext(DmServiceUrlStorageContext)
+
+export function DmServiceUrlProvider({children}: {children: React.ReactNode}) {
+  const [serviceUrl, setServiceUrl] = React.useState<string>('')
+  const {getItem, setItem: setItemInner} = useAsyncStorage('dmServiceUrl')
+
+  React.useEffect(() => {
+    ;(async () => {
+      const v = await getItem()
+      console.log(v)
+      setServiceUrl(v ?? '')
+    })()
+  }, [getItem])
+
+  const setItem = React.useCallback(
+    (v: string) => {
+      setItemInner(v)
+      setServiceUrl(v)
+    },
+    [setItemInner],
+  )
+
+  const value = React.useMemo(
+    () => ({
+      serviceUrl,
+      setServiceUrl: setItem,
+    }),
+    [serviceUrl, setItem],
+  )
+
+  return (
+    <DmServiceUrlStorageContext.Provider value={value}>
+      {children}
+    </DmServiceUrlStorageContext.Provider>
+  )
+}