about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorHailey <me@haileyok.com>2024-04-29 23:34:26 -0700
committerGitHub <noreply@github.com>2024-04-29 23:34:26 -0700
commiteb8bfd11d14a87983b210bea4a619d2dd7c51cdf (patch)
tree6f322f86ba92e989570daa1ada94ad5f10258506 /src
parent5d19f27052ebd3642db58742f7efaaee0b3a6720 (diff)
downloadvoidsky-eb8bfd11d14a87983b210bea4a619d2dd7c51cdf.tar.zst
[Clipclops] Add screen to view and send clip clops (#3754)
* add new routes with placeholder screens

* add clops list

* add a clop input

* add some better padding to the clops

* some more adjustments

* add rnkc

* implement rnkc

* implement rnkc

* be a little less weird about it

* rename clop stuff

* rename more clop

* one more

* [Clipclops] Temp codegenerated lexicon (#3749)

* add codegenerated lexicon

* replace hailey's types

* use codegen'd types in components

* fix error + throw if fetch failed

* remove bad imports

* update messageslist and messageitem

* import useState

* add clop service URL hook

* add dm service url storage

* use context

* use context for service url (temp)

* remove log

* nits

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Diffstat (limited to 'src')
-rw-r--r--src/App.native.tsx5
-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
-rw-r--r--src/state/preferences/index.tsx5
-rw-r--r--src/temp/dm/defs.ts195
-rw-r--r--src/temp/dm/deleteMessage.ts31
-rw-r--r--src/temp/dm/getChat.ts30
-rw-r--r--src/temp/dm/getChatForMembers.ts30
-rw-r--r--src/temp/dm/getChatLog.ts36
-rw-r--r--src/temp/dm/getChatMessages.ts37
-rw-r--r--src/temp/dm/getUserSettings.ts28
-rw-r--r--src/temp/dm/leaveChat.ts30
-rw-r--r--src/temp/dm/listChats.ts32
-rw-r--r--src/temp/dm/muteChat.ts30
-rw-r--r--src/temp/dm/sendMessage.ts31
-rw-r--r--src/temp/dm/sendMessageBatch.ts66
-rw-r--r--src/temp/dm/unmuteChat.ts30
-rw-r--r--src/temp/dm/updateChatRead.ts31
-rw-r--r--src/temp/dm/updateUserSettings.ts33
-rw-r--r--src/view/screens/Settings/index.tsx24
-rw-r--r--src/view/shell/bottom-bar/BottomBarWeb.tsx10
26 files changed, 1289 insertions, 7 deletions
diff --git a/src/App.native.tsx b/src/App.native.tsx
index cf96781b7..4cb963fe8 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -4,6 +4,7 @@ import 'view/icons'
 
 import React, {useEffect, useState} from 'react'
 import {GestureHandlerRootView} from 'react-native-gesture-handler'
+import {KeyboardProvider} from 'react-native-keyboard-controller'
 import {RootSiblingParent} from 'react-native-root-siblings'
 import {
   initialWindowMetrics,
@@ -137,7 +138,9 @@ function App() {
                   <LightboxStateProvider>
                     <I18nProvider>
                       <PortalProvider>
-                        <InnerApp />
+                        <KeyboardProvider>
+                          <InnerApp />
+                        </KeyboardProvider>
                       </PortalProvider>
                     </I18nProvider>
                   </LightboxStateProvider>
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>
+  )
+}
diff --git a/src/state/preferences/index.tsx b/src/state/preferences/index.tsx
index 5c8fab2ad..820358518 100644
--- a/src/state/preferences/index.tsx
+++ b/src/state/preferences/index.tsx
@@ -1,5 +1,6 @@
 import React from 'react'
 
+import {DmServiceUrlProvider} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
 import {Provider as AltTextRequiredProvider} from './alt-text-required'
 import {Provider as AutoplayProvider} from './autoplay'
 import {Provider as DisableHapticsProvider} from './disable-haptics'
@@ -30,7 +31,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
           <HiddenPostsProvider>
             <InAppBrowserProvider>
               <DisableHapticsProvider>
-                <AutoplayProvider>{children}</AutoplayProvider>
+                <AutoplayProvider>
+                  <DmServiceUrlProvider>{children}</DmServiceUrlProvider>
+                </AutoplayProvider>
               </DisableHapticsProvider>
             </InAppBrowserProvider>
           </HiddenPostsProvider>
diff --git a/src/temp/dm/defs.ts b/src/temp/dm/defs.ts
new file mode 100644
index 000000000..91f68365c
--- /dev/null
+++ b/src/temp/dm/defs.ts
@@ -0,0 +1,195 @@
+import {
+  AppBskyActorDefs,
+  AppBskyEmbedRecord,
+  AppBskyRichtextFacet,
+} from '@atproto/api'
+import {ValidationResult} from '@atproto/lexicon'
+
+export interface Message {
+  id?: string
+  text: string
+  /** Annotations of text (mentions, URLs, hashtags, etc) */
+  facets?: AppBskyRichtextFacet.Main[]
+  embed?: AppBskyEmbedRecord.Main | {$type: string; [k: string]: unknown}
+  [k: string]: unknown
+}
+
+export function isMessage(v: unknown): v is Message {
+  return isObj(v) && hasProp(v, '$type') && v.$type === 'temp.dm.defs#message'
+}
+
+export function validateMessage(v: unknown): ValidationResult {
+  return {
+    success: true,
+    value: v,
+  }
+}
+
+export interface MessageView {
+  id: string
+  rev: string
+  text: string
+  /** Annotations of text (mentions, URLs, hashtags, etc) */
+  facets?: AppBskyRichtextFacet.Main[]
+  embed?: AppBskyEmbedRecord.Main | {$type: string; [k: string]: unknown}
+  sender?: MessageViewSender
+  sentAt: string
+  [k: string]: unknown
+}
+
+export function isMessageView(v: unknown): v is MessageView {
+  return (
+    isObj(v) && hasProp(v, '$type') && v.$type === 'temp.dm.defs#messageView'
+  )
+}
+
+export function validateMessageView(v: unknown): ValidationResult {
+  return {
+    success: true,
+    value: v,
+  }
+}
+
+export interface DeletedMessage {
+  id: string
+  rev?: string
+  sender?: MessageViewSender
+  sentAt: string
+  [k: string]: unknown
+}
+
+export function isDeletedMessage(v: unknown): v is DeletedMessage {
+  return (
+    isObj(v) && hasProp(v, '$type') && v.$type === 'temp.dm.defs#deletedMessage'
+  )
+}
+
+export function validateDeletedMessage(v: unknown): ValidationResult {
+  return {
+    success: true,
+    value: v,
+  }
+}
+
+export interface MessageViewSender {
+  did: string
+  [k: string]: unknown
+}
+
+export function isMessageViewSender(v: unknown): v is MessageViewSender {
+  return (
+    isObj(v) &&
+    hasProp(v, '$type') &&
+    v.$type === 'temp.dm.defs#messageViewSender'
+  )
+}
+
+export function validateMessageViewSender(v: unknown): ValidationResult {
+  return {
+    success: true,
+    value: v,
+  }
+}
+
+export interface ChatView {
+  id: string
+  rev: string
+  members: AppBskyActorDefs.ProfileViewBasic[]
+  lastMessage?:
+    | MessageView
+    | DeletedMessage
+    | {$type: string; [k: string]: unknown}
+  unreadCount: number
+  [k: string]: unknown
+}
+
+export function isChatView(v: unknown): v is ChatView {
+  return isObj(v) && hasProp(v, '$type') && v.$type === 'temp.dm.defs#chatView'
+}
+
+export function validateChatView(v: unknown): ValidationResult {
+  return {
+    success: true,
+    value: v,
+  }
+}
+
+export type IncomingMessageSetting =
+  | 'all'
+  | 'none'
+  | 'following'
+  | (string & {})
+
+export interface LogBeginChat {
+  rev: string
+  chatId: string
+  [k: string]: unknown
+}
+
+export function isLogBeginChat(v: unknown): v is LogBeginChat {
+  return (
+    isObj(v) && hasProp(v, '$type') && v.$type === 'temp.dm.defs#logBeginChat'
+  )
+}
+
+export function validateLogBeginChat(v: unknown): ValidationResult {
+  return {
+    success: true,
+    value: v,
+  }
+}
+
+export interface LogCreateMessage {
+  rev: string
+  chatId: string
+  message: MessageView | DeletedMessage | {$type: string; [k: string]: unknown}
+  [k: string]: unknown
+}
+
+export function isLogCreateMessage(v: unknown): v is LogCreateMessage {
+  return (
+    isObj(v) &&
+    hasProp(v, '$type') &&
+    v.$type === 'temp.dm.defs#logCreateMessage'
+  )
+}
+
+export function validateLogCreateMessage(v: unknown): ValidationResult {
+  return {
+    success: true,
+    value: v,
+  }
+}
+
+export interface LogDeleteMessage {
+  rev: string
+  chatId: string
+  message: MessageView | DeletedMessage | {$type: string; [k: string]: unknown}
+  [k: string]: unknown
+}
+
+export function isLogDeleteMessage(v: unknown): v is LogDeleteMessage {
+  return (
+    isObj(v) &&
+    hasProp(v, '$type') &&
+    v.$type === 'temp.dm.defs#logDeleteMessage'
+  )
+}
+
+export function validateLogDeleteMessage(v: unknown): ValidationResult {
+  return {
+    success: true,
+    value: v,
+  }
+}
+
+export function isObj(v: unknown): v is Record<string, unknown> {
+  return typeof v === 'object' && v !== null
+}
+
+export function hasProp<K extends PropertyKey>(
+  data: object,
+  prop: K,
+): data is Record<K, unknown> {
+  return prop in data
+}
diff --git a/src/temp/dm/deleteMessage.ts b/src/temp/dm/deleteMessage.ts
new file mode 100644
index 000000000..d9fa1f9cf
--- /dev/null
+++ b/src/temp/dm/deleteMessage.ts
@@ -0,0 +1,31 @@
+import {Headers, XRPCError} from '@atproto/xrpc'
+
+import * as TempDmDefs from './defs'
+
+export interface QueryParams {}
+
+export interface InputSchema {
+  chatId: string
+  messageId: string
+  [k: string]: unknown
+}
+
+export type OutputSchema = TempDmDefs.DeletedMessage
+
+export interface CallOptions {
+  headers?: Headers
+  qp?: QueryParams
+  encoding: 'application/json'
+}
+
+export interface Response {
+  success: boolean
+  headers: Headers
+  data: OutputSchema
+}
+
+export function toKnownErr(e: any) {
+  if (e instanceof XRPCError) {
+  }
+  return e
+}
diff --git a/src/temp/dm/getChat.ts b/src/temp/dm/getChat.ts
new file mode 100644
index 000000000..d0a7b891c
--- /dev/null
+++ b/src/temp/dm/getChat.ts
@@ -0,0 +1,30 @@
+import {Headers, XRPCError} from '@atproto/xrpc'
+
+import * as TempDmDefs from './defs'
+
+export interface QueryParams {
+  chatId: string
+}
+
+export type InputSchema = undefined
+
+export interface OutputSchema {
+  chat: TempDmDefs.ChatView
+  [k: string]: unknown
+}
+
+export interface CallOptions {
+  headers?: Headers
+}
+
+export interface Response {
+  success: boolean
+  headers: Headers
+  data: OutputSchema
+}
+
+export function toKnownErr(e: any) {
+  if (e instanceof XRPCError) {
+  }
+  return e
+}
diff --git a/src/temp/dm/getChatForMembers.ts b/src/temp/dm/getChatForMembers.ts
new file mode 100644
index 000000000..0c9962c8b
--- /dev/null
+++ b/src/temp/dm/getChatForMembers.ts
@@ -0,0 +1,30 @@
+import {Headers, XRPCError} from '@atproto/xrpc'
+
+import * as TempDmDefs from './defs'
+
+export interface QueryParams {
+  members: string[]
+}
+
+export type InputSchema = undefined
+
+export interface OutputSchema {
+  chat: TempDmDefs.ChatView
+  [k: string]: unknown
+}
+
+export interface CallOptions {
+  headers?: Headers
+}
+
+export interface Response {
+  success: boolean
+  headers: Headers
+  data: OutputSchema
+}
+
+export function toKnownErr(e: any) {
+  if (e instanceof XRPCError) {
+  }
+  return e
+}
diff --git a/src/temp/dm/getChatLog.ts b/src/temp/dm/getChatLog.ts
new file mode 100644
index 000000000..9d310d908
--- /dev/null
+++ b/src/temp/dm/getChatLog.ts
@@ -0,0 +1,36 @@
+import {Headers, XRPCError} from '@atproto/xrpc'
+
+import * as TempDmDefs from './defs'
+
+export interface QueryParams {
+  cursor?: string
+}
+
+export type InputSchema = undefined
+
+export interface OutputSchema {
+  cursor?: string
+  logs: (
+    | TempDmDefs.LogBeginChat
+    | TempDmDefs.LogCreateMessage
+    | TempDmDefs.LogDeleteMessage
+    | {$type: string; [k: string]: unknown}
+  )[]
+  [k: string]: unknown
+}
+
+export interface CallOptions {
+  headers?: Headers
+}
+
+export interface Response {
+  success: boolean
+  headers: Headers
+  data: OutputSchema
+}
+
+export function toKnownErr(e: any) {
+  if (e instanceof XRPCError) {
+  }
+  return e
+}
diff --git a/src/temp/dm/getChatMessages.ts b/src/temp/dm/getChatMessages.ts
new file mode 100644
index 000000000..54ae21910
--- /dev/null
+++ b/src/temp/dm/getChatMessages.ts
@@ -0,0 +1,37 @@
+import {Headers, XRPCError} from '@atproto/xrpc'
+
+import * as TempDmDefs from './defs'
+
+export interface QueryParams {
+  chatId: string
+  limit?: number
+  cursor?: string
+}
+
+export type InputSchema = undefined
+
+export interface OutputSchema {
+  cursor?: string
+  messages: (
+    | TempDmDefs.MessageView
+    | TempDmDefs.DeletedMessage
+    | {$type: string; [k: string]: unknown}
+  )[]
+  [k: string]: unknown
+}
+
+export interface CallOptions {
+  headers?: Headers
+}
+
+export interface Response {
+  success: boolean
+  headers: Headers
+  data: OutputSchema
+}
+
+export function toKnownErr(e: any) {
+  if (e instanceof XRPCError) {
+  }
+  return e
+}
diff --git a/src/temp/dm/getUserSettings.ts b/src/temp/dm/getUserSettings.ts
new file mode 100644
index 000000000..792c697b4
--- /dev/null
+++ b/src/temp/dm/getUserSettings.ts
@@ -0,0 +1,28 @@
+import {Headers, XRPCError} from '@atproto/xrpc'
+
+import * as TempDmDefs from './defs'
+
+export interface QueryParams {}
+
+export type InputSchema = undefined
+
+export interface OutputSchema {
+  allowIncoming: TempDmDefs.IncomingMessageSetting
+  [k: string]: unknown
+}
+
+export interface CallOptions {
+  headers?: Headers
+}
+
+export interface Response {
+  success: boolean
+  headers: Headers
+  data: OutputSchema
+}
+
+export function toKnownErr(e: any) {
+  if (e instanceof XRPCError) {
+  }
+  return e
+}
diff --git a/src/temp/dm/leaveChat.ts b/src/temp/dm/leaveChat.ts
new file mode 100644
index 000000000..e116f2775
--- /dev/null
+++ b/src/temp/dm/leaveChat.ts
@@ -0,0 +1,30 @@
+import {Headers, XRPCError} from '@atproto/xrpc'
+
+export interface QueryParams {}
+
+export interface InputSchema {
+  chatId: string
+  [k: string]: unknown
+}
+
+export interface OutputSchema {
+  [k: string]: unknown
+}
+
+export interface CallOptions {
+  headers?: Headers
+  qp?: QueryParams
+  encoding: 'application/json'
+}
+
+export interface Response {
+  success: boolean
+  headers: Headers
+  data: OutputSchema
+}
+
+export function toKnownErr(e: any) {
+  if (e instanceof XRPCError) {
+  }
+  return e
+}
diff --git a/src/temp/dm/listChats.ts b/src/temp/dm/listChats.ts
new file mode 100644
index 000000000..0f9cb0c6a
--- /dev/null
+++ b/src/temp/dm/listChats.ts
@@ -0,0 +1,32 @@
+import {Headers, XRPCError} from '@atproto/xrpc'
+
+import * as TempDmDefs from './defs'
+
+export interface QueryParams {
+  limit?: number
+  cursor?: string
+}
+
+export type InputSchema = undefined
+
+export interface OutputSchema {
+  cursor?: string
+  chats: TempDmDefs.ChatView[]
+  [k: string]: unknown
+}
+
+export interface CallOptions {
+  headers?: Headers
+}
+
+export interface Response {
+  success: boolean
+  headers: Headers
+  data: OutputSchema
+}
+
+export function toKnownErr(e: any) {
+  if (e instanceof XRPCError) {
+  }
+  return e
+}
diff --git a/src/temp/dm/muteChat.ts b/src/temp/dm/muteChat.ts
new file mode 100644
index 000000000..e116f2775
--- /dev/null
+++ b/src/temp/dm/muteChat.ts
@@ -0,0 +1,30 @@
+import {Headers, XRPCError} from '@atproto/xrpc'
+
+export interface QueryParams {}
+
+export interface InputSchema {
+  chatId: string
+  [k: string]: unknown
+}
+
+export interface OutputSchema {
+  [k: string]: unknown
+}
+
+export interface CallOptions {
+  headers?: Headers
+  qp?: QueryParams
+  encoding: 'application/json'
+}
+
+export interface Response {
+  success: boolean
+  headers: Headers
+  data: OutputSchema
+}
+
+export function toKnownErr(e: any) {
+  if (e instanceof XRPCError) {
+  }
+  return e
+}
diff --git a/src/temp/dm/sendMessage.ts b/src/temp/dm/sendMessage.ts
new file mode 100644
index 000000000..24a4cf733
--- /dev/null
+++ b/src/temp/dm/sendMessage.ts
@@ -0,0 +1,31 @@
+import {Headers, XRPCError} from '@atproto/xrpc'
+
+import * as TempDmDefs from './defs'
+
+export interface QueryParams {}
+
+export interface InputSchema {
+  chatId: string
+  message: TempDmDefs.Message
+  [k: string]: unknown
+}
+
+export type OutputSchema = TempDmDefs.MessageView
+
+export interface CallOptions {
+  headers?: Headers
+  qp?: QueryParams
+  encoding: 'application/json'
+}
+
+export interface Response {
+  success: boolean
+  headers: Headers
+  data: OutputSchema
+}
+
+export function toKnownErr(e: any) {
+  if (e instanceof XRPCError) {
+  }
+  return e
+}
diff --git a/src/temp/dm/sendMessageBatch.ts b/src/temp/dm/sendMessageBatch.ts
new file mode 100644
index 000000000..c2ce1d82c
--- /dev/null
+++ b/src/temp/dm/sendMessageBatch.ts
@@ -0,0 +1,66 @@
+import {ValidationResult} from '@atproto/lexicon'
+import {Headers, XRPCError} from '@atproto/xrpc'
+
+import * as TempDmDefs from './defs'
+
+export interface QueryParams {}
+
+export interface InputSchema {
+  items: BatchItem[]
+  [k: string]: unknown
+}
+
+export interface OutputSchema {
+  items: TempDmDefs.MessageView[]
+  [k: string]: unknown
+}
+
+export interface CallOptions {
+  headers?: Headers
+  qp?: QueryParams
+  encoding: 'application/json'
+}
+
+export interface Response {
+  success: boolean
+  headers: Headers
+  data: OutputSchema
+}
+
+export function toKnownErr(e: any) {
+  if (e instanceof XRPCError) {
+  }
+  return e
+}
+
+export interface BatchItem {
+  chatId: string
+  message: TempDmDefs.Message
+  [k: string]: unknown
+}
+
+export function isBatchItem(v: unknown): v is BatchItem {
+  return (
+    isObj(v) &&
+    hasProp(v, '$type') &&
+    v.$type === 'temp.dm.sendMessageBatch#batchItem'
+  )
+}
+
+export function validateBatchItem(v: unknown): ValidationResult {
+  return {
+    success: true,
+    value: v,
+  }
+}
+
+export function isObj(v: unknown): v is Record<string, unknown> {
+  return typeof v === 'object' && v !== null
+}
+
+export function hasProp<K extends PropertyKey>(
+  data: object,
+  prop: K,
+): data is Record<K, unknown> {
+  return prop in data
+}
diff --git a/src/temp/dm/unmuteChat.ts b/src/temp/dm/unmuteChat.ts
new file mode 100644
index 000000000..e116f2775
--- /dev/null
+++ b/src/temp/dm/unmuteChat.ts
@@ -0,0 +1,30 @@
+import {Headers, XRPCError} from '@atproto/xrpc'
+
+export interface QueryParams {}
+
+export interface InputSchema {
+  chatId: string
+  [k: string]: unknown
+}
+
+export interface OutputSchema {
+  [k: string]: unknown
+}
+
+export interface CallOptions {
+  headers?: Headers
+  qp?: QueryParams
+  encoding: 'application/json'
+}
+
+export interface Response {
+  success: boolean
+  headers: Headers
+  data: OutputSchema
+}
+
+export function toKnownErr(e: any) {
+  if (e instanceof XRPCError) {
+  }
+  return e
+}
diff --git a/src/temp/dm/updateChatRead.ts b/src/temp/dm/updateChatRead.ts
new file mode 100644
index 000000000..7eec7e4ac
--- /dev/null
+++ b/src/temp/dm/updateChatRead.ts
@@ -0,0 +1,31 @@
+import {Headers, XRPCError} from '@atproto/xrpc'
+
+import * as TempDmDefs from './defs'
+
+export interface QueryParams {}
+
+export interface InputSchema {
+  chatId: string
+  messageId?: string
+  [k: string]: unknown
+}
+
+export type OutputSchema = TempDmDefs.ChatView
+
+export interface CallOptions {
+  headers?: Headers
+  qp?: QueryParams
+  encoding: 'application/json'
+}
+
+export interface Response {
+  success: boolean
+  headers: Headers
+  data: OutputSchema
+}
+
+export function toKnownErr(e: any) {
+  if (e instanceof XRPCError) {
+  }
+  return e
+}
diff --git a/src/temp/dm/updateUserSettings.ts b/src/temp/dm/updateUserSettings.ts
new file mode 100644
index 000000000..f88122f5a
--- /dev/null
+++ b/src/temp/dm/updateUserSettings.ts
@@ -0,0 +1,33 @@
+import {Headers, XRPCError} from '@atproto/xrpc'
+
+import * as TempDmDefs from './defs'
+
+export interface QueryParams {}
+
+export interface InputSchema {
+  allowIncoming?: TempDmDefs.IncomingMessageSetting
+  [k: string]: unknown
+}
+
+export interface OutputSchema {
+  allowIncoming: TempDmDefs.IncomingMessageSetting
+  [k: string]: unknown
+}
+
+export interface CallOptions {
+  headers?: Headers
+  qp?: QueryParams
+  encoding: 'application/json'
+}
+
+export interface Response {
+  success: boolean
+  headers: Headers
+  data: OutputSchema
+}
+
+export function toKnownErr(e: any) {
+  if (e instanceof XRPCError) {
+  }
+  return e
+}
diff --git a/src/view/screens/Settings/index.tsx b/src/view/screens/Settings/index.tsx
index 6b5390c29..470bace87 100644
--- a/src/view/screens/Settings/index.tsx
+++ b/src/view/screens/Settings/index.tsx
@@ -51,6 +51,7 @@ import {HandIcon, HashtagIcon} from 'lib/icons'
 import {makeProfileLink} from 'lib/routes/links'
 import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
 import {NavigationProp} from 'lib/routes/types'
+import {useGate} from 'lib/statsig/statsig'
 import {colors, s} from 'lib/styles'
 import {AccountDropdownBtn} from 'view/com/util/AccountDropdownBtn'
 import {SelectableBtn} from 'view/com/util/forms/SelectableBtn'
@@ -61,8 +62,10 @@ import {Text} from 'view/com/util/text/Text'
 import * as Toast from 'view/com/util/Toast'
 import {UserAvatar} from 'view/com/util/UserAvatar'
 import {ScrollView} from 'view/com/util/Views'
+import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
 import {useDialogControl} from '#/components/Dialog'
 import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
+import * as TextField from '#/components/forms/TextField'
 import {navigate, resetToTab} from '#/Navigation'
 import {Email2FAToggle} from './Email2FAToggle'
 import {ExportCarDialog} from './ExportCarDialog'
@@ -169,6 +172,11 @@ export function SettingsScreen({}: Props) {
   const exportCarControl = useDialogControl()
   const birthdayControl = useDialogControl()
 
+  // TODO: TEMP REMOVE WHEN CLOPS ARE RELEASED
+  const gate = useGate()
+  const {serviceUrl: dmServiceUrl, setServiceUrl: setDmServiceUrl} =
+    useDmServiceUrlStorage()
+
   // const primaryBg = useCustomPalette<ViewStyle>({
   //   light: {backgroundColor: colors.blue0},
   //   dark: {backgroundColor: colors.blue6},
@@ -778,6 +786,22 @@ export function SettingsScreen({}: Props) {
             <Trans>System log</Trans>
           </Text>
         </TouchableOpacity>
+        {gate('dms') && (
+          <TextField.Root>
+            <TextField.Input
+              value={dmServiceUrl}
+              onChangeText={(text: string) => {
+                if (text.endsWith('/')) {
+                  text = text.slice(0, -1)
+                }
+                setDmServiceUrl(text)
+              }}
+              autoCapitalize="none"
+              keyboardType="url"
+              label="🐴"
+            />
+          </TextField.Root>
+        )}
         {__DEV__ ? (
           <>
             <TouchableOpacity
diff --git a/src/view/shell/bottom-bar/BottomBarWeb.tsx b/src/view/shell/bottom-bar/BottomBarWeb.tsx
index 8b316faa5..d8deaf696 100644
--- a/src/view/shell/bottom-bar/BottomBarWeb.tsx
+++ b/src/view/shell/bottom-bar/BottomBarWeb.tsx
@@ -122,6 +122,16 @@ export function BottomBarWeb() {
                   )
                 }}
               </NavItem>
+              <NavItem routeName="Messages" href="/messages">
+                {() => {
+                  return (
+                    <Envelope
+                      size="lg"
+                      style={[styles.ctrlIcon, pal.text, styles.messagesIcon]}
+                    />
+                  )
+                }}
+              </NavItem>
               {gate('dms') && (
                 <NavItem routeName="Messages" href="/messages">
                   {({isActive}) => {