From 0ac8158fa7a8058a0474f557003d56d9c78362d0 Mon Sep 17 00:00:00 2001 From: Alex Risch Date: Wed, 13 Dec 2023 13:25:37 -0700 Subject: [PATCH] Hook Setup Setup for hooks in module Step 1 of many towards parity with Web on the JS side Added xmtp context into module and out from example app Added useClient hook to help manage client Follow ups, adding codecs, processors, namespaces, validators Adding client close method in disconnect callback --- example/App.tsx | 7 +-- example/src/ConversationCreateScreen.tsx | 2 +- example/src/ConversationScreen.tsx | 58 ++++++++++------- example/src/HomeScreen.tsx | 3 +- example/src/LaunchScreen.tsx | 40 ++++++------ example/src/XmtpContext.tsx | 26 -------- example/src/hooks.tsx | 2 +- src/context/XmtpContext.tsx | 36 +++++++++++ src/context/index.ts | 1 + src/hooks/index.ts | 2 + src/hooks/useClient.ts | 80 ++++++++++++++++++++++++ src/hooks/useXmtp.ts | 5 ++ src/index.ts | 2 + 13 files changed, 187 insertions(+), 77 deletions(-) delete mode 100644 example/src/XmtpContext.tsx create mode 100644 src/context/XmtpContext.tsx create mode 100644 src/context/index.ts create mode 100644 src/hooks/index.ts create mode 100644 src/hooks/useClient.ts create mode 100644 src/hooks/useXmtp.ts 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