diff --git a/example/App.tsx b/example/App.tsx index f475f8792..fa7d87dd0 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -1,7 +1,7 @@ import { NavigationContainer } from '@react-navigation/native' -import React from 'react' import { Button, Platform } from 'react-native' import { QueryClient, QueryClientProvider } from 'react-query' +import { XmtpProvider } from 'xmtp-react-native-sdk' import ConversationCreateScreen from './src/ConversationCreateScreen' import ConversationScreen from './src/ConversationScreen' @@ -9,13 +9,12 @@ import HomeScreen from './src/HomeScreen' import LaunchScreen from './src/LaunchScreen' import { Navigator } from './src/Navigation' import TestScreen from './src/TestScreen' -import { XmtpContextProvider } from './src/XmtpContext' const queryClient = new QueryClient() export default function App() { return ( - + - + ) } diff --git a/example/src/ConversationCreateScreen.tsx b/example/src/ConversationCreateScreen.tsx index f2a80d984..9f0676dc9 100644 --- a/example/src/ConversationCreateScreen.tsx +++ b/example/src/ConversationCreateScreen.tsx @@ -1,9 +1,9 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack' import React, { useState } from 'react' import { Button, ScrollView, Text, TextInput } from 'react-native' +import { useXmtp } from 'xmtp-react-native-sdk' import { NavigationParamList } from './Navigation' -import { useXmtp } from './XmtpContext' export default function ConversationCreateScreen({ route, diff --git a/example/src/ConversationScreen.tsx b/example/src/ConversationScreen.tsx index b6e92a649..5b12e2b4b 100644 --- a/example/src/ConversationScreen.tsx +++ b/example/src/ConversationScreen.tsx @@ -8,7 +8,7 @@ import * as ImagePicker from 'expo-image-picker' import type { ImagePickerAsset } from 'expo-image-picker' import { PermissionStatus } from 'expo-modules-core' import moment from 'moment' -import React, { useRef, useState } from 'react' +import React, { useCallback, useMemo, useRef, useState } from 'react' import { Button, FlatList, @@ -30,11 +30,10 @@ import { DecodedMessage, StaticAttachmentContent, ReplyContent, - Client, + useClient, } from 'xmtp-react-native-sdk' import { NavigationParamList } from './Navigation' -import { useXmtp } from './XmtpContext' import { useConversation, useMessage, @@ -57,7 +56,7 @@ export default function ConversationScreen({ }: NativeStackScreenProps) { const { topic } = route.params const messageListRef = useRef(null) - let { + const { data: messages, refetch: refreshMessages, isFetching, @@ -74,10 +73,15 @@ export default function ConversationScreen({ fileUri: attachment?.image?.uri || attachment?.file?.uri, mimeType: attachment?.file?.mimeType, }) - messages = (messages || []).filter( - (message) => !hiddenMessageTypes.includes(message.contentTypeId) + + const filteredMessages = useMemo( + () => + (messages ?? [])?.filter( + (message) => !hiddenMessageTypes.includes(message.contentTypeId) + ), + [messages] ) - // console.log("messages", JSON.stringify(messages, null, 2)); + const sendMessage = async (content: any) => { setSending(true) console.log('Sending message', content) @@ -102,16 +106,22 @@ export default function ConversationScreen({ const sendRemoteAttachmentMessage = () => sendMessage({ remoteAttachment }).then(() => setAttachment(null)) const sendTextMessage = () => sendMessage({ text }).then(() => setText('')) - const scrollToMessageId = (messageId: string) => { - const index = (messages || []).findIndex((m) => m.id === messageId) - if (index === -1) { - return - } - return messageListRef.current?.scrollToIndex({ - index, - animated: true, - }) - } + const scrollToMessageId = useCallback( + (messageId: string) => { + const index = (filteredMessages || []).findIndex( + (m) => m.id === messageId + ) + if (index === -1) { + return + } + return messageListRef.current?.scrollToIndex({ + index, + animated: true, + }) + }, + [filteredMessages] + ) + return ( message.id} @@ -154,9 +164,9 @@ export default function ConversationScreen({ onReply={() => setReplyingTo(message.id)} onMessageReferencePress={scrollToMessageId} showSender={ - index === (messages || []).length - 1 || - (index + 1 < (messages || []).length && - messages![index + 1].senderAddress !== + index === (filteredMessages || []).length - 1 || + (index + 1 < (filteredMessages || []).length && + filteredMessages![index + 1].senderAddress !== message.senderAddress) } /> @@ -1046,7 +1056,7 @@ function MessageContents({ contentTypeId: string content: any }) { - const { client }: { client: Client } = useXmtp() + const { client } = useClient() if (contentTypeId === 'xmtp.org/text:1.0') { const text: string = content @@ -1080,8 +1090,8 @@ function MessageContents({ if (contentTypeId === 'xmtp.org/reply:1.0') { const replyContent: ReplyContent = content const replyContentType = replyContent.contentType - const codec = client.codecRegistry[replyContentType] - const actualReplyContent = codec.decode(replyContent.content) + const codec = client?.codecRegistry[replyContentType] + const actualReplyContent = codec?.decode(replyContent.content) return ( diff --git a/example/src/HomeScreen.tsx b/example/src/HomeScreen.tsx index fe19e122a..f137b2d55 100644 --- a/example/src/HomeScreen.tsx +++ b/example/src/HomeScreen.tsx @@ -9,9 +9,8 @@ import { Text, View, } from 'react-native' -import { Conversation, Client } from 'xmtp-react-native-sdk' +import { Conversation, Client, useXmtp } from 'xmtp-react-native-sdk' -import { useXmtp } from './XmtpContext' import { useConversationList, useMessages } from './hooks' /// Show the user's list of conversations. diff --git a/example/src/LaunchScreen.tsx b/example/src/LaunchScreen.tsx index 191666314..ed889fc29 100644 --- a/example/src/LaunchScreen.tsx +++ b/example/src/LaunchScreen.tsx @@ -1,10 +1,10 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack' -import React from 'react' +import React, { useCallback } from 'react' import { Button, ScrollView, StyleSheet, Text, View } from 'react-native' import * as XMTP from 'xmtp-react-native-sdk' +import { useXmtp } from 'xmtp-react-native-sdk' import { NavigationParamList } from './Navigation' -import { useXmtp } from './XmtpContext' import { useSavedKeys } from './hooks' const appVersion = 'XMTP_RN_EX/0.0.1' @@ -22,24 +22,26 @@ export default function LaunchScreen({ }: NativeStackScreenProps) { const { setClient } = useXmtp() const savedKeys = useSavedKeys() - const configureWallet = ( - label: string, - configuring: Promise - ) => { - console.log('Connecting XMTP client', label) - configuring - .then(async (client) => { - console.log('Connected XMTP client', label, { - address: client.address, + const configureWallet = useCallback( + (label: string, configuring: Promise) => { + console.log('Connecting XMTP client', label) + configuring + .then(async (client) => { + console.log('Connected XMTP client', label, { + address: client.address, + }) + setClient(client) + navigation.navigate('home') + // Save the configured client keys for use in later sessions. + const keyBundle = await client.exportKeyBundle() + await savedKeys.save(keyBundle) }) - setClient(client) - navigation.navigate('home') - // Save the configured client keys for use in later sessions. - const keyBundle = await client.exportKeyBundle() - await savedKeys.save(keyBundle) - }) - .catch((err) => console.log('Unable to connect XMTP client', label, err)) - } + .catch((err) => + console.log('Unable to connect XMTP client', label, err) + ) + }, + [] + ) return ( void -}>({ - client: null, - setClient: () => {}, -}) -export const useXmtp = () => useContext(XmtpContext) -type Props = { - children: ReactNode -} -export const XmtpContextProvider: FC = ({ children }) => { - const [client, setClient] = useState(null) - const context = useMemo(() => ({ client, setClient }), [client, setClient]) - return {children} -} diff --git a/example/src/hooks.tsx b/example/src/hooks.tsx index ea34a2777..dc64123b2 100644 --- a/example/src/hooks.tsx +++ b/example/src/hooks.tsx @@ -7,9 +7,9 @@ import { EncryptedLocalAttachment, ReactionContent, RemoteAttachmentContent, + useXmtp, } from 'xmtp-react-native-sdk' -import { useXmtp } from './XmtpContext' import { downloadFile, uploadFile } from './storage' /** diff --git a/src/context/XmtpContext.tsx b/src/context/XmtpContext.tsx new file mode 100644 index 000000000..cd25ee27f --- /dev/null +++ b/src/context/XmtpContext.tsx @@ -0,0 +1,36 @@ +import * as React from 'react' + +import { Client } from '../lib/Client' + +export interface XmtpContextValue { + /** + * The XMTP client instance + */ + client: Client | null + /** + * Set the XMTP client instance + */ + setClient: React.Dispatch | null>> +} + +export const XmtpContext = React.createContext({ + client: null, + setClient: () => {}, +}) +interface Props { + children: React.ReactNode + client?: Client +} +export const XmtpProvider: React.FC = ({ + children, + client: initialClient, +}) => { + const [client, setClient] = React.useState | null>( + initialClient ?? null + ) + const context = React.useMemo( + () => ({ client, setClient }), + [client, setClient] + ) + return {children} +} diff --git a/src/context/index.ts b/src/context/index.ts new file mode 100644 index 000000000..8c967f1f9 --- /dev/null +++ b/src/context/index.ts @@ -0,0 +1 @@ +export * from './XmtpContext' diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 000000000..47428c4e1 --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './useXmtp' +export * from './useClient' diff --git a/src/hooks/useClient.ts b/src/hooks/useClient.ts new file mode 100644 index 000000000..9f7042e86 --- /dev/null +++ b/src/hooks/useClient.ts @@ -0,0 +1,80 @@ +import { Signer } from 'ethers' +import { useCallback, useRef, useState } from 'react' + +import { useXmtp } from './useXmtp' +import { Client, ClientOptions } from '../lib/Client' + +interface InitializeClientOptions { + signer: Signer + options?: ClientOptions +} + +export const useClient = (onError?: (e: Error) => void) => { + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + // client is initializing + const initializingRef = useRef(false) + + const { client, setClient } = useXmtp() + /** + * Initialize an XMTP client + */ + const initialize = useCallback( + async ({ options, signer }: InitializeClientOptions) => { + // only initialize a client if one doesn't already exist + if (!client && signer) { + // if the client is already initializing, don't do anything + if (initializingRef.current) { + return undefined + } + + // flag the client as initializing + initializingRef.current = true + + // reset error state + setError(null) + // reset loading state + setIsLoading(true) + + let xmtpClient: Client + + try { + // create a new XMTP client with the provided keys, or a wallet + xmtpClient = await Client.create(signer ?? null, { + ...options, + }) + setClient(xmtpClient) + } catch (e) { + setClient(null) + setError(e as Error) + onError?.(e as Error) + // re-throw error for upstream consumption + throw e + } + + setIsLoading(false) + + return xmtpClient + } + return client + }, + [client, onError, setClient] + ) + + /** + * Disconnect the XMTP client + */ + const disconnect = useCallback(async () => { + if (client) { + setClient(null) + } + }, [client, setClient]) + + return { + client, + error, + initialize, + disconnect, + isLoading, + } +} diff --git a/src/hooks/useXmtp.ts b/src/hooks/useXmtp.ts new file mode 100644 index 000000000..6e4e5a04a --- /dev/null +++ b/src/hooks/useXmtp.ts @@ -0,0 +1,5 @@ +import { useContext } from 'react' + +import { XmtpContext } from '../context/XmtpContext' + +export const useXmtp = () => useContext(XmtpContext) diff --git a/src/index.ts b/src/index.ts index d924bf230..93ab5d90f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,8 @@ export { ReadReceiptCodec } from './lib/NativeCodecs/ReadReceiptCodec' export { StaticAttachmentCodec } from './lib/NativeCodecs/StaticAttachmentCodec' export { RemoteAttachmentCodec } from './lib/NativeCodecs/RemoteAttachmentCodec' export { TextCodec } from './lib/NativeCodecs/TextCodec' +export * from './hooks' +export * from './context' const EncodedContent = content.EncodedContent