diff --git a/package.json b/package.json
index 0cb6bdb2..acb9007b 100644
--- a/package.json
+++ b/package.json
@@ -168,6 +168,7 @@
"react-native-get-random-values": "~1.11.0",
"react-native-image-crop-picker": "^0.38.1",
"react-native-ios-context-menu": "^1.15.3",
+ "react-native-keyboard-controller": "^1.11.7",
"react-native-pager-view": "6.2.3",
"react-native-picker-select": "^8.1.0",
"react-native-progress": "bluesky-social/react-native-progress",
diff --git a/src/App.native.tsx b/src/App.native.tsx
index cf96781b..4cb963fe 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() {
-
+
+
+
diff --git a/src/screens/Messages/Conversation/MessageInput.tsx b/src/screens/Messages/Conversation/MessageInput.tsx
new file mode 100644
index 00000000..bd73594c
--- /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(null)
+
+ const onSubmit = React.useCallback(() => {
+ onSendMessage(message)
+ setMessage('')
+ setTimeout(() => {
+ inputRef.current?.focus()
+ }, 100)
+ }, [message, onSendMessage])
+
+ return (
+
+
+
+ 🐴
+
+
+ )
+}
diff --git a/src/screens/Messages/Conversation/MessageItem.tsx b/src/screens/Messages/Conversation/MessageItem.tsx
new file mode 100644
index 00000000..74e65488
--- /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 (
+
+
+ {item.text}
+
+
+ )
+}
diff --git a/src/screens/Messages/Conversation/MessagesList.tsx b/src/screens/Messages/Conversation/MessagesList.tsx
new file mode 100644
index 00000000..aafed42a
--- /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 (
+
+ {isLoading && }
+
+ )
+}
+
+function renderItem({
+ item,
+}: {
+ item: TempDmChatDefs.MessageView | TempDmChatDefs.DeletedMessage
+}) {
+ if (TempDmChatDefs.isMessageView(item)) return
+
+ if (TempDmChatDefs.isDeletedMessage(item)) return Deleted message
+
+ 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(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; changed: Array}) => {
+ 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 (
+
+ 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={}
+ removeClippedSubviews={true}
+ ref={flatListRef}
+ keyboardDismissMode="none"
+ />
+
+
+
+
+ )
+}
diff --git a/src/screens/Messages/Conversation/index.tsx b/src/screens/Messages/Conversation/index.tsx
index 239425a2..efa64f5f 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
return (
-
+
-
+
+
)
}
diff --git a/src/screens/Messages/List/index.tsx b/src/screens/Messages/List/index.tsx
index c4490aa5..b13ddd29 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 (
diff --git a/src/screens/Messages/Temp/query/query.ts b/src/screens/Messages/Temp/query/query.ts
new file mode 100644
index 00000000..2477dc56
--- /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 00000000..3679858f
--- /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('')
+ 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 (
+
+ {children}
+
+ )
+}
diff --git a/src/state/preferences/index.tsx b/src/state/preferences/index.tsx
index 5c8fab2a..82035851 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<{}>) {
- {children}
+
+ {children}
+
diff --git a/src/temp/dm/defs.ts b/src/temp/dm/defs.ts
new file mode 100644
index 00000000..91f68365
--- /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 {
+ return typeof v === 'object' && v !== null
+}
+
+export function hasProp(
+ data: object,
+ prop: K,
+): data is Record {
+ return prop in data
+}
diff --git a/src/temp/dm/deleteMessage.ts b/src/temp/dm/deleteMessage.ts
new file mode 100644
index 00000000..d9fa1f9c
--- /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 00000000..d0a7b891
--- /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 00000000..0c9962c8
--- /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 00000000..9d310d90
--- /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 00000000..54ae2191
--- /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 00000000..792c697b
--- /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 00000000..e116f277
--- /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 00000000..0f9cb0c6
--- /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 00000000..e116f277
--- /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 00000000..24a4cf73
--- /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 00000000..c2ce1d82
--- /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 {
+ return typeof v === 'object' && v !== null
+}
+
+export function hasProp(
+ data: object,
+ prop: K,
+): data is Record {
+ return prop in data
+}
diff --git a/src/temp/dm/unmuteChat.ts b/src/temp/dm/unmuteChat.ts
new file mode 100644
index 00000000..e116f277
--- /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 00000000..7eec7e4a
--- /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 00000000..f88122f5
--- /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 6b5390c2..470bace8 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({
// light: {backgroundColor: colors.blue0},
// dark: {backgroundColor: colors.blue6},
@@ -778,6 +786,22 @@ export function SettingsScreen({}: Props) {
System log
+ {gate('dms') && (
+
+ {
+ if (text.endsWith('/')) {
+ text = text.slice(0, -1)
+ }
+ setDmServiceUrl(text)
+ }}
+ autoCapitalize="none"
+ keyboardType="url"
+ label="🐴"
+ />
+
+ )}
{__DEV__ ? (
<>
+
+ {() => {
+ return (
+
+ )
+ }}
+
{gate('dms') && (
{({isActive}) => {
diff --git a/yarn.lock b/yarn.lock
index ec58c7b9..5d62929d 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -18695,6 +18695,11 @@ react-native-ios-context-menu@^1.15.3:
dependencies:
"@dominicstop/ts-event-emitter" "^1.1.0"
+react-native-keyboard-controller@^1.11.7:
+ version "1.11.7"
+ resolved "https://registry.yarnpkg.com/react-native-keyboard-controller/-/react-native-keyboard-controller-1.11.7.tgz#85640374e4c3627c3b667256a1d308698ff80393"
+ integrity sha512-K2zlqVyWX4QO7r+dHMQgZT41G2dSEWtDYgBdht1WVyTaMQmwTMalZcHCWBVOnzyGaJq/hMKhF1kSPqJP1xqSFA==
+
react-native-pager-view@6.2.3:
version "6.2.3"
resolved "https://registry.yarnpkg.com/react-native-pager-view/-/react-native-pager-view-6.2.3.tgz#698f6387fdf06cecc3d8d4792604419cb89cb775"