diff --git a/.changeset/chilled-cougars-beg.md b/.changeset/chilled-cougars-beg.md new file mode 100644 index 00000000..5202d258 --- /dev/null +++ b/.changeset/chilled-cougars-beg.md @@ -0,0 +1,8 @@ +--- +"@xmtp/react-sdk": patch +--- + +- Add `isLoaded` state to the `useMessages` and `useConversations` hooks +- Add `clearCache` to exports +- Minor refactor of `useStartConversation` hook to export `conversation` when no initial message is sent +- Access all cached conversations using the client's wallet address diff --git a/dev/docker-compose.yml b/dev/docker-compose.yml index f260de1c..1ac22727 100644 --- a/dev/docker-compose.yml +++ b/dev/docker-compose.yml @@ -5,13 +5,9 @@ services: environment: - GOWAKU-NODEKEY=8a30dcb604b0b53627a5adc054dbf434b446628d4bd1eccc681d223f0550ce67 command: - - --ws - - --store - - --message-db-connection-string=postgres://postgres:xmtp@db:5432/postgres?sslmode=disable - - --message-db-reader-connection-string=postgres://postgres:xmtp@db:5432/postgres?sslmode=disable - - --lightpush - - --filter - - --ws-port=9001 + - --store.enable + - --store.db-connection-string=postgres://postgres:xmtp@db:5432/postgres?sslmode=disable + - --store.reader-db-connection-string=postgres://postgres:xmtp@db:5432/postgres?sslmode=disable - --wait-for-db=30s - --api.authn.enable ports: diff --git a/packages/react-sdk/src/helpers/caching/conversations.test.ts b/packages/react-sdk/src/helpers/caching/conversations.test.ts index a472afc3..2f7e4667 100644 --- a/packages/react-sdk/src/helpers/caching/conversations.test.ts +++ b/packages/react-sdk/src/helpers/caching/conversations.test.ts @@ -31,22 +31,34 @@ beforeEach(async () => { describe("getCachedConversationBy", () => { it("should return undefined if no conversation is found", async () => { const conversation = await getCachedConversationBy( + "testWalletAddress", "topic", "testTopic", db, ); expect(conversation).toBeUndefined(); - const conversation2 = await getCachedConversationBy("id", 1, db); + const conversation2 = await getCachedConversationBy( + "testWalletAddress", + "id", + 1, + db, + ); expect(conversation2).toBeUndefined(); const conversation3 = await getCachedConversationBy( + "testWalletAddress", "peerAddress", "testPeerAddress", db, ); expect(conversation3).toBeUndefined(); - const conversation4 = await getCachedConversationByTopic("testTopic", db); + const conversation4 = await getCachedConversationByTopic( + "testWalletAddress", + "testTopic", + db, + ); expect(conversation4).toBeUndefined(); const conversation5 = await getCachedConversationByPeerAddress( + "testWalletAddress", "testPeerAddress", db, ); @@ -65,22 +77,34 @@ describe("getCachedConversationBy", () => { } satisfies CachedConversationWithId; const cachedConversation = await saveConversation(testConversation, db); const conversation = await getCachedConversationBy( + "testWalletAddress", "topic", "testTopic", db, ); expect(conversation).toEqual(cachedConversation); - const conversation2 = await getCachedConversationBy("id", 1, db); + const conversation2 = await getCachedConversationBy( + "testWalletAddress", + "id", + 1, + db, + ); expect(conversation2).toEqual(cachedConversation); const conversation3 = await getCachedConversationBy( + "testWalletAddress", "peerAddress", "testPeerAddress", db, ); expect(conversation3).toEqual(cachedConversation); - const conversation4 = await getCachedConversationByTopic("testTopic", db); + const conversation4 = await getCachedConversationByTopic( + "testWalletAddress", + "testTopic", + db, + ); expect(conversation4).toEqual(cachedConversation); const conversation5 = await getCachedConversationByPeerAddress( + "testWalletAddress", "testPeerAddress", db, ); @@ -138,6 +162,7 @@ describe("updateConversation", () => { ); const updatedConversation = await getCachedConversationByTopic( + "testWalletAddress", "testTopic", db, ); @@ -163,9 +188,16 @@ describe("updateConversationMetadata", () => { const cachedConversation = await saveConversation(testConversation, db); expect(cachedConversation).toEqual(testConversation); - await updateConversationMetadata("testTopic", "test", { test: "test" }, db); + await updateConversationMetadata( + "testWalletAddress", + "testTopic", + "test", + { test: "test" }, + db, + ); const updatedConversation = await getCachedConversationByTopic( + "testWalletAddress", "testTopic", db, ); @@ -193,7 +225,11 @@ describe("setConversationUpdatedAt", () => { await setConversationUpdatedAt("testTopic", updatedAt, db); - const conversation = await getCachedConversationByTopic("testTopic", db); + const conversation = await getCachedConversationByTopic( + "testWalletAddress", + "testTopic", + db, + ); expect(conversation?.updatedAt).toEqual(updatedAt); }); }); @@ -213,11 +249,15 @@ describe("hasConversationTopic", () => { const cachedConversation = await saveConversation(testConversation, db); expect(cachedConversation).toEqual(testConversation); - expect(await hasConversationTopic("testTopic", db)).toBe(true); + expect( + await hasConversationTopic("testWalletAddress", "testTopic", db), + ).toBe(true); }); it("should return false if the topic does not exist", async () => { - expect(await hasConversationTopic("testTopic", db)).toBe(false); + expect( + await hasConversationTopic("testWalletAddress", "testTopic", db), + ).toBe(false); }); }); diff --git a/packages/react-sdk/src/helpers/caching/conversations.ts b/packages/react-sdk/src/helpers/caching/conversations.ts index 1d65f877..9ce35814 100644 --- a/packages/react-sdk/src/helpers/caching/conversations.ts +++ b/packages/react-sdk/src/helpers/caching/conversations.ts @@ -25,7 +25,7 @@ type ToFunctionArgs = { }[keyof T]; type GetCachedConversationBy = ( - ...args: [...ToFunctionArgs, Dexie] + ...args: [string, ...ToFunctionArgs, Dexie] ) => Promise; export type CachedConversationsTable = Table; @@ -40,6 +40,7 @@ export type CachedConversationWithId = CachedConversation & { * @returns The cached conversation if found, otherwise `undefined` */ export const getCachedConversationBy: GetCachedConversationBy = async ( + walletAddress, key, value, db, @@ -48,8 +49,10 @@ export const getCachedConversationBy: GetCachedConversationBy = async ( "conversations", ) as CachedConversationsTable; const conversation = await conversationsTable - .where(key) - .equals(value) + .where({ + walletAddress, + [key]: value, + }) .first(); return conversation ? (conversation as CachedConversationWithId) : undefined; }; @@ -59,8 +62,11 @@ export const getCachedConversationBy: GetCachedConversationBy = async ( * * @returns The cached conversation if found, otherwise `undefined` */ -export const getCachedConversationByTopic = async (topic: string, db: Dexie) => - getCachedConversationBy("topic", topic, db); +export const getCachedConversationByTopic = async ( + walletAddress: string, + topic: string, + db: Dexie, +) => getCachedConversationBy(walletAddress, "topic", topic, db); /** * Retrieve a cached conversation by peer address @@ -68,9 +74,10 @@ export const getCachedConversationByTopic = async (topic: string, db: Dexie) => * @returns The cached conversation if found, otherwise `undefined` */ export const getCachedConversationByPeerAddress = async ( + walletAddress: string, peerAddress: string, db: Dexie, -) => getCachedConversationBy("peerAddress", peerAddress, db); +) => getCachedConversationBy(walletAddress, "peerAddress", peerAddress, db); /** * Retrieve a conversation from the XMTP client by a topic @@ -121,12 +128,13 @@ export const updateConversation = async ( * This is not meant to be called directly */ export const updateConversationMetadata = async ( + walletAddress: string, topic: string, namespace: string, data: ContentTypeMetadataValues, db: Dexie, ) => { - const existing = await getCachedConversationByTopic(topic, db); + const existing = await getCachedConversationByTopic(walletAddress, topic, db); if (existing) { const metadata = existing.metadata || {}; metadata[namespace] = data; @@ -148,8 +156,12 @@ export const setConversationUpdatedAt = async ( /** * Check to see if a topic exists in the conversations cache */ -export const hasConversationTopic = async (topic: string, db: Dexie) => { - const existing = await getCachedConversationByTopic(topic, db); +export const hasConversationTopic = async ( + walletAddress: string, + topic: string, + db: Dexie, +) => { + const existing = await getCachedConversationByTopic(walletAddress, topic, db); return !!existing; }; diff --git a/packages/react-sdk/src/helpers/caching/db.ts b/packages/react-sdk/src/helpers/caching/db.ts index 35237e60..eb8ad53b 100644 --- a/packages/react-sdk/src/helpers/caching/db.ts +++ b/packages/react-sdk/src/helpers/caching/db.ts @@ -92,7 +92,8 @@ export const getDbInstance = (options?: GetDBInstanceOptions) => { ...customSchema, conversations: ` ++id, - [topic+walletAddress], + [walletAddress+topic], + [walletAddress+peerAddress], createdAt, peerAddress, topic, diff --git a/packages/react-sdk/src/helpers/caching/messages.test.ts b/packages/react-sdk/src/helpers/caching/messages.test.ts index 7707f8a6..51170dfa 100644 --- a/packages/react-sdk/src/helpers/caching/messages.test.ts +++ b/packages/react-sdk/src/helpers/caching/messages.test.ts @@ -541,6 +541,7 @@ describe("processMessage", () => { expect(mockProcessor3).not.toHaveBeenCalled(); const updatedConversation = await getCachedConversationByTopic( + "testWalletAddress", "testTopic", db, ); @@ -594,6 +595,7 @@ describe("processMessage", () => { expect(mockProcessor3).not.toHaveBeenCalled(); const updatedConversation = await getCachedConversationByTopic( + "testWalletAddress", "testTopic", db, ); @@ -642,6 +644,7 @@ describe("processMessage", () => { expect(mockProcessor3).not.toHaveBeenCalled(); const updatedConversation = await getCachedConversationByTopic( + "testWalletAddress", "testTopic", db, ); @@ -882,13 +885,13 @@ describe("processMessage", () => { isReady: false, topic: "testTopic", peerAddress: "testPeerAddress", - walletAddress: "testWalletAddress", + walletAddress: testClient.address, } satisfies CachedConversation; const cachedConversation = await saveConversation(testConversation, db); const sentAt = adjustDate(createdAt, 1000); const testMessage = { id: 1, - walletAddress: "testWalletAddress", + walletAddress: testClient.address, conversationTopic: "testTopic", content: "test", contentType: ContentTypeText.toString(), @@ -897,7 +900,7 @@ describe("processMessage", () => { hasSendError: false, sentAt, status: "unprocessed", - senderAddress: "testWalletAddress", + senderAddress: testClient.address, uuid: "testUuid", xmtpID: "testXmtpId", } satisfies CachedMessage; @@ -924,6 +927,7 @@ describe("processMessage", () => { expect(mockProcessor3).not.toHaveBeenCalled(); const updatedConversation = await getCachedConversationByTopic( + testClient.address, "testTopic", db, ); diff --git a/packages/react-sdk/src/helpers/caching/messages.ts b/packages/react-sdk/src/helpers/caching/messages.ts index acfae4f0..72a48fec 100644 --- a/packages/react-sdk/src/helpers/caching/messages.ts +++ b/packages/react-sdk/src/helpers/caching/messages.ts @@ -325,6 +325,7 @@ export const processMessage = async ( data: ContentTypeMetadataValues, ) => { await _updateConversationMetadata( + client.address, conversation.topic, namespace, data, @@ -503,6 +504,7 @@ export const processUnprocessedMessages = async ({ unprocessed.map(async (unprocessedMessage) => { // get message's conversation from cache const conversation = await getCachedConversationByTopic( + client.address, unprocessedMessage.conversationTopic, db, ); diff --git a/packages/react-sdk/src/hooks/useConversation.ts b/packages/react-sdk/src/hooks/useConversation.ts index fa9c532f..6f4a5f67 100644 --- a/packages/react-sdk/src/hooks/useConversation.ts +++ b/packages/react-sdk/src/hooks/useConversation.ts @@ -37,9 +37,17 @@ export const useConversationInternal = () => { RemoveLastParameter >( async (conversation, namespace, data) => { - await updateConversationMetadata(conversation, namespace, data, db); + if (client) { + await updateConversationMetadata( + client.address, + conversation, + namespace, + data, + db, + ); + } }, - [db], + [client, db], ); return { @@ -57,36 +65,38 @@ export const useConversation = () => { const { client } = useClient(); const { db } = useDb(); - const getByTopic = useCallback< - RemoveLastParameter - >( - async (topic) => { - if (client) { - return getConversationByTopic(topic, client); - } - return undefined; - }, + const getByTopic = useCallback( + async (topic: string) => + client ? getConversationByTopic(topic, client) : undefined, [client], ); - const getCachedByTopic = useCallback< - RemoveLastParameter - >(async (topic) => getCachedConversationByTopic(topic, db), [db]); + const getCachedByTopic = useCallback( + async (topic: string) => + client + ? getCachedConversationByTopic(client.address, topic, db) + : undefined, + [client, db], + ); - const getCachedByPeerAddress = useCallback< - RemoveLastParameter - >( - async (peerAddress) => getCachedConversationByPeerAddress(peerAddress, db), - [db], + const getCachedByPeerAddress = useCallback( + async (peerAddress: string) => + client + ? getCachedConversationByPeerAddress(client.address, peerAddress, db) + : undefined, + [client, db], ); - const getLastMessage = useCallback< - RemoveLastParameter - >(async (topic) => _getLastMessage(topic, db), [db]); + const getLastMessage = useCallback( + async (topic: string) => _getLastMessage(topic, db), + [db], + ); - const hasConversationTopic = useCallback< - RemoveLastParameter - >(async (topic) => _hasConversationTopic(topic, db), [db]); + const hasConversationTopic = useCallback( + async (topic: string) => + client ? _hasConversationTopic(client.address, topic, db) : false, + [client, db], + ); return { getByTopic, diff --git a/packages/react-sdk/src/hooks/useConversations.ts b/packages/react-sdk/src/hooks/useConversations.ts index bfad3b74..8ea9ac87 100644 --- a/packages/react-sdk/src/hooks/useConversations.ts +++ b/packages/react-sdk/src/hooks/useConversations.ts @@ -24,6 +24,7 @@ export type UseConversationsOptions = OnError & { */ export const useConversations = (options?: UseConversationsOptions) => { const [isLoading, setIsLoading] = useState(false); + const [isLoaded, setIsLoaded] = useState(false); const [error, setError] = useState(null); const { client } = useClient(); const { processMessage } = useMessage(); @@ -58,6 +59,7 @@ export const useConversations = (options?: UseConversationsOptions) => { loadingRef.current = true; setIsLoading(true); + setIsLoaded(false); setError(null); try { @@ -87,6 +89,7 @@ export const useConversations = (options?: UseConversationsOptions) => { } }), ); + setIsLoaded(true); onConversations?.(conversationList); } catch (e) { setError(e as Error); @@ -112,6 +115,7 @@ export const useConversations = (options?: UseConversationsOptions) => { return { conversations, error, + isLoaded, isLoading, }; }; diff --git a/packages/react-sdk/src/hooks/useMessages.ts b/packages/react-sdk/src/hooks/useMessages.ts index 1f12dd18..4f4b52d5 100644 --- a/packages/react-sdk/src/hooks/useMessages.ts +++ b/packages/react-sdk/src/hooks/useMessages.ts @@ -27,6 +27,7 @@ export const useMessages = ( conversation: CachedConversation, options?: UseMessagesOptions, ) => { + const [isLoaded, setIsLoaded] = useState(false); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const { processMessage } = useMessage(); @@ -57,8 +58,9 @@ export const useMessages = ( loadingRef.current = true; - // reset loading state + // reset loading states setIsLoading(true); + setIsLoaded(false); // reset error state setError(null); @@ -96,6 +98,7 @@ export const useMessages = ( await updateConversation(conversation.topic, { isReady: true }); } + setIsLoaded(true); onMessages?.(networkMessages); } catch (e) { setError(e as Error); @@ -122,6 +125,7 @@ export const useMessages = ( return { error, + isLoaded, isLoading, messages, }; diff --git a/packages/react-sdk/src/hooks/useStartConversation.ts b/packages/react-sdk/src/hooks/useStartConversation.ts index dd65b7b3..21e23ea4 100644 --- a/packages/react-sdk/src/hooks/useStartConversation.ts +++ b/packages/react-sdk/src/hooks/useStartConversation.ts @@ -61,19 +61,19 @@ export const useStartConversation = (options?: UseStartConversation) => { toCachedConversation(conversation, client.address), ); - if (content === undefined) { + if (!cachedConversation) { return { - cachedConversation, + cachedConversation: undefined, cachedMessage: undefined, - conversation: undefined, + conversation, }; } - if (!cachedConversation) { + if (content === undefined) { return { - cachedConversation: undefined, + cachedConversation, cachedMessage: undefined, - conversation: undefined, + conversation, }; } diff --git a/packages/react-sdk/src/index.ts b/packages/react-sdk/src/index.ts index 1ed29caf..0390bed8 100644 --- a/packages/react-sdk/src/index.ts +++ b/packages/react-sdk/src/index.ts @@ -33,7 +33,7 @@ export { useReactions } from "./hooks/useReactions"; export { useReply } from "./hooks/useReply"; // caching -export { getDbInstance } from "./helpers/caching/db"; +export { getDbInstance, clearCache } from "./helpers/caching/db"; export type { ContentTypeMetadataValue,