diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e418292e..e5c9ed44 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -49,10 +49,14 @@ jobs: run: | corepack enable corepack prepare yarn@3.4.1 --activate + - name: Start docker container + run: ./dev/up - name: Install dependencies run: yarn - name: Run tests run: yarn test + - name: Stop docker container + run: ./dev/down typecheck: runs-on: ubuntu-latest diff --git a/dev/docker-compose.yml b/dev/docker-compose.yml new file mode 100644 index 00000000..f260de1c --- /dev/null +++ b/dev/docker-compose.yml @@ -0,0 +1,25 @@ +services: + waku-node: + image: xmtp/node-go:latest + platform: linux/amd64 + 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 + - --wait-for-db=30s + - --api.authn.enable + ports: + - 9001:9001 + - 5555:5555 + depends_on: + - db + db: + image: postgres:13 + environment: + POSTGRES_PASSWORD: xmtp diff --git a/dev/down b/dev/down new file mode 100755 index 00000000..9bc48d52 --- /dev/null +++ b/dev/down @@ -0,0 +1,4 @@ +#!/bin/bash +set -e + +docker-compose -p xmtp-js -f dev/docker-compose.yml down diff --git a/dev/up b/dev/up new file mode 100755 index 00000000..84c010ab --- /dev/null +++ b/dev/up @@ -0,0 +1,9 @@ +#!/bin/bash +set -e + +if ! which docker &>/dev/null; then + echo "Docker required to run dev/up. Install it and run this again." + exit 1 +fi + +docker-compose -p xmtp-js -f dev/docker-compose.yml up -d diff --git a/examples/react-quickstart/package.json b/examples/react-quickstart/package.json index 9b93186c..e56cee29 100644 --- a/examples/react-quickstart/package.json +++ b/examples/react-quickstart/package.json @@ -12,11 +12,13 @@ "format:base": "prettier --ignore-path ../../.gitignore", "format:check": "yarn format:base -c .", "format": "yarn format:base -w .", + "quickstart": "yarn dev", "typecheck": "tsc --noEmit" }, "dependencies": { "@heroicons/react": "^2.0.18", "@rainbow-me/rainbowkit": "^0.12.16", + "@xmtp/content-type-remote-attachment": "^1.0.7", "@xmtp/react-components": "workspace:*", "@xmtp/react-sdk": "workspace:*", "ethers": "5.7.2", @@ -27,7 +29,7 @@ "devDependencies": { "@types/react": "^18.2.14", "@types/react-dom": "^18.2.6", - "@vitejs/plugin-react": "^4.0.2", + "@vitejs/plugin-react": "^4.0.4", "@xmtp/tsconfig": "workspace:*", "autoprefixer": "^10.4.14", "eslint": "^8.44.0", @@ -35,6 +37,6 @@ "postcss": "^8.4.25", "postcss-preset-env": "^8.5.1", "typescript": "^5.1.6", - "vite": "^4.4.1" + "vite": "^4.4.9" } } diff --git a/examples/react-quickstart/src/components/App.tsx b/examples/react-quickstart/src/components/App.tsx index ca727202..0e9499c0 100644 --- a/examples/react-quickstart/src/components/App.tsx +++ b/examples/react-quickstart/src/components/App.tsx @@ -10,7 +10,7 @@ const App = () => { // disconnect XMTP client when the wallet changes useEffect(() => { - disconnect(); + void disconnect(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [signer]); diff --git a/examples/react-quickstart/src/components/ConversationCard.tsx b/examples/react-quickstart/src/components/ConversationCard.tsx new file mode 100644 index 00000000..eedc9aac --- /dev/null +++ b/examples/react-quickstart/src/components/ConversationCard.tsx @@ -0,0 +1,26 @@ +import { useLastMessage, type CachedConversation } from "@xmtp/react-sdk"; +import { ConversationPreview } from "@xmtp/react-components"; + +type ConversationCardProps = { + conversation: CachedConversation; + isSelected: boolean; + onConversationClick?: (conversation: CachedConversation) => void; +}; + +export const ConversationCard: React.FC = ({ + conversation, + onConversationClick, + isSelected, +}) => { + const lastMessage = useLastMessage(conversation.topic); + + return ( + + ); +}; diff --git a/examples/react-quickstart/src/components/Conversations.tsx b/examples/react-quickstart/src/components/Conversations.tsx index 5b803688..0fa8dc73 100644 --- a/examples/react-quickstart/src/components/Conversations.tsx +++ b/examples/react-quickstart/src/components/Conversations.tsx @@ -1,13 +1,13 @@ import { useConversations, useStreamConversations } from "@xmtp/react-sdk"; -import type { Conversation } from "@xmtp/react-sdk"; +import type { CachedConversation } from "@xmtp/react-sdk"; import { ChatBubbleLeftIcon } from "@heroicons/react/24/outline"; -import { useCallback, useState } from "react"; -import { ConversationPreviewList } from "@xmtp/react-components"; +import { ConversationList } from "@xmtp/react-components"; import { Notification } from "./Notification"; +import { ConversationCard } from "./ConversationCard"; type ConversationsProps = { - selectedConversation?: Conversation; - onConversationClick?: (conversation: Conversation) => void; + selectedConversation?: CachedConversation; + onConversationClick?: (conversation: CachedConversation) => void; }; const NoConversations: React.FC = () => ( @@ -21,28 +21,23 @@ export const Conversations: React.FC = ({ onConversationClick, selectedConversation, }) => { - const [streamedConversations, setStreamedConversations] = useState< - Conversation[] - >([]); const { conversations, isLoading } = useConversations(); - const onConversation = useCallback( - (conversation: Conversation) => { - // prevent duplicates - if (!conversations.some((convo) => convo.topic === conversation.topic)) { - setStreamedConversations((prev) => [...prev, conversation]); - } - }, - [conversations], - ); - useStreamConversations(onConversation); + useStreamConversations(); + + const previews = conversations.map((conversation) => ( + + )); return ( - } - selectedConversation={selectedConversation} /> ); }; diff --git a/examples/react-quickstart/src/components/Inbox.tsx b/examples/react-quickstart/src/components/Inbox.tsx index 5d926a61..81a0ee67 100644 --- a/examples/react-quickstart/src/components/Inbox.tsx +++ b/examples/react-quickstart/src/components/Inbox.tsx @@ -1,6 +1,6 @@ import "./Inbox.css"; import { useCallback, useState } from "react"; -import type { Conversation } from "@xmtp/react-sdk"; +import { type CachedConversation } from "@xmtp/react-sdk"; import { ArrowRightOnRectangleIcon, PlusCircleIcon, @@ -13,13 +13,13 @@ import { NoSelectedConversationNotification } from "./NoSelectedConversationNoti export const Inbox: React.FC = () => { const { disconnect } = useWallet(); - const [conversation, setConversation] = useState( - undefined, - ); + const [selectedConversation, setSelectedConversation] = useState< + CachedConversation | undefined + >(undefined); const [isNewMessage, setIsNewMessage] = useState(false); - const handleConversationClick = useCallback((convo: Conversation) => { - setConversation(convo); + const handleConversationClick = useCallback((convo: CachedConversation) => { + setSelectedConversation(convo); setIsNewMessage(false); }, []); @@ -28,8 +28,8 @@ export const Inbox: React.FC = () => { }, []); const handleStartNewConversationSuccess = useCallback( - (convo?: Conversation) => { - setConversation(convo); + (convo?: CachedConversation) => { + setSelectedConversation(convo); setIsNewMessage(false); }, [], @@ -64,14 +64,14 @@ export const Inbox: React.FC = () => {
{isNewMessage ? ( - ) : conversation ? ( - + ) : selectedConversation ? ( + ) : ( = ({ conversation, }) => { const [isSending, setIsSending] = useState(false); - const [streamedMessages, setStreamedMessages] = useState( - [], - ); const messageInputRef = useRef(null); const { messages, isLoading } = useMessages(conversation); - const onMessage = useCallback( - (message: DecodedMessage) => { - // prevent duplicates - if (!streamedMessages.some((msg) => msg.id === message.id)) { - setStreamedMessages((prev) => [...prev, message]); - } - }, - [streamedMessages], - ); - useStreamMessages(conversation, onMessage); - const { sendMessage } = useSendMessage(conversation); + const { client } = useClient(); + useStreamMessages(conversation); + const { sendMessage } = useSendMessage(); const handleSendMessage = useCallback( async (message: string) => { setIsSending(true); - await sendMessage(message); + await sendMessage(conversation, message); setIsSending(false); // ensure focus of input by waiting for a browser tick setTimeout(() => messageInputRef.current?.focus(), 0); }, - [sendMessage], + [conversation, sendMessage], ); useEffect(() => { messageInputRef.current?.focus(); - setStreamedMessages([]); }, [conversation]); return ( @@ -59,10 +48,11 @@ export const Messages: React.FC = ({ value={conversation.peerAddress} avatarUrlProps={{ address: conversation.peerAddress }} /> - message.content !== undefined)} + clientAddress={client?.address} />
void; + onSuccess?: (conversation?: CachedConversation) => void; }; export const NewMessage: React.FC = ({ onSuccess }) => { @@ -33,9 +29,11 @@ export const NewMessage: React.FC = ({ onSuccess }) => { async (message: string) => { if (peerAddress && isOnNetwork) { setIsLoading(true); - const conversation = await startConversation(peerAddress, message); + const result = await startConversation(peerAddress, message); setIsLoading(false); - onSuccess?.(conversation); + if (result) { + onSuccess?.(result.cachedConversation); + } } }, [isOnNetwork, onSuccess, peerAddress, startConversation], @@ -84,7 +82,7 @@ export const NewMessage: React.FC = ({ onSuccess }) => { address: isOnNetwork ? peerAddress : "", }} /> - +
= ({ label }) => { const { initialize } = useClient(); const handleConnect = useCallback(() => { - void initialize({ signer }); + void initialize({ + signer, + }); }, [initialize, signer]); return ( diff --git a/examples/react-quickstart/src/globals.d.ts b/examples/react-quickstart/src/globals.d.ts new file mode 100644 index 00000000..97a1e2eb --- /dev/null +++ b/examples/react-quickstart/src/globals.d.ts @@ -0,0 +1,5 @@ +interface ImportMeta { + env: { + VITE_PROJECT_ID: string; + }; +} diff --git a/examples/react-quickstart/src/main.tsx b/examples/react-quickstart/src/main.tsx index 51b12c03..e63b0373 100644 --- a/examples/react-quickstart/src/main.tsx +++ b/examples/react-quickstart/src/main.tsx @@ -6,12 +6,27 @@ import { getDefaultWallets, RainbowKitProvider } from "@rainbow-me/rainbowkit"; import { configureChains, createClient, WagmiConfig } from "wagmi"; import { mainnet } from "wagmi/chains"; import { publicProvider } from "wagmi/providers/public"; -import { XMTPProvider } from "@xmtp/react-sdk"; +import { + XMTPProvider, + attachmentsCacheConfig, + reactionsCacheConfig, + readReceiptsCacheConfig, + repliesCacheConfig, +} from "@xmtp/react-sdk"; import App from "./components/App"; import "@xmtp/react-components/styles.css"; import { WalletProvider } from "./contexts/WalletContext"; import "./index.css"; +const DB_VERSION = 1; + +const cacheConfig = [ + attachmentsCacheConfig, + reactionsCacheConfig, + readReceiptsCacheConfig, + repliesCacheConfig, +]; + const { chains, provider, webSocketProvider } = configureChains( [mainnet], [publicProvider()], @@ -20,7 +35,8 @@ const { chains, provider, webSocketProvider } = configureChains( const { connectors } = getDefaultWallets({ appName: "XMTP React RainbowKit Example", chains, - projectId: "YOUR_PROJECT_ID", // now required for WalletConnect V2 + // now required for WalletConnect V2 + projectId: import.meta.env.VITE_PROJECT_ID, }); const wagmiClient = createClient({ @@ -35,7 +51,7 @@ createRoot(document.getElementById("root") as HTMLElement).render( - + diff --git a/package.json b/package.json index f5df9a8c..550b295b 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "generate:types": "turbo run generate:types", "lint": "FORCE_COLOR=1 turbo run lint", "publish": "yarn build && changeset publish", + "quickstart": "turbo run quickstart", "test": "FORCE_COLOR=1 turbo run test", "typecheck": "FORCE_COLOR=1 turbo run typecheck" }, @@ -29,5 +30,9 @@ "dependencies": { "@changesets/changelog-github": "^0.4.8", "@changesets/cli": "^2.26.2" + }, + "resolutions": { + "@xmtp/xmtp-js": "^10.2.0", + "vite": "^4.4.9" } } diff --git a/packages/eslint-config-xmtp-web/index.js b/packages/eslint-config-xmtp-web/index.js index dbfa9b2f..a9855f98 100644 --- a/packages/eslint-config-xmtp-web/index.js +++ b/packages/eslint-config-xmtp-web/index.js @@ -72,30 +72,17 @@ module.exports = { message: "for..in loops iterate over the entire prototype chain, which is virtually never what you want. Use Object.{keys,values,entries}, and iterate over the resulting array.", }, - { - selector: "ForOfStatement", - message: - "iterators/generators require regenerator-runtime, which is too heavyweight for this guide to allow them. Separately, loops should be avoided in favor of array iterations.", - }, { selector: "WithStatement", message: "`with` is disallowed in strict mode because it makes code impossible to predict and optimize.", }, ], - "no-restricted-syntax": [ - "warn", - { - selector: "ForOfStatement", - message: - "iterators/generators require regenerator-runtime, which is too heavyweight for this guide to allow them. Separately, loops should be avoided in favor of array iterations.", - }, - ], }, overrides: [ // allow devDependencies in configuration files { - files: ["*.ts", "*.js", "*.cjs"], + files: ["*.ts", "*.js", "*.cjs", "**/*.test.tsx", "**/*.test.ts"], rules: { "import/no-extraneous-dependencies": [ "error", diff --git a/packages/react-components/package.json b/packages/react-components/package.json index ab1897a7..5286240d 100644 --- a/packages/react-components/package.json +++ b/packages/react-components/package.json @@ -73,6 +73,9 @@ }, "dependencies": { "@heroicons/react": "^2.0.18", + "@xmtp/content-type-reaction": "^1.0.1", + "@xmtp/content-type-remote-attachment": "^1.0.7", + "@xmtp/content-type-reply": "^1.0.0", "@xmtp/react-sdk": "workspace:*", "date-fns": "^2.30.0", "react": "^18.2.0", @@ -91,7 +94,7 @@ "@testing-library/react": "^14.0.0", "@types/react": "^18.2.14", "@types/react-dom": "^18.2.6", - "@vitejs/plugin-react": "^4.0.2", + "@vitejs/plugin-react": "^4.0.4", "@xmtp/tsconfig": "workspace:*", "eslint": "^8.44.0", "eslint-config-xmtp-web": "workspace:*", @@ -106,8 +109,8 @@ "storybook": "^7.0.26", "tsup": "^7.1.0", "typescript": "^5.1.6", - "vite": "^4.4.1", - "vitest": "^0.33.0" + "vite": "^4.4.9", + "vitest": "^0.34.1" }, "peerDependencies": { "react": ">=16.14" diff --git a/packages/react-components/src/components/Attachment.module.css b/packages/react-components/src/components/Attachment.module.css new file mode 100644 index 00000000..e3784b1f --- /dev/null +++ b/packages/react-components/src/components/Attachment.module.css @@ -0,0 +1,9 @@ +.attachment { + margin-bottom: 4px; + border-radius: 4px; + background-color: #eee; +} + +.attachment { + max-width: 50%; +} diff --git a/packages/react-components/src/components/AttachmentContent.tsx b/packages/react-components/src/components/AttachmentContent.tsx new file mode 100644 index 00000000..d772f2de --- /dev/null +++ b/packages/react-components/src/components/AttachmentContent.tsx @@ -0,0 +1,73 @@ +import type { Attachment } from "@xmtp/content-type-remote-attachment"; +import styles from "./Attachment.module.css"; + +export type AttachmentProps = { + attachment?: Attachment; +}; + +/** + * Creating object URLs from blobs is non-deterministic, so we store the + * generated URLs in a cache so that they can be reused, which results in + * a more consistent rendering of images/data and less memory usage. + */ +const blobCache = new WeakMap(); + +const getBlobURL = (attachment: Attachment) => { + if (!blobCache.get(attachment.data)) { + blobCache.set( + attachment.data, + URL.createObjectURL( + new Blob([Buffer.from(attachment.data)], { + type: attachment.mimeType, + }), + ), + ); + } + + return blobCache.get(attachment.data)!; +}; + +export const AttachmentContent: React.FC = ({ + attachment, +}) => { + if (!attachment) { + return "Loading..."; + } + + const blobURL = getBlobURL(attachment); + + if (attachment.mimeType.startsWith("image/")) { + return ( +
+ +
+ ); + } + + if (attachment.mimeType.startsWith("audio/")) { + return ( + // eslint-disable-next-line jsx-a11y/media-has-caption + + ); + } + + if (attachment.mimeType.startsWith("video/")) { + return ( + // eslint-disable-next-line jsx-a11y/media-has-caption + + ); + } + + return ( + + ); +}; diff --git a/packages/react-components/src/components/ConversationList.stories.tsx b/packages/react-components/src/components/ConversationList.stories.tsx index 379d92bb..8741bc3a 100644 --- a/packages/react-components/src/components/ConversationList.stories.tsx +++ b/packages/react-components/src/components/ConversationList.stories.tsx @@ -1,5 +1,6 @@ import type { ComponentStory, ComponentMeta } from "@storybook/react"; +import type { CachedConversation } from "@xmtp/react-sdk"; import { ConversationList } from "./ConversationList"; import { ConversationPreviewCard } from "./ConversationPreviewCard"; @@ -28,16 +29,39 @@ Loading.args = { isLoading: true, }; +const conversation1 = { + peerAddress: "0x1234", + createdAt: new Date(), + updatedAt: new Date(), + isReady: false, + topic: "topic1", + walletAddress: "walletAddress1", +} satisfies CachedConversation; + +const conversation2 = { + peerAddress: "0x1234", + createdAt: new Date(), + updatedAt: new Date(), + isReady: false, + topic: "topic2", + walletAddress: "walletAddress1", +} satisfies CachedConversation; + +const conversation3 = { + peerAddress: "0x1234", + createdAt: new Date(), + updatedAt: new Date(), + isReady: false, + topic: "topic3", + walletAddress: "walletAddress1", +} satisfies CachedConversation; + export const WithConversationPreviewCard = Template.bind({}); WithConversationPreviewCard.storyName = "With ConversationPreviewCard"; WithConversationPreviewCard.args = { conversations: [ - , - , - , + , + , + , ], }; diff --git a/packages/react-components/src/components/ConversationList.tsx b/packages/react-components/src/components/ConversationList.tsx index 0d7279d0..06825c92 100644 --- a/packages/react-components/src/components/ConversationList.tsx +++ b/packages/react-components/src/components/ConversationList.tsx @@ -1,5 +1,8 @@ -import { ConversationPreviewCard } from "./ConversationPreviewCard"; +import { Avatar } from "./Avatar"; import styles from "./ConversationList.module.css"; +import previewStyles from "./ConversationPreviewCard.module.css"; +import { IconSkeletonLoader } from "./SkeletonLoaders/IconSkeletonLoader"; +import { ShortCopySkeletonLoader } from "./SkeletonLoaders/ShortCopySkeletonLoader"; export type ConversationListProps = { /** @@ -16,19 +19,21 @@ export type ConversationListProps = { renderEmpty?: React.ReactNode; }; -const DefaultEmptyMessage: React.FC = () =>
No conversations
; +const DefaultEmptyMessage: React.FC = () =>
No conversations!
; export const ConversationList: React.FC = ({ conversations = [], isLoading, renderEmpty = , }) => { - if (isLoading) { + if (isLoading && !conversations.length) { return ( -
- {Array.from({ length: 12 }).map((_, idx) => ( - - ))} +
+ +
+ +
+
); } diff --git a/packages/react-components/src/components/ConversationPreviewCard.module.css b/packages/react-components/src/components/ConversationPreviewCard.module.css index c27395a0..8f48e905 100644 --- a/packages/react-components/src/components/ConversationPreviewCard.module.css +++ b/packages/react-components/src/components/ConversationPreviewCard.module.css @@ -50,6 +50,7 @@ composes: text-md from global; word-break: break-all; align-self: stretch; + min-height: 21px; } .time { diff --git a/packages/react-components/src/components/ConversationPreviewCard.stories.tsx b/packages/react-components/src/components/ConversationPreviewCard.stories.tsx index dc1249d6..ab50a1a4 100644 --- a/packages/react-components/src/components/ConversationPreviewCard.stories.tsx +++ b/packages/react-components/src/components/ConversationPreviewCard.stories.tsx @@ -1,4 +1,5 @@ import type { ComponentStory, ComponentMeta } from "@storybook/react"; +import type { CachedConversation } from "@xmtp/react-sdk"; import { ConversationPreviewCard } from "./ConversationPreviewCard"; export default { @@ -15,22 +16,16 @@ const Template: ComponentStory = (args) => ( ); +const conversation1 = { + peerAddress: "0x1234", + createdAt: new Date(), + updatedAt: new Date(), + isReady: false, + topic: "topic1", + walletAddress: "walletAddress1", +} satisfies CachedConversation; + export const Default = Template.bind({}); Default.args = { - text: "Hello there", - displayAddress: "hi.xmtp.eth", - datetime: new Date(2023, 0, 1), -}; - -export const Loading = Template.bind({}); -Loading.args = { - isLoading: true, -}; - -export const WithDomain = Template.bind({}); -WithDomain.args = { - text: "Hello there", - displayAddress: "hi.xmtp.eth", - datetime: new Date(2023, 0, 1), - conversationDomain: "foo", + conversation: conversation1, }; diff --git a/packages/react-components/src/components/ConversationPreviewCard.tsx b/packages/react-components/src/components/ConversationPreviewCard.tsx index c3dcf66e..0e5d9d97 100644 --- a/packages/react-components/src/components/ConversationPreviewCard.tsx +++ b/packages/react-components/src/components/ConversationPreviewCard.tsx @@ -1,108 +1,77 @@ import type { KeyboardEvent } from "react"; import { useCallback } from "react"; import { formatDistanceToNowStrict } from "date-fns"; -import { IconSkeletonLoader } from "./SkeletonLoaders/IconSkeletonLoader"; -import { ShortCopySkeletonLoader } from "./SkeletonLoaders/ShortCopySkeletonLoader"; +import { + type CachedConversation, + type CachedMessage, + getAttachment, +} from "@xmtp/react-sdk"; import { Avatar } from "./Avatar"; import styles from "./ConversationPreviewCard.module.css"; +import { shortAddress } from "../helpers/shortAddress"; export type ConversationPreviewCardProps = { /** - * What is the avatar url? + * Conversation to preview */ - avatarUrl?: string; + conversation: CachedConversation; /** - * What is the message text? + * What is the last message of this conversation? */ - text?: string; - /** - * What is the display address associated with the message? - */ - displayAddress?: string; - /** - * What is the wallet address associated with the message? - */ - address?: string; - /** - * What is the datetime of the message - */ - datetime?: Date; - /** - * Are we waiting on anything loading? - */ - isLoading?: boolean; + lastMessage?: CachedMessage; /** * What happens on message click? */ - onClick?: () => void; + onClick?: (conversation: CachedConversation) => void; /** * Is conversation selected? */ isSelected?: boolean; - /** - * What is the app this conversation started on? - */ - conversationDomain?: string; - // To-do: Add error views once we have the designs }; export const ConversationPreviewCard: React.FC< ConversationPreviewCardProps -> = ({ - avatarUrl, - text, - displayAddress, - address, - datetime, - isLoading = false, - onClick, - isSelected, - conversationDomain, -}) => { +> = ({ conversation, onClick, isSelected, lastMessage }) => { + const attachment = lastMessage ? getAttachment(lastMessage) : undefined; + let content: any; + if (attachment) { + content = attachment.filename; + } else if (typeof lastMessage?.content === "string") { + content = lastMessage.content; + } else if (lastMessage?.contentFallback) { + content = lastMessage.contentFallback; + } const handleKeyDown = useCallback( (event: KeyboardEvent) => { if (event.key === "Enter") { - onClick?.(); + onClick?.(conversation); } }, - [onClick], + [conversation, onClick], ); - // nothing to display - if (!text && !isLoading) { - return null; - } + const handleClick = useCallback(() => { + onClick?.(conversation); + }, [conversation, onClick]); return (
- + onClick={handleClick}> +
- {!isLoading && conversationDomain && ( -
{conversationDomain}
- )} - {isLoading ? ( - - ) : ( - <> -
{displayAddress}
-
{text}
- - )} -
- {isLoading ? ( - - ) : ( -
- {datetime && `${formatDistanceToNowStrict(datetime)} ago`} +
+ {shortAddress(conversation.peerAddress)}
- )} +
{content}
+
+
+ {lastMessage?.sentAt && + `${formatDistanceToNowStrict(lastMessage.sentAt)} ago`} +
); }; diff --git a/packages/react-components/src/components/DateDivider.tsx b/packages/react-components/src/components/DateDivider.tsx index 9b795bbd..6f313eb7 100644 --- a/packages/react-components/src/components/DateDivider.tsx +++ b/packages/react-components/src/components/DateDivider.tsx @@ -10,6 +10,8 @@ export type DateDividerProps = { export const DateDivider: React.FC = ({ date }) => (
-
{format(date, "PPP")}
+
+ {format(date, "PPP")} +
); diff --git a/packages/react-components/src/components/Message.module.css b/packages/react-components/src/components/Message.module.css index d36067b9..ed1cc357 100644 --- a/packages/react-components/src/components/Message.module.css +++ b/packages/react-components/src/components/Message.module.css @@ -1,6 +1,11 @@ .wrapper { display: flex; flex-direction: column; + position: relative; +} + +.wrapper:hover { + background-color: #eee; } .wrapper.left { @@ -19,19 +24,34 @@ margin-bottom: 0.25rem; } -.wrapper.left .content { - background-color: rgb(229 231 235); - border-radius: 0.75rem 0.75rem 0.75rem 0; -} - -.wrapper.right .content { - color: white; - background-color: rgb(79 70 229); - border-radius: 0.75rem 0.75rem 0 0.75rem; -} - .time { color: rgb(107 114 128); font-size: var(--xmtp-font-size-sm); line-height: 1rem; + display: flex; + align-items: center; + gap: 8px; +} + +.readReceipt { + color: #666; +} + +.reactions { + display: none; + position: absolute; + top: 0; + margin-top: -0.5rem; +} + +.wrapper.left .reactions { + right: 0; +} + +.wrapper.right .reactions { + left: 0; +} + +.wrapper:hover .reactions { + display: block; } diff --git a/packages/react-components/src/components/Message.stories.tsx b/packages/react-components/src/components/Message.stories.tsx index 5fdaa45a..71bfa39d 100644 --- a/packages/react-components/src/components/Message.stories.tsx +++ b/packages/react-components/src/components/Message.stories.tsx @@ -1,5 +1,6 @@ import type { ComponentStory, ComponentMeta } from "@storybook/react"; +import { ContentTypeText, type CachedMessage } from "@xmtp/react-sdk"; import { Message } from "./Message"; export default { @@ -7,16 +8,46 @@ export default { } as ComponentMeta; const Template: ComponentStory = (args) => ( - + ); +const message1 = { + id: 1, + content: "Hello!", + contentType: ContentTypeText.toString(), + isSending: false, + hasSendError: false, + sentAt: new Date(2023, 0, 2, 0, 4, 0), + conversationTopic: "topic1", + senderAddress: "0x1234", + status: "processed", + xmtpID: "1", + uuid: "uuid1", + walletAddress: "walletAddress1", +} satisfies CachedMessage; + export const Incoming = Template.bind({}); Incoming.args = { - content: "Hello, world!", + message: message1, isIncoming: true, }; +const message2 = { + id: 2, + content: "Hi!", + contentType: ContentTypeText.toString(), + isSending: false, + hasSendError: false, + sentAt: new Date(2023, 0, 2, 0, 3, 20), + conversationTopic: "topic1", + senderAddress: "0x5678", + status: "processed", + xmtpID: "2", + uuid: "uuid2", + walletAddress: "walletAddress1", +} satisfies CachedMessage; + export const Outgoing = Template.bind({}); Outgoing.args = { - content: "Hello, world!", + message: message2, }; diff --git a/packages/react-components/src/components/Message.tsx b/packages/react-components/src/components/Message.tsx index 30d91352..bfecc87d 100644 --- a/packages/react-components/src/components/Message.tsx +++ b/packages/react-components/src/components/Message.tsx @@ -1,32 +1,49 @@ import { format } from "date-fns"; +import { ContentTypeId } from "@xmtp/react-sdk"; +import type { CachedConversation, CachedMessage } from "@xmtp/react-sdk"; +import { ContentTypeReply } from "@xmtp/content-type-reply"; import styles from "./Message.module.css"; +import { MessageContent } from "./MessageContent"; +import { ReplyContent } from "./ReplyContent"; +import { ReactionsBar } from "./ReactionsBar"; +import { ReactionsContent } from "./ReactionsContent"; export type MessageProps = { + conversation: CachedConversation; /** - * What is the content of the message? + * The message to display */ - content: React.ReactNode; - /** - * What is the datetime of the message? - */ - datetime: Date; + message: CachedMessage; /** * Is this an incoming message? */ isIncoming?: boolean; + isRead?: boolean; }; export const Message: React.FC = ({ - content, - datetime, + conversation, + message, isIncoming, -}) => ( -
-
- {content} + isRead, +}) => { + const contentType = ContentTypeId.fromString(message.contentType); + return ( +
+ {contentType.sameAs(ContentTypeReply) ? ( + + ) : ( + + )} +
+ {isRead && Read} + {format(message.sentAt, "h:mm a")} +
+
+ +
+
-
- {format(datetime, "h:mm a")} -
-
-); + ); +}; diff --git a/packages/react-components/src/components/MessageContent.module.css b/packages/react-components/src/components/MessageContent.module.css new file mode 100644 index 00000000..c9c603c0 --- /dev/null +++ b/packages/react-components/src/components/MessageContent.module.css @@ -0,0 +1,42 @@ +.content { + font-size: var(--xmtp-font-size-md); + padding: 0.5rem 0.75rem; + overflow-wrap: break-word; + white-space: pre-wrap; + margin-bottom: 0.25rem; + position: relative; +} + +.content.left { + background-color: rgb(229 231 235); + border-radius: 0.75rem 0.75rem 0.75rem 0; +} + +.content.right { + color: white; + background-color: rgb(79 70 229); + border-radius: 0.75rem 0.75rem 0 0.75rem; +} + +.original.content { + background-color: white; + border: 1px solid #eee; + margin-bottom: 0.5rem; + font-size: var(--xmtp-font-size-sm); + color: inherit; + opacity: 0.7; + border-radius: 0.75rem; +} + +.original.content::after { + content: ""; + position: absolute; + top: 100%; + left: 100%; + border-radius: 0.25rem; + width: 1rem; + height: 3rem; + margin-top: -1.25rem; + border: 3px solid #ccc; + border-left: 0; +} diff --git a/packages/react-components/src/components/MessageContent.tsx b/packages/react-components/src/components/MessageContent.tsx new file mode 100644 index 00000000..2a9b09b0 --- /dev/null +++ b/packages/react-components/src/components/MessageContent.tsx @@ -0,0 +1,50 @@ +import { ContentTypeId, ContentTypeText, getAttachment } from "@xmtp/react-sdk"; +import type { CachedMessage } from "@xmtp/react-sdk"; +import { + ContentTypeAttachment, + ContentTypeRemoteAttachment, +} from "@xmtp/content-type-remote-attachment"; +import { AttachmentContent } from "./AttachmentContent"; +import styles from "./MessageContent.module.css"; + +export type MessageContentProps = { + message: CachedMessage; + isIncoming?: boolean; + isRepliedTo?: boolean; +}; + +export const MessageContent: React.FC = ({ + message, + isIncoming, + isRepliedTo, +}) => { + const contentType = ContentTypeId.fromString(message.contentType); + let content: any; + + // text messages + if (contentType.sameAs(ContentTypeText)) { + if (typeof message.content === "string") + content = + typeof message.content === "string" ? message.content : undefined; + } + + // attachment messages + if ( + contentType.sameAs(ContentTypeAttachment) || + contentType.sameAs(ContentTypeRemoteAttachment) + ) { + content = ; + } + + return ( +
+ {content ?? + message.contentFallback ?? + "This content is not supported by this client"} +
+ ); +}; diff --git a/packages/react-components/src/components/Messages.module.css b/packages/react-components/src/components/Messages.module.css index e0e25635..85ff8cee 100644 --- a/packages/react-components/src/components/Messages.module.css +++ b/packages/react-components/src/components/Messages.module.css @@ -1,13 +1,13 @@ .wrapper { display: flex; - flex-direction: column-reverse; + flex-direction: column; padding: 2rem 1rem 1rem 1rem; gap: 0.5rem; } .loading { display: flex; - flex-direction: column-reverse; + flex-direction: column; justify-content: flex-start; padding: 1rem; } diff --git a/packages/react-components/src/components/Messages.stories.tsx b/packages/react-components/src/components/Messages.stories.tsx index 625cfd12..e3d22cc6 100644 --- a/packages/react-components/src/components/Messages.stories.tsx +++ b/packages/react-components/src/components/Messages.stories.tsx @@ -1,6 +1,7 @@ import type { ComponentStory, ComponentMeta } from "@storybook/react"; -import { ContentTypeId } from "@xmtp/react-sdk"; +import type { CachedMessage } from "@xmtp/react-sdk"; +import { ContentTypeText } from "@xmtp/react-sdk"; import { Messages } from "./Messages"; export default { @@ -11,12 +12,35 @@ const Template: ComponentStory = (args) => ( ); -const mockContentType = new ContentTypeId({ - authorityId: "test-authorityId", - typeId: "test-typeId", - versionMajor: 1, - versionMinor: 1, -}); +const message1 = { + id: 1, + content: "Hello!", + contentType: ContentTypeText.toString(), + isSending: false, + hasSendError: false, + sentAt: new Date(2023, 0, 2, 0, 4, 0), + conversationTopic: "topic1", + senderAddress: "0x1234", + status: "processed", + xmtpID: "1", + uuid: "uuid1", + walletAddress: "walletAddress1", +} satisfies CachedMessage; + +const message2 = { + id: 2, + content: "Hi!", + contentType: ContentTypeText.toString(), + isSending: false, + hasSendError: false, + sentAt: new Date(2023, 0, 2, 0, 3, 20), + conversationTopic: "topic1", + senderAddress: "0x5678", + status: "processed", + xmtpID: "2", + uuid: "uuid2", + walletAddress: "walletAddress1", +} satisfies CachedMessage; export const Loading = Template.bind({}); Loading.args = { @@ -26,60 +50,5 @@ Loading.args = { export const WithMessages = Template.bind({}); WithMessages.args = { clientAddress: "foo", - messages: [ - { - contentTopic: "", - content: "cool", - contentType: mockContentType, - id: "message6", - recipientAddress: "foo", - senderAddress: "bar", - sent: new Date(2023, 0, 2, 0, 4, 0), - }, - { - contentTopic: "", - content: "cool", - contentType: mockContentType, - id: "message5", - recipientAddress: "bar", - senderAddress: "foo", - sent: new Date(2023, 0, 2, 0, 3, 20), - }, - { - contentTopic: "", - content: "same", - contentType: mockContentType, - id: "message4", - recipientAddress: "foo", - senderAddress: "bar", - sent: new Date(2023, 0, 1, 23, 59, 10), - }, - { - contentTopic: "", - content: "chillin", - contentType: mockContentType, - id: "message3", - recipientAddress: "bar", - senderAddress: "foo", - sent: new Date(2023, 0, 1, 23, 59, 0), - }, - { - contentTopic: "", - content: "sup?", - contentType: mockContentType, - id: "message2", - recipientAddress: "foo", - senderAddress: "bar", - sent: new Date(2023, 0, 1, 23, 58, 5), - }, - { - contentTopic: "", - content: "hey", - contentType: mockContentType, - id: "message1", - recipientAddress: "bar", - senderAddress: "foo", - sent: new Date(2023, 0, 1, 23, 58, 0), - }, - ], + messages: [message1, message2], }; diff --git a/packages/react-components/src/components/Messages.tsx b/packages/react-components/src/components/Messages.tsx index 46362ee0..7a7b4585 100644 --- a/packages/react-components/src/components/Messages.tsx +++ b/packages/react-components/src/components/Messages.tsx @@ -1,24 +1,17 @@ -import type { DecodedMessage } from "@xmtp/react-sdk"; -import { isSameDay } from "date-fns"; -import { Fragment } from "react"; +import { isAfter, isBefore, isSameDay } from "date-fns"; +import { Fragment, useMemo } from "react"; +import { + getReadReceipt, + type CachedConversation, + type CachedMessage, +} from "@xmtp/react-sdk"; import { MessageSkeletonLoader } from "./SkeletonLoaders/MessageSkeletonLoader"; import { Message } from "./Message"; import { DateDivider } from "./DateDivider"; import styles from "./Messages.module.css"; -export type MessageData = Pick< - DecodedMessage, - | "content" - | "contentTopic" - | "contentType" - | "error" - | "id" - | "recipientAddress" - | "senderAddress" - | "sent" ->; - export type MessagesProps = { + conversation: CachedConversation; /** * What's the client's wallet address? */ @@ -30,18 +23,36 @@ export type MessagesProps = { /** * What messages should be displayed? */ - messages?: MessageData[]; + messages?: CachedMessage[]; }; +// TODO: account for messages sent at the same time +const hasMessageReadAfter = ( + messages: CachedMessage[], + afterSent: Date, + readReceipt: Date, +) => + messages.some( + (message) => + isAfter(message.sentAt, afterSent) && + isBefore(message.sentAt, readReceipt), + ); + export const Messages: React.FC = ({ clientAddress = "", + conversation, isLoading = false, messages = [], }) => { - if (isLoading) { + const outgoingMessages = useMemo( + () => messages.filter((message) => message.senderAddress === clientAddress), + [messages, clientAddress], + ); + + if (isLoading && !messages.length) { return (
- {Array.from({ length: 2 }).map((_, idx) => ( + {Array.from({ length: 3 }).map((_, idx) => ( @@ -51,52 +62,60 @@ export const Messages: React.FC = ({ } const renderedDates: Date[] = []; + const readReceipt = getReadReceipt(conversation); return (
{messages.map((message, idx, filteredMessages) => { if (renderedDates.length === 0) { - renderedDates.push(message.sent); + renderedDates.push(message.sentAt); } const lastRenderedDate = renderedDates.at(-1) as Date; const isIncoming = message.senderAddress !== clientAddress; + const isOutgoing = message.senderAddress === clientAddress; + const isFirstMessage = idx === 0; const isLastMessage = idx === filteredMessages.length - 1; - const isSameDate = isSameDay(lastRenderedDate, message.sent); - const shouldDisplayDate = isLastMessage || !isSameDate; + const isSameDate = isSameDay(lastRenderedDate, message.sentAt); + const shouldDisplayDate = + isFirstMessage || isLastMessage || !isSameDate; if (shouldDisplayDate && !isLastMessage) { - renderedDates.push(message.sent); + renderedDates.push(message.sentAt); } + // determine if this message should display a read receipt, which + // we only want to display on the last read outgoing message + const isRead = + // conversation must have a valid read receipt, and... + readReceipt && + // this message must be outgoing, and... + isOutgoing && + // this message must be sent before the read receipt, and... + isBefore(message.sentAt, readReceipt) && + // this message is the last message, or... + (isLastMessage || + // the next outgoing message was sent after the read receipt + !hasMessageReadAfter( + outgoingMessages, + message.sentAt, + readReceipt, + )); + return ( - {shouldDisplayDate && - renderedDates.length > 1 && - (!isLastMessage || (isLastMessage && !isSameDate)) && ( - - )} + {shouldDisplayDate && ( + + )} - {shouldDisplayDate && isLastMessage && ( - - )} ); })} -
- This is the beginning of the conversation -
); }; diff --git a/packages/react-components/src/components/ReactionsBar.module.css b/packages/react-components/src/components/ReactionsBar.module.css new file mode 100644 index 00000000..64693561 --- /dev/null +++ b/packages/react-components/src/components/ReactionsBar.module.css @@ -0,0 +1,26 @@ +.wrapper { + padding: 0.25rem; + background-color: #eee; + border: 1px solid #ccc; + border-radius: 0.5rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.option { + border-radius: 0.5rem; + display: flex; + align-items: center; + appearance: none; + border: 0; + padding: 0.25rem; +} + +.option:hover { + border-radius: 0.5rem; + display: flex; + align-items: center; + background-color: #ddd; + cursor: pointer; +} diff --git a/packages/react-components/src/components/ReactionsBar.tsx b/packages/react-components/src/components/ReactionsBar.tsx new file mode 100644 index 00000000..98aeb0e7 --- /dev/null +++ b/packages/react-components/src/components/ReactionsBar.tsx @@ -0,0 +1,53 @@ +import { useSendMessage } from "@xmtp/react-sdk"; +import type { CachedMessage, CachedConversation } from "@xmtp/react-sdk"; +import { useCallback } from "react"; +import { + ContentTypeReaction, + type Reaction, +} from "@xmtp/content-type-reaction"; +import styles from "./ReactionsBar.module.css"; + +export type ReactionsBarProps = { + conversation: CachedConversation; + message: CachedMessage; +}; + +const availableReactionEmojis = ["👍", "👎", "❤️"]; + +export const ReactionsBar: React.FC = ({ + conversation, + message, +}) => { + const { sendMessage } = useSendMessage(); + const handleClick = useCallback( + (emoji: string) => { + void sendMessage( + conversation, + { + content: emoji, + schema: "unicode", + reference: message.xmtpID, + action: "added", + }, + { + contentType: ContentTypeReaction, + }, + ); + }, + [conversation, message.xmtpID, sendMessage], + ); + + return ( +
+ {availableReactionEmojis.map((emoji) => ( + + ))} +
+ ); +}; diff --git a/packages/react-components/src/components/ReactionsContent.module.css b/packages/react-components/src/components/ReactionsContent.module.css new file mode 100644 index 00000000..9497948a --- /dev/null +++ b/packages/react-components/src/components/ReactionsContent.module.css @@ -0,0 +1,31 @@ +.wrapper { + padding: 0.25rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.option { + border-radius: 0.5rem; + background-color: #fefefe; + display: flex; + align-items: center; + appearance: none; + padding: 0.25rem; + cursor: pointer; + border: 0; +} + +.option:hover { + background-color: #ddd; +} + +.count { + font-size: var(--xmtp-font-size-sm); + font-weight: bold; +} + +.active { + background-color: #eee; + border: 1px solid #ccc; +} diff --git a/packages/react-components/src/components/ReactionsContent.tsx b/packages/react-components/src/components/ReactionsContent.tsx new file mode 100644 index 00000000..0acf342d --- /dev/null +++ b/packages/react-components/src/components/ReactionsContent.tsx @@ -0,0 +1,107 @@ +import { + type CachedConversation, + type CachedMessage, + useSendMessage, + useClient, + useReactions, +} from "@xmtp/react-sdk"; +import { useCallback, useMemo } from "react"; +import type { Reaction } from "@xmtp/content-type-reaction"; +import { ContentTypeReaction } from "@xmtp/content-type-reaction"; +import styles from "./ReactionsContent.module.css"; + +export type ReactionsContentProps = { + conversation: CachedConversation; + message: CachedMessage; +}; + +const availableReactionEmojis = ["👍", "👎", "❤️"]; + +export const ReactionsContent: React.FC = ({ + conversation, + message, +}) => { + const { client } = useClient(); + const { sendMessage } = useSendMessage(); + const reactions = useReactions(message); + + const emojiReactions = useMemo( + () => + reactions + .filter((reaction) => reaction.schema === "unicode") + .reduce( + (acc, reaction) => { + const count = (acc?.[reaction.content]?.count ?? 0) + 1; + const senderAddresses = + acc?.[reaction.content]?.senderAddresses ?? []; + return { + ...acc, + [reaction.content]: { + count, + senderAddresses: [...senderAddresses, reaction.senderAddress], + }, + }; + }, + {} as Record< + string, + { + count: number; + senderAddresses: string[]; + } + >, + ), + [reactions], + ); + + const emojiCount = useCallback( + (emoji: string) => emojiReactions[emoji]?.count ?? 0, + [emojiReactions], + ); + + const handleClick = useCallback( + (emoji: string) => { + const hasReacted = emojiReactions[emoji].senderAddresses.includes( + client?.address ?? "", + ); + void sendMessage( + conversation, + { + content: emoji, + schema: "unicode", + reference: message.xmtpID, + action: hasReacted ? "removed" : "added", + }, + { + contentType: ContentTypeReaction, + }, + ); + }, + [ + client?.address, + conversation, + emojiReactions, + message.xmtpID, + sendMessage, + ], + ); + + return ( + reactions.length > 0 && ( +
+ {availableReactionEmojis.map((emoji) => { + const count = emojiCount(emoji); + return count > 0 ? ( + + ) : null; + })} +
+ ) + ); +}; diff --git a/packages/react-components/src/components/ReplyContent.tsx b/packages/react-components/src/components/ReplyContent.tsx new file mode 100644 index 00000000..2d8c2ca7 --- /dev/null +++ b/packages/react-components/src/components/ReplyContent.tsx @@ -0,0 +1,40 @@ +import type { CachedMessage } from "@xmtp/react-sdk"; +import { ContentTypeId, useReply } from "@xmtp/react-sdk"; +import type { Reply } from "@xmtp/content-type-reply"; +import { MessageContent } from "./MessageContent"; + +export type ReplyProps = { + message: CachedMessage; + isIncoming?: boolean; +}; + +export const ReplyContent: React.FC = ({ message, isIncoming }) => { + const { originalMessage } = useReply(message); + + const reply = message.content as Reply; + const replyMessage = { + ...message, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + content: reply.content, + contentType: new ContentTypeId(reply.contentType).toString(), + } satisfies CachedMessage; + + return ( + <> +
+ {originalMessage ? ( + + ) : ( + "Loading original message..." + )} +
+
+ +
+ + ); +}; diff --git a/packages/react-components/src/controllers/ConversationMessages.tsx b/packages/react-components/src/controllers/ConversationMessages.tsx deleted file mode 100644 index b748c938..00000000 --- a/packages/react-components/src/controllers/ConversationMessages.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import type { DecodedMessage } from "@xmtp/react-sdk"; -import { isAfter, isBefore } from "date-fns"; -import { Messages } from "../components/Messages"; - -export type ConversationMessagesProps = { - /** - * What's the client's wallet address? - */ - clientAddress?: string; - /** - * Are the messages loading? - */ - isLoading?: boolean; - /** - * What messages should be displayed? - */ - messages?: DecodedMessage[]; -}; - -/** - * This component filters empty messages, sorts them by most recent, and then - * converts them into a more generic format to be consumed by the Messages - * component. - */ -export const ConversationMessages: React.FC = ({ - clientAddress = "", - isLoading = false, - messages = [], -}) => ( - message.content) - // sort by most recent - .sort((a, b) => { - if (isBefore(a.sent, b.sent)) { - return 1; - } - if (isAfter(a.sent, b.sent)) { - return -1; - } - return 0; - }) - .map( - ({ - content, - contentTopic, - contentType, - error, - id, - recipientAddress, - senderAddress, - sent, - }) => ({ - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - content, - contentTopic, - contentType, - error, - id, - recipientAddress, - senderAddress, - sent, - }), - )} - /> -); diff --git a/packages/react-components/src/controllers/ConversationPreview.tsx b/packages/react-components/src/controllers/ConversationPreview.tsx index b6289695..df3d6a20 100644 --- a/packages/react-components/src/controllers/ConversationPreview.tsx +++ b/packages/react-components/src/controllers/ConversationPreview.tsx @@ -1,14 +1,12 @@ -import type { Conversation } from "@xmtp/react-sdk"; -import { SortDirection } from "@xmtp/react-sdk"; -import { useCallback, useEffect, useState } from "react"; +import type { CachedConversation, CachedMessage } from "@xmtp/react-sdk"; +import { useCallback } from "react"; import { ConversationPreviewCard } from "../components/ConversationPreviewCard"; -import { shortAddress } from "../helpers/shortAddress"; export type ConversationPreviewProps = { /** * Conversation to preview */ - conversation: Conversation; + conversation: CachedConversation; /** * Is conversation selected? */ @@ -16,7 +14,11 @@ export type ConversationPreviewProps = { /** * What happens when you click on the conversation? */ - onClick?: (conversation: Conversation) => void; + onClick?: (conversation: CachedConversation) => void; + /** + * Preview text to display + */ + lastMessage?: CachedMessage; }; /** @@ -27,36 +29,18 @@ export const ConversationPreview: React.FC = ({ conversation, isSelected, onClick, + lastMessage, }) => { - const [message, setMessage] = useState(""); - const [isLoading, setIsLoading] = useState(false); - - useEffect(() => { - const fetchMostRecentMessage = async () => { - setIsLoading(true); - const messages = await conversation.messages({ - limit: 1, - direction: SortDirection.SORT_DIRECTION_DESCENDING, - }); - setIsLoading(false); - setMessage(messages.length > 0 ? (messages[0].content as string) : ""); - }; - void fetchMostRecentMessage(); - }, [conversation]); - const handlePreviewClick = useCallback(() => { onClick?.(conversation); }, [conversation, onClick]); return ( ); }; diff --git a/packages/react-components/src/controllers/ConversationPreviewList.tsx b/packages/react-components/src/controllers/ConversationPreviewList.tsx index de829b3f..d0adbe93 100644 --- a/packages/react-components/src/controllers/ConversationPreviewList.tsx +++ b/packages/react-components/src/controllers/ConversationPreviewList.tsx @@ -1,5 +1,4 @@ -import type { Conversation } from "@xmtp/react-sdk"; -import { isAfter, isBefore } from "date-fns"; +import type { CachedConversation } from "@xmtp/react-sdk"; import { ConversationPreview } from "./ConversationPreview"; import type { ConversationListProps } from "../components/ConversationList"; import { ConversationList } from "../components/ConversationList"; @@ -11,15 +10,15 @@ export type ConversationPreviewListProps = Pick< /** * What conversations should we render? */ - conversations?: Conversation[]; + conversations?: CachedConversation[]; /** * What happens when a conversation is clicked? */ - onConversationClick?: (conversation: Conversation) => void; + onConversationClick?: (conversation: CachedConversation) => void; /** * What, if any, conversation is selected */ - selectedConversation?: Conversation; + selectedConversation?: CachedConversation; }; /** @@ -35,25 +34,14 @@ export const ConversationPreviewList: React.FC< renderEmpty, selectedConversation, }) => { - const conversationPreviews = conversations - // order by most recent - .sort((a, b) => { - if (isBefore(a.createdAt, b.createdAt)) { - return 1; - } - if (isAfter(a.createdAt, b.createdAt)) { - return -1; - } - return 0; - }) - .map((conversation) => ( - - )); + const conversationPreviews = conversations.map((conversation) => ( + + )); return ( ", "license": "MIT", "type": "module", @@ -71,29 +71,42 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@xmtp/xmtp-js": "^9.2.0", + "@xmtp/content-type-reaction": "^1.0.1", + "@xmtp/content-type-read-receipt": "^1.0.0", + "@xmtp/content-type-remote-attachment": "^1.0.7", + "@xmtp/content-type-reply": "^1.0.0", + "@xmtp/xmtp-js": "^10.2.0", + "async-mutex": "^0.4.0", "date-fns": "^2.30.0", "dexie": "^3.2.4", - "react": "^18.2.0" + "dexie-react-hooks": "^1.1.6", + "react": "^18.2.0", + "uuid": "^9.0.0" }, "devDependencies": { + "@testing-library/jest-dom": "^6.0.0", "@testing-library/react": "^14.0.0", "@types/react": "^18.2.14", "@types/react-dom": "^18.2.6", - "@vitejs/plugin-react": "^4.0.2", + "@types/uuid": "^9.0.2", + "@vitejs/plugin-react": "^4.0.4", + "@vitest/coverage-v8": "^0.34.1", "@xmtp/tsconfig": "workspace:*", "eslint": "^8.44.0", "eslint-config-xmtp-web": "workspace:*", + "ethers": "^6.7.0", + "fake-indexeddb": "^4.0.2", "jsdom": "^21.1.2", "prettier": "^3.0.0", "react-dom": "^18.2.0", "tsup": "^7.1.0", "typescript": "^5.1.6", - "vite": "^4.4.1", - "vitest": "^0.33.0" + "vite": "^4.4.9", + "vite-tsconfig-paths": "^4.2.0", + "vitest": "^0.34.1" }, "peerDependencies": { - "@xmtp/xmtp-js": "^9.1.6", + "@xmtp/xmtp-js": "^10.1.0", "react": ">=16.14" }, "engines": { diff --git a/packages/react-sdk/src/contexts/XMTPContext.test.tsx b/packages/react-sdk/src/contexts/XMTPContext.test.tsx new file mode 100644 index 00000000..d4e5a6ce --- /dev/null +++ b/packages/react-sdk/src/contexts/XMTPContext.test.tsx @@ -0,0 +1,25 @@ +import { it, expect, describe } from "vitest"; +import { render } from "@testing-library/react"; +import type { PropsWithChildren } from "react"; +import type { XMTPProviderProps } from "@/contexts/XMTPContext"; +import { XMTPProvider } from "@/contexts/XMTPContext"; + +type TestWrapperProps = PropsWithChildren & + Pick; + +const TestWrapper: React.FC = ({ + cacheConfig, + children, + dbVersion, +}) => ( + + {children} + +); + +describe("XMTPProvider", () => { + it("should render", () => { + const { getByText } = render(test); + expect(getByText("test")).toBeInTheDocument(); + }); +}); diff --git a/packages/react-sdk/src/contexts/XMTPContext.tsx b/packages/react-sdk/src/contexts/XMTPContext.tsx index f6cdd931..27f6f3e5 100644 --- a/packages/react-sdk/src/contexts/XMTPContext.tsx +++ b/packages/react-sdk/src/contexts/XMTPContext.tsx @@ -1,157 +1,123 @@ -import { useState, createContext, useCallback, useMemo, useRef } from "react"; -import type { ClientOptions, Signer } from "@xmtp/xmtp-js"; -import { Client } from "@xmtp/xmtp-js"; -import type { CanMessageReturns, OnError } from "../sharedTypes"; - -export type InitClientArgs = { - keys?: Uint8Array; - options?: Partial & OnError; - signer?: Signer | null; -}; +import { createContext, useMemo, useState } from "react"; +import type { Client, ContentCodec, Signer } from "@xmtp/xmtp-js"; +import Dexie from "dexie"; +import type { + CacheConfiguration, + CachedMessageProcessors, +} from "@/helpers/caching/db"; +import { getDbInstance } from "@/helpers/caching/db"; +import { combineNamespaces } from "@/helpers/combineNamespaces"; +import { combineMessageProcessors } from "@/helpers/combineMessageProcessors"; +import { combineCodecs } from "@/helpers/combineCodecs"; export type XMTPContextValue = { /** - * XMTP client error + * The XMTP client instance */ - error: unknown; + client?: Client; /** - * Check if a wallet address is on the XMTP network + * Content codecs used by the XMTP client */ - canMessage: ( - peerAddress: T, - ) => Promise>; + codecs: ContentCodec[]; /** - * XMTP JS Client + * Local DB instance */ - client?: Client; + db: Dexie; /** - * Disconnect the XMTP client + * Namespaces for content types */ - closeClient: () => void; + namespaces: Record; /** - * Initialize the XMTP client + * Message processors for caching */ - initClient: (arg0: InitClientArgs) => Promise; + processors: CachedMessageProcessors; + setClient: React.Dispatch>; + setClientSigner: React.Dispatch>; /** - * Loading state when the XMTP client is busy + * The signer (wallet) to associate with the XMTP client */ - isLoading: boolean; - /** - * The signer (wallet) associated with the XMTP client - */ - signer?: Signer; + signer?: Signer | null; }; +const initialDb = new Dexie("__XMTP__"); + export const XMTPContext = createContext({ - canMessage: () => Promise.resolve(false) as Promise>, - client: undefined, - closeClient: () => {}, - error: null, - initClient: () => Promise.resolve(undefined), - isLoading: false, + codecs: [], + db: initialDb, + namespaces: {}, + processors: {}, + setClient: () => {}, + setClientSigner: () => {}, }); -export const XMTPProvider: React.FC = ({ +export type XMTPProviderProps = React.PropsWithChildren & { + /** + * Initial XMTP client instance + */ + client?: Client; + /** + * An array of cache configurations to support the caching of messages + */ + cacheConfig?: CacheConfiguration[]; + /** + * Database version to use for the local cache + * + * This number should be incremented when adding support for additional + * content types + */ + dbVersion?: number; +}; + +export const XMTPProvider: React.FC = ({ children, + client: initialClient, + cacheConfig, + dbVersion, }) => { - const [client, setClient] = useState(undefined); + const [client, setClient] = useState(initialClient); const [clientSigner, setClientSigner] = useState( undefined, ); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const initializingRef = useRef(false); - - // initialize the XMTP client - const initClient = useCallback( - async ({ keys, options, signer }: InitClientArgs) => { - // client already exists, don't re-initialize - 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; + // combine all processors into a single object + const processors = useMemo( + () => combineMessageProcessors(cacheConfig ?? []), + [cacheConfig], + ); - setError(null); - setIsLoading(true); + // combine all codecs into a single array + const codecs = useMemo(() => combineCodecs(cacheConfig ?? []), [cacheConfig]); - try { - // create a new XMTP client with the provided keys, or a wallet - const xmtpClient = await Client.create(keys ? null : signer, { - ...options, - privateKeyOverride: keys, - }); - setClient(xmtpClient); - setClientSigner(signer); - return xmtpClient; - } catch (e) { - setClient(undefined); - setClientSigner(undefined); - setError(e); - options?.onError?.(e); - // re-throw error for upstream consumption - throw e; - } finally { - setIsLoading(false); - initializingRef.current = false; - } - } - return client; - }, - [client], + // combine all namespaces into a single object + const namespaces = useMemo( + () => combineNamespaces(cacheConfig ?? []), + [cacheConfig], ); - // close the XMTP client - const closeClient = useCallback(async () => { - if (client) { - await client.close(); - setClient(undefined); - setClientSigner(undefined); - } - }, [client]); - - // check if the client can message an address - const canMessage = useCallback( - async ( - peerAddress: T, - ): Promise> => { - if (!client) { - return typeof peerAddress === "string" - ? (false as CanMessageReturns) - : (Array.from({ length: peerAddress.length }).fill( - false, - ) as CanMessageReturns); - } - return typeof peerAddress === "string" - ? (client.canMessage(peerAddress) as Promise>) - : (client.canMessage(peerAddress) as Promise>); - }, - [client], + // DB instance for caching + const db = useMemo( + () => + getDbInstance({ + db: initialDb, + cacheConfig, + version: dbVersion, + }), + [dbVersion, cacheConfig], ); // memo-ize the context value to prevent unnecessary re-renders const value = useMemo( () => ({ - canMessage, client, - closeClient, - error, - initClient, - isLoading, + codecs, + db, + namespaces, + processors, + setClient, + setClientSigner, signer: clientSigner, }), - [ - canMessage, - client, - clientSigner, - closeClient, - error, - initClient, - isLoading, - ], + [client, clientSigner, codecs, db, namespaces, processors], ); return {children}; diff --git a/packages/react-sdk/src/helpers/adjustDate.test.ts b/packages/react-sdk/src/helpers/adjustDate.test.ts new file mode 100644 index 00000000..7d56b567 --- /dev/null +++ b/packages/react-sdk/src/helpers/adjustDate.test.ts @@ -0,0 +1,16 @@ +import { it, expect, describe } from "vitest"; +import { adjustDate } from "@/helpers/adjustDate"; + +describe("adjustDate", () => { + it("should should increase the date by the given amount of milliseconds", () => { + const date = new Date(); + const newDate = adjustDate(date, 1000); + expect(newDate.getTime()).toBe(date.getTime() + 1000); + }); + + it("should should decrease the date by the given amount of milliseconds", () => { + const date = new Date(); + const newDate = adjustDate(date, -1000); + expect(newDate.getTime()).toBe(date.getTime() - 1000); + }); +}); diff --git a/packages/react-sdk/src/helpers/adjustDate.ts b/packages/react-sdk/src/helpers/adjustDate.ts index 2f24cde8..7d17c41e 100644 --- a/packages/react-sdk/src/helpers/adjustDate.ts +++ b/packages/react-sdk/src/helpers/adjustDate.ts @@ -1,3 +1,10 @@ +/** + * Adjust the date by the given amount of milliseconds + * + * @param date The data to adjust + * @param change The change in milliseconds to apply to the date + * @returns The new adjusted date + */ export const adjustDate = (date: Date, change: number) => { const newDate = new Date(date); newDate.setMilliseconds(date.getMilliseconds() + change); diff --git a/packages/react-sdk/src/helpers/caching/contentTypes/attachment.test.ts b/packages/react-sdk/src/helpers/caching/contentTypes/attachment.test.ts new file mode 100644 index 00000000..af5f2227 --- /dev/null +++ b/packages/react-sdk/src/helpers/caching/contentTypes/attachment.test.ts @@ -0,0 +1,307 @@ +import { it, expect, describe, vi } from "vitest"; +import type { + Attachment, + RemoteAttachment, +} from "@xmtp/content-type-remote-attachment"; +import { + AttachmentCodec, + ContentTypeAttachment, + ContentTypeRemoteAttachment, + RemoteAttachmentCodec, +} from "@xmtp/content-type-remote-attachment"; +import { Client, ContentTypeText } from "@xmtp/xmtp-js"; +import { Wallet } from "ethers"; +import { + getAttachment, + hasAttachment, + processAttachment, + processRemoteAttachment, + attachmentsCacheConfig, +} from "./attachment"; +import { type CachedMessageWithId } from "@/helpers/caching/messages"; +import { getDbInstance } from "@/helpers/caching/db"; +import type { CachedConversationWithId } from "@/helpers/caching/conversations"; + +const testWallet = Wallet.createRandom(); +const db = getDbInstance({ + cacheConfig: [attachmentsCacheConfig], +}); + +describe("ContentTypeRemoteAttachment caching", () => { + it("should have the correct cache config", () => { + expect(attachmentsCacheConfig.namespace).toEqual("attachment"); + expect(attachmentsCacheConfig.codecs?.length).toEqual(2); + expect(attachmentsCacheConfig.codecs?.[0]).toBeInstanceOf(AttachmentCodec); + expect(attachmentsCacheConfig.codecs?.[1]).toBeInstanceOf( + RemoteAttachmentCodec, + ); + expect( + attachmentsCacheConfig.processors[ContentTypeAttachment.toString()], + ).toEqual([processAttachment]); + expect( + attachmentsCacheConfig.processors[ContentTypeRemoteAttachment.toString()], + ).toEqual([processRemoteAttachment]); + }); + + describe("processAttachment", () => { + it("should save a message to the cache with attachment metadata", async () => { + const testClient = await Client.create(testWallet, { env: "local" }); + const testConversation = { + id: 1, + createdAt: new Date(), + updatedAt: new Date(), + isReady: false, + topic: "testTopic", + peerAddress: "testPeerAddress", + walletAddress: testWallet.address, + } satisfies CachedConversationWithId; + const testMessage = { + id: 1, + walletAddress: testWallet.address, + conversationTopic: "testTopic", + content: { + filename: "testFilename", + mimeType: "testMimeType", + data: new Uint8Array(), + }, + contentType: ContentTypeAttachment.toString(), + isSending: false, + hasSendError: false, + sentAt: new Date(), + status: "unprocessed", + senderAddress: "testWalletAddress", + uuid: "testUuid", + xmtpID: "testXmtpId", + } satisfies CachedMessageWithId; + + const persist = vi.fn(); + const updateConversationMetadata = vi.fn(); + await processAttachment({ + client: testClient, + conversation: testConversation, + db, + message: testMessage, + persist, + updateConversationMetadata, + processors: attachmentsCacheConfig.processors, + }); + expect(persist).toHaveBeenCalledWith({ + metadata: testMessage.content, + }); + }); + + it("should not process a message with the wrong content type", async () => { + const testClient = await Client.create(testWallet, { env: "local" }); + const testConversation = { + id: 1, + createdAt: new Date(), + updatedAt: new Date(), + isReady: false, + topic: "testTopic", + peerAddress: "testPeerAddress", + walletAddress: testWallet.address, + } satisfies CachedConversationWithId; + const testMessage = { + id: 1, + walletAddress: testWallet.address, + conversationTopic: "testTopic", + content: "test", + contentType: ContentTypeText.toString(), + isSending: false, + hasSendError: false, + sentAt: new Date(), + status: "unprocessed", + senderAddress: "testWalletAddress", + uuid: "testUuid", + xmtpID: "testXmtpId", + } satisfies CachedMessageWithId; + + const persist = vi.fn(); + const updateConversationMetadata = vi.fn(); + await processAttachment({ + client: testClient, + conversation: testConversation, + db, + message: testMessage, + persist, + updateConversationMetadata, + processors: attachmentsCacheConfig.processors, + }); + expect(persist).not.toHaveBeenCalled(); + }); + }); + + describe("processRemoteAttachment", () => { + it("should save a message to the cache with attachment metadata", async () => { + const testMetadata = { + filename: "testFilename", + mimeType: "testMimeType", + data: new Uint8Array(), + } satisfies Attachment; + const spy = vi + .spyOn(RemoteAttachmentCodec, "load") + .mockImplementationOnce(async () => Promise.resolve(testMetadata)); + const testClient = await Client.create(testWallet, { env: "local" }); + const testConversation = { + id: 1, + createdAt: new Date(), + updatedAt: new Date(), + isReady: false, + topic: "testTopic", + peerAddress: "testPeerAddress", + walletAddress: testWallet.address, + } satisfies CachedConversationWithId; + const testMessage = { + id: 1, + walletAddress: testWallet.address, + conversationTopic: "testTopic", + content: { + contentDigest: "testContentDigest", + contentLength: 0, + filename: "testFilename", + nonce: new Uint8Array(), + salt: new Uint8Array(), + scheme: "testScheme", + secret: new Uint8Array(), + url: "testUrl", + }, + contentType: ContentTypeRemoteAttachment.toString(), + isSending: false, + hasSendError: false, + sentAt: new Date(), + status: "unprocessed", + senderAddress: "testWalletAddress", + uuid: "testUuid", + xmtpID: "testXmtpId", + } satisfies CachedMessageWithId; + + const persist = vi.fn(); + const updateConversationMetadata = vi.fn(); + await processRemoteAttachment({ + client: testClient, + conversation: testConversation, + db, + message: testMessage, + persist, + updateConversationMetadata, + processors: attachmentsCacheConfig.processors, + }); + expect(spy).toHaveBeenCalledWith(testMessage.content, testClient); + expect(persist).toHaveBeenCalledWith({ + metadata: testMetadata, + }); + }); + + it("should not process a message with the wrong content type", async () => { + const testClient = await Client.create(testWallet, { env: "local" }); + const testConversation = { + id: 1, + createdAt: new Date(), + updatedAt: new Date(), + isReady: false, + topic: "testTopic", + peerAddress: "testPeerAddress", + walletAddress: testWallet.address, + } satisfies CachedConversationWithId; + const testMessage = { + id: 1, + walletAddress: testWallet.address, + conversationTopic: "testTopic", + content: "test", + contentType: ContentTypeText.toString(), + isSending: false, + hasSendError: false, + sentAt: new Date(), + status: "unprocessed", + senderAddress: "testWalletAddress", + uuid: "testUuid", + xmtpID: "testXmtpId", + } satisfies CachedMessageWithId; + + const persist = vi.fn(); + const updateConversationMetadata = vi.fn(); + await processRemoteAttachment({ + client: testClient, + conversation: testConversation, + db, + message: testMessage, + persist, + updateConversationMetadata, + processors: attachmentsCacheConfig.processors, + }); + expect(persist).not.toHaveBeenCalled(); + }); + }); + + describe("getAttachment", () => { + it("should return an attachment from cached message metadata (if present)", () => { + const testMetadata = { + filename: "testFilename", + mimeType: "testMimeType", + data: new Uint8Array(), + } satisfies Attachment; + const testMessage = { + id: 1, + walletAddress: testWallet.address, + conversationTopic: "testTopic", + content: testMetadata, + contentType: ContentTypeAttachment.toString(), + isSending: false, + hasSendError: false, + sentAt: new Date(), + status: "unprocessed", + senderAddress: "testWalletAddress", + uuid: "testUuid", + xmtpID: "testXmtpId", + metadata: { + [attachmentsCacheConfig.namespace]: testMetadata, + }, + } satisfies CachedMessageWithId; + + const attachment = getAttachment(testMessage); + expect(attachment).toEqual(testMetadata); + + const attachment2 = getAttachment({ + ...testMessage, + metadata: {}, + }); + expect(attachment2).toBeUndefined(); + }); + }); + + describe("hasAttachment", () => { + it("should return true if attachment metadata exists", () => { + const testMetadata = { + filename: "testFilename", + mimeType: "testMimeType", + data: new Uint8Array(), + } satisfies Attachment; + const testMessage = { + id: 1, + walletAddress: testWallet.address, + conversationTopic: "testTopic", + content: testMetadata, + contentType: ContentTypeAttachment.toString(), + isSending: false, + hasSendError: false, + sentAt: new Date(), + status: "unprocessed", + senderAddress: "testWalletAddress", + uuid: "testUuid", + xmtpID: "testXmtpId", + metadata: { + [attachmentsCacheConfig.namespace]: testMetadata, + }, + } satisfies CachedMessageWithId; + + const attachment = hasAttachment(testMessage); + expect(attachment).toBe(true); + + const attachment2 = hasAttachment({ + ...testMessage, + metadata: {}, + }); + expect(attachment2).toBe(false); + }); + }); +}); diff --git a/packages/react-sdk/src/helpers/caching/contentTypes/attachment.ts b/packages/react-sdk/src/helpers/caching/contentTypes/attachment.ts new file mode 100644 index 00000000..37086f4f --- /dev/null +++ b/packages/react-sdk/src/helpers/caching/contentTypes/attachment.ts @@ -0,0 +1,66 @@ +import type { + Attachment, + RemoteAttachment, +} from "@xmtp/content-type-remote-attachment"; +import { + AttachmentCodec, + ContentTypeAttachment, + ContentTypeRemoteAttachment, + RemoteAttachmentCodec, +} from "@xmtp/content-type-remote-attachment"; +import { ContentTypeId } from "@xmtp/xmtp-js"; +import type { CacheConfiguration, CachedMessageProcessor } from "../db"; +import { type CachedMessage } from "../messages"; + +const NAMESPACE = "attachment"; + +export type CachedAttachmentsMetadata = Attachment | undefined; + +export const hasAttachment = (message: CachedMessage) => { + const metadata = message?.metadata?.[NAMESPACE] as CachedAttachmentsMetadata; + return !!metadata; +}; + +export const getAttachment = (message: CachedMessage) => + message?.metadata?.[NAMESPACE] as CachedAttachmentsMetadata; + +export const processAttachment: CachedMessageProcessor = async ({ + message, + persist, +}) => { + const contentType = ContentTypeId.fromString(message.contentType); + if (ContentTypeAttachment.sameAs(contentType)) { + // save message to cache with the attachment metadata + await persist({ + metadata: message.content as Attachment, + }); + } +}; + +export const processRemoteAttachment: CachedMessageProcessor = async ({ + client, + message, + persist, +}) => { + const contentType = ContentTypeId.fromString(message.contentType); + if (ContentTypeRemoteAttachment.sameAs(contentType)) { + const attachment = await RemoteAttachmentCodec.load( + message.content as RemoteAttachment, + client, + ); + + // save message to cache with the attachment metadata + await persist({ + metadata: attachment, + }); + } +}; + +export const attachmentsCacheConfig: CacheConfiguration = { + codecs: [new AttachmentCodec(), new RemoteAttachmentCodec()], + namespace: NAMESPACE, + processors: { + [ContentTypeAttachment.toString()]: [processAttachment], + [ContentTypeRemoteAttachment.toString()]: [processRemoteAttachment], + }, +}; diff --git a/packages/react-sdk/src/helpers/caching/contentTypes/reaction.test.ts b/packages/react-sdk/src/helpers/caching/contentTypes/reaction.test.ts new file mode 100644 index 00000000..c9ee8399 --- /dev/null +++ b/packages/react-sdk/src/helpers/caching/contentTypes/reaction.test.ts @@ -0,0 +1,226 @@ +import { it, expect, describe, vi, beforeEach } from "vitest"; +import { Client, ContentTypeText } from "@xmtp/xmtp-js"; +import { Wallet } from "ethers"; +import type { Reaction } from "@xmtp/content-type-reaction"; +import { + ContentTypeReaction, + ReactionCodec, +} from "@xmtp/content-type-reaction"; +import type { CachedReaction, CachedReactionsTable } from "./reaction"; +import { + reactionsCacheConfig, + processReaction, + getReactionsByXmtpID, + hasReaction, + saveReaction, +} from "./reaction"; +import { + saveMessage, + type CachedMessageWithId, + getMessageByXmtpID, +} from "@/helpers/caching/messages"; +import { getDbInstance, clearCache } from "@/helpers/caching/db"; +import type { CachedConversationWithId } from "@/helpers/caching/conversations"; + +const testWallet = Wallet.createRandom(); +const db = getDbInstance({ + cacheConfig: [reactionsCacheConfig], +}); + +describe("ContentTypeReaction caching", () => { + beforeEach(async () => { + await clearCache(db); + }); + + it("should have the correct cache config", () => { + expect(reactionsCacheConfig.namespace).toEqual("reactions"); + expect(reactionsCacheConfig.codecs?.length).toEqual(1); + expect(reactionsCacheConfig.codecs?.[0]).toBeInstanceOf(ReactionCodec); + expect( + reactionsCacheConfig.processors[ContentTypeReaction.toString()], + ).toEqual([processReaction]); + }); + + describe("saveReaction", () => { + it("should save a reaction to the cache", async () => { + const testReaction = { + content: "test", + referenceXmtpID: "testXmtpId", + schema: "custom", + senderAddress: "testWalletAddress", + xmtpID: "testXmtpId", + } satisfies CachedReaction; + + const reactionId = await saveReaction(testReaction, db); + expect(reactionId).toEqual(1); + + const testReaction2 = { + content: "test", + referenceXmtpID: "testXmtpId", + schema: "custom", + senderAddress: "testWalletAddress", + xmtpID: "testXmtpId", + } satisfies CachedReaction; + + const reactionId2 = await saveReaction(testReaction2, db); + expect(reactionId2).toEqual(1); + }); + }); + + describe("processReaction", () => { + it("should add and remove reactions to the cache", async () => { + const testClient = await Client.create(testWallet, { env: "local" }); + const testConversation = { + id: 1, + createdAt: new Date(), + updatedAt: new Date(), + isReady: false, + topic: "testTopic", + peerAddress: "testPeerAddress", + walletAddress: testWallet.address, + } satisfies CachedConversationWithId; + const testTextMessage = { + id: 1, + walletAddress: testWallet.address, + conversationTopic: "testTopic", + content: "test", + contentType: ContentTypeText.toString(), + isSending: false, + hasSendError: false, + sentAt: new Date(), + status: "unprocessed", + senderAddress: "testWalletAddress", + uuid: "testUuid1", + xmtpID: "testXmtpId1", + } satisfies CachedMessageWithId; + + await saveMessage({ db, message: testTextMessage }); + + const testReactionContent = { + content: "test", + schema: "custom", + action: "added", + reference: "testXmtpId1", + } satisfies Reaction; + + const testReactionMessage = { + id: 2, + walletAddress: testWallet.address, + conversationTopic: "testTopic", + content: testReactionContent, + contentType: ContentTypeReaction.toString(), + isSending: false, + hasSendError: false, + sentAt: new Date(), + status: "unprocessed", + senderAddress: "testWalletAddress", + uuid: "testUuid2", + xmtpID: "testXmtpId2", + } satisfies CachedMessageWithId; + + const persist = vi.fn(); + const updateConversationMetadata = vi.fn(); + await processReaction({ + client: testClient, + conversation: testConversation, + db, + message: testReactionMessage, + persist, + updateConversationMetadata, + processors: reactionsCacheConfig.processors, + }); + expect(persist).not.toHaveBeenCalled(); + + const reactions = await getReactionsByXmtpID("testXmtpId1", db); + expect(reactions.length).toEqual(1); + expect(reactions[0].content).toEqual(testReactionContent.content); + expect(reactions[0].referenceXmtpID).toEqual( + testReactionContent.reference, + ); + expect(reactions[0].schema).toEqual(testReactionContent.schema); + expect(reactions[0].senderAddress).toBe("testWalletAddress"); + expect(reactions[0].xmtpID).toEqual("testXmtpId2"); + + const originalMessage = await getMessageByXmtpID("testXmtpId1", db); + expect(hasReaction(originalMessage!)).toBe(true); + + const testReactionMessage2 = { + id: 3, + walletAddress: testWallet.address, + conversationTopic: "testTopic", + content: { + ...testReactionContent, + action: "removed", + }, + contentType: ContentTypeReaction.toString(), + isSending: false, + hasSendError: false, + sentAt: new Date(), + status: "unprocessed", + senderAddress: "testWalletAddress", + uuid: "testUuid3", + xmtpID: "testXmtpId3", + } satisfies CachedMessageWithId; + + await processReaction({ + client: testClient, + conversation: testConversation, + db, + message: testReactionMessage2, + persist, + updateConversationMetadata, + processors: reactionsCacheConfig.processors, + }); + expect(persist).not.toHaveBeenCalled(); + + const reactions2 = await getReactionsByXmtpID("testXmtpId1", db); + expect(reactions2.length).toEqual(0); + + const originalMessage2 = await getMessageByXmtpID("testXmtpId1", db); + expect(hasReaction(originalMessage2!)).toBe(false); + }); + + it("should not process a message with the wrong content type", async () => { + const testClient = await Client.create(testWallet, { env: "local" }); + const testConversation = { + id: 1, + createdAt: new Date(), + updatedAt: new Date(), + isReady: false, + topic: "testTopic", + peerAddress: "testPeerAddress", + walletAddress: testWallet.address, + } satisfies CachedConversationWithId; + const testMessage = { + id: 1, + walletAddress: testWallet.address, + conversationTopic: "testTopic", + content: "test", + contentType: ContentTypeText.toString(), + isSending: false, + hasSendError: false, + sentAt: new Date(), + status: "unprocessed", + senderAddress: "testWalletAddress", + uuid: "testUuid", + xmtpID: "testXmtpId", + } satisfies CachedMessageWithId; + + const persist = vi.fn(); + const updateConversationMetadata = vi.fn(); + await processReaction({ + client: testClient, + conversation: testConversation, + db, + message: testMessage, + persist, + updateConversationMetadata, + processors: reactionsCacheConfig.processors, + }); + expect(persist).not.toHaveBeenCalled(); + const reactionsTable = db.table("reactions") as CachedReactionsTable; + const allReactions = await reactionsTable.toArray(); + expect(allReactions.length).toEqual(0); + }); + }); +}); diff --git a/packages/react-sdk/src/helpers/caching/contentTypes/reaction.ts b/packages/react-sdk/src/helpers/caching/contentTypes/reaction.ts new file mode 100644 index 00000000..0beedb5b --- /dev/null +++ b/packages/react-sdk/src/helpers/caching/contentTypes/reaction.ts @@ -0,0 +1,149 @@ +import type { Reaction } from "@xmtp/content-type-reaction"; +import { + ReactionCodec, + ContentTypeReaction, +} from "@xmtp/content-type-reaction"; +import { ContentTypeId } from "@xmtp/xmtp-js"; +import type { Dexie, Table } from "dexie"; +import type { CacheConfiguration, CachedMessageProcessor } from "../db"; +import type { CachedMessage } from "../messages"; +import { getMessageByXmtpID, updateMessageMetadata } from "../messages"; + +const NAMESPACE = "reactions"; + +export type CachedReaction = { + content: Reaction["content"]; + id?: number; + referenceXmtpID: Reaction["reference"]; + schema: Reaction["schema"]; + senderAddress: string; + xmtpID: string; +}; + +export type CachedReactionWithId = CachedReaction & { + id: number; +}; + +export type CachedReactionQuery = Partial< + Pick< + CachedReaction, + "content" | "referenceXmtpID" | "schema" | "senderAddress" + > +>; + +export type CachedReactionsMetadata = boolean; + +export type CachedReactionsTable = Table; + +export const findReaction = async (reaction: CachedReaction, db: Dexie) => { + const reactionsTable = db.table("reactions") as CachedReactionsTable; + + const reactionQuery: CachedReactionQuery = { + content: reaction.content, + referenceXmtpID: reaction.referenceXmtpID, + schema: reaction.schema, + senderAddress: reaction.senderAddress, + }; + + const found = await reactionsTable.where(reactionQuery).first(); + + return found ? (found as CachedReactionWithId) : undefined; +}; + +export const saveReaction = async (reaction: CachedReaction, db: Dexie) => { + const reactionsTable = db.table("reactions") as CachedReactionsTable; + + // check if reaction already exists + const existing = await findReaction(reaction, db); + if (existing) { + return existing.id; + } + + return reactionsTable.add(reaction); +}; + +export const deleteReaction = async (reaction: CachedReaction, db: Dexie) => { + const reactionsTable = db.table("reactions") as CachedReactionsTable; + // make sure reaction exists + const existing = await findReaction(reaction, db); + if (existing) { + await reactionsTable.delete(existing.id); + } +}; + +export const getReactionsByXmtpID = async ( + xmtpID: Reaction["reference"], + db: Dexie, +) => { + const reactionsTable = db.table("reactions") as CachedReactionsTable; + return reactionsTable.where({ referenceXmtpID: xmtpID }).toArray(); +}; + +const updateReactionsMetadata = async ( + referenceXmtpID: Reaction["reference"], + db: Dexie, +) => { + const reactions = await getReactionsByXmtpID(referenceXmtpID, db); + const message = await getMessageByXmtpID(referenceXmtpID, db); + if (message) { + await updateMessageMetadata(message, NAMESPACE, reactions.length > 0, db); + } +}; + +export const hasReaction = (message: CachedMessage) => + !!message?.metadata?.[NAMESPACE]; + +/** + * Process a reaction message + * + * This will add or remove the reaction from the cache based on the `action` + * property. The original message is not saved to the messages cache. + */ +export const processReaction: CachedMessageProcessor = async ({ + message, + db, +}) => { + const contentType = ContentTypeId.fromString(message.contentType); + if (ContentTypeReaction.sameAs(contentType)) { + const reaction = message.content as Reaction; + const cachedReaction = { + content: reaction.content, + referenceXmtpID: reaction.reference, + schema: reaction.schema, + senderAddress: message.senderAddress, + xmtpID: message.xmtpID, + } satisfies CachedReaction; + + switch (reaction.action) { + case "added": + await saveReaction(cachedReaction, db); + break; + case "removed": + await deleteReaction(cachedReaction, db); + break; + // no default + } + + // update reactions metadata on the referenced message + await updateReactionsMetadata(reaction.reference, db); + } +}; + +export const reactionsCacheConfig: CacheConfiguration = { + codecs: [new ReactionCodec()], + namespace: NAMESPACE, + processors: { + [ContentTypeReaction.toString()]: [processReaction], + }, + schema: { + reactions: ` + ++id, + [content+referenceXmtpID+schema+senderAddress], + referenceXmtpID, + content, + schema, + senderAddress, + xmtpID + `, + }, +}; diff --git a/packages/react-sdk/src/helpers/caching/contentTypes/readReceipt.test.ts b/packages/react-sdk/src/helpers/caching/contentTypes/readReceipt.test.ts new file mode 100644 index 00000000..734364e9 --- /dev/null +++ b/packages/react-sdk/src/helpers/caching/contentTypes/readReceipt.test.ts @@ -0,0 +1,204 @@ +import { it, expect, describe, vi, beforeEach } from "vitest"; +import { Client, ContentTypeText } from "@xmtp/xmtp-js"; +import { Wallet } from "ethers"; +import type { ReadReceipt } from "@xmtp/content-type-read-receipt"; +import { + ContentTypeReadReceipt, + ReadReceiptCodec, +} from "@xmtp/content-type-read-receipt"; +import { type CachedMessageWithId } from "@/helpers/caching/messages"; +import { getDbInstance, clearCache } from "@/helpers/caching/db"; +import { + saveConversation, + type CachedConversationWithId, +} from "@/helpers/caching/conversations"; +import { + getReadReceipt, + hasReadReceipt, + processReadReceipt, + readReceiptsCacheConfig, +} from "@/helpers/caching/contentTypes/readReceipt"; + +const testWallet = Wallet.createRandom(); +const db = getDbInstance({ + cacheConfig: [readReceiptsCacheConfig], +}); + +describe("ContentTypeReadReceipt caching", () => { + beforeEach(async () => { + await clearCache(db); + }); + + it("should have the correct cache config", () => { + expect(readReceiptsCacheConfig.namespace).toEqual("readReceipt"); + expect(readReceiptsCacheConfig.codecs?.length).toEqual(1); + expect(readReceiptsCacheConfig.codecs?.[0]).toBeInstanceOf( + ReadReceiptCodec, + ); + expect( + readReceiptsCacheConfig.processors[ContentTypeReadReceipt.toString()], + ).toEqual([processReadReceipt]); + }); + + describe("processReadReceipt", () => { + it("should update conversation in cache", async () => { + const testClient = await Client.create(testWallet, { env: "local" }); + const testConversation = { + id: 1, + createdAt: new Date(), + updatedAt: new Date(), + isReady: false, + topic: "testTopic", + peerAddress: "testPeerAddress", + walletAddress: testWallet.address, + } satisfies CachedConversationWithId; + + await saveConversation(testConversation, db); + + const readReceiptDate = new Date(); + + const testReadReceiptContent = { + timestamp: readReceiptDate.toString(), + } satisfies ReadReceipt; + + const testReadReceiptMessage = { + id: 1, + walletAddress: testWallet.address, + conversationTopic: "testTopic", + content: testReadReceiptContent, + contentType: ContentTypeReadReceipt.toString(), + isSending: false, + hasSendError: false, + sentAt: new Date(), + status: "unprocessed", + senderAddress: "testWalletAddress", + uuid: "testUuid1", + xmtpID: "testXmtpId1", + } satisfies CachedMessageWithId; + + const persist = vi.fn(); + const updateConversationMetadata = vi.fn(); + await processReadReceipt({ + client: testClient, + conversation: testConversation, + db, + message: testReadReceiptMessage, + persist, + updateConversationMetadata, + processors: readReceiptsCacheConfig.processors, + }); + expect(persist).not.toHaveBeenCalled(); + expect(updateConversationMetadata).toHaveBeenCalledWith( + readReceiptDate.toString(), + ); + }); + + it("should not process a message with the wrong content type", async () => { + const testClient = await Client.create(testWallet, { env: "local" }); + const testConversation = { + id: 1, + createdAt: new Date(), + updatedAt: new Date(), + isReady: false, + topic: "testTopic", + peerAddress: "testPeerAddress", + walletAddress: testWallet.address, + } satisfies CachedConversationWithId; + const testTextMessage = { + id: 1, + walletAddress: testWallet.address, + conversationTopic: "testTopic", + content: "test", + contentType: ContentTypeText.toString(), + isSending: false, + hasSendError: false, + sentAt: new Date(), + status: "unprocessed", + senderAddress: "testWalletAddress", + uuid: "testUuid1", + xmtpID: "testXmtpId1", + } satisfies CachedMessageWithId; + + const persist = vi.fn(); + const updateConversationMetadata = vi.fn(); + await processReadReceipt({ + client: testClient, + conversation: testConversation, + db, + message: testTextMessage, + persist, + updateConversationMetadata, + processors: readReceiptsCacheConfig.processors, + }); + expect(persist).not.toHaveBeenCalled(); + expect(updateConversationMetadata).not.toHaveBeenCalled(); + }); + }); + + describe("getReadReceipt", () => { + it("should return read receipt if conversation has one", () => { + const readReceiptDate = new Date(); + const testConversationWithReadReceipt = { + id: 1, + createdAt: new Date(), + updatedAt: new Date(), + isReady: false, + topic: "testTopic", + peerAddress: "testPeerAddress", + walletAddress: testWallet.address, + metadata: { + readReceipt: readReceiptDate.toISOString(), + }, + } satisfies CachedConversationWithId; + + expect(getReadReceipt(testConversationWithReadReceipt)).toEqual( + readReceiptDate, + ); + }); + + it("should not return read receipt if conversation does not have one", () => { + const testConversation = { + id: 1, + createdAt: new Date(), + updatedAt: new Date(), + isReady: false, + topic: "testTopic", + peerAddress: "testPeerAddress", + walletAddress: testWallet.address, + } satisfies CachedConversationWithId; + expect(getReadReceipt(testConversation)).toBe(undefined); + }); + }); + + describe("hasReadReceipt", () => { + it("should return true if conversation has a read receipt", () => { + const testConversationWithReadReceipt = { + id: 1, + createdAt: new Date(), + updatedAt: new Date(), + isReady: false, + topic: "testTopic", + peerAddress: "testPeerAddress", + walletAddress: testWallet.address, + metadata: { + readReceipt: new Date().toString(), + }, + } satisfies CachedConversationWithId; + + expect(hasReadReceipt(testConversationWithReadReceipt)).toBe(true); + }); + + it("should return false if conversation does not have a read receipt", () => { + const testConversation = { + id: 1, + createdAt: new Date(), + updatedAt: new Date(), + isReady: false, + topic: "testTopic", + peerAddress: "testPeerAddress", + walletAddress: testWallet.address, + } satisfies CachedConversationWithId; + expect(hasReadReceipt(testConversation)).toBe(false); + }); + }); +}); diff --git a/packages/react-sdk/src/helpers/caching/contentTypes/readReceipt.ts b/packages/react-sdk/src/helpers/caching/contentTypes/readReceipt.ts new file mode 100644 index 00000000..a05ca12c --- /dev/null +++ b/packages/react-sdk/src/helpers/caching/contentTypes/readReceipt.ts @@ -0,0 +1,60 @@ +import type { ReadReceipt } from "@xmtp/content-type-read-receipt"; +import { + ReadReceiptCodec, + ContentTypeReadReceipt, +} from "@xmtp/content-type-read-receipt"; +import { ContentTypeId } from "@xmtp/xmtp-js"; +import { parseISO } from "date-fns"; +import type { CacheConfiguration, CachedMessageProcessor } from "../db"; +import type { CachedConversation } from "../conversations"; + +const NAMESPACE = "readReceipt"; + +export type CachedReadReceiptMetadata = string | undefined; + +/** + * Retrieve the read receipt from a cached conversation + * + * @returns The read receipt date, or `undefined` if the conversation + * has no read receipt + */ +export const getReadReceipt = (conversation: CachedConversation) => { + const metadata = conversation?.metadata?.[ + NAMESPACE + ] as CachedReadReceiptMetadata; + return metadata ? parseISO(metadata) : undefined; +}; + +/** + * Check if a cached conversation has a read receipt + */ +export const hasReadReceipt = (conversation: CachedConversation) => + getReadReceipt(conversation) !== undefined; + +/** + * Process a read receipt message + * + * The message is not saved to the cache, but rather the metadata of its + * conversation is updated with the timestamp of the read receipt. + */ +export const processReadReceipt: CachedMessageProcessor = async ({ + message, + conversation, + updateConversationMetadata, +}) => { + const contentType = ContentTypeId.fromString(message.contentType); + if (ContentTypeReadReceipt.sameAs(contentType) && conversation) { + // update message's conversation with the read receipt metadata + await updateConversationMetadata( + (message.content as ReadReceipt).timestamp, + ); + } +}; + +export const readReceiptsCacheConfig: CacheConfiguration = { + codecs: [new ReadReceiptCodec()], + namespace: NAMESPACE, + processors: { + [ContentTypeReadReceipt.toString()]: [processReadReceipt], + }, +}; diff --git a/packages/react-sdk/src/helpers/caching/contentTypes/reply.test.ts b/packages/react-sdk/src/helpers/caching/contentTypes/reply.test.ts new file mode 100644 index 00000000..abf0717e --- /dev/null +++ b/packages/react-sdk/src/helpers/caching/contentTypes/reply.test.ts @@ -0,0 +1,260 @@ +import { it, expect, describe, vi, beforeEach } from "vitest"; +import { Client, ContentTypeText } from "@xmtp/xmtp-js"; +import { Wallet } from "ethers"; +import type { Reply } from "@xmtp/content-type-reply"; +import { ContentTypeReply, ReplyCodec } from "@xmtp/content-type-reply"; +import { + processReply, + hasReply, + repliesCacheConfig, + getReplies, + getOriginalMessageFromReply, + addReply, +} from "./reply"; +import { + saveMessage, + type CachedMessageWithId, + getMessageByXmtpID, +} from "@/helpers/caching/messages"; +import { getDbInstance, clearCache } from "@/helpers/caching/db"; +import type { CachedConversationWithId } from "@/helpers/caching/conversations"; + +const testWallet = Wallet.createRandom(); +const db = getDbInstance({ + cacheConfig: [repliesCacheConfig], +}); + +describe("ContentTypeReply caching", () => { + beforeEach(async () => { + await clearCache(db); + }); + + it("should have the correct cache config", () => { + expect(repliesCacheConfig.namespace).toEqual("replies"); + expect(repliesCacheConfig.codecs?.length).toEqual(1); + expect(repliesCacheConfig.codecs?.[0]).toBeInstanceOf(ReplyCodec); + expect(repliesCacheConfig.processors[ContentTypeReply.toString()]).toEqual([ + processReply, + ]); + }); + + describe("processReply", () => { + it("should add the reply and message to the cache", async () => { + const testClient = await Client.create(testWallet, { env: "local" }); + const testConversation = { + id: 1, + createdAt: new Date(), + updatedAt: new Date(), + isReady: false, + topic: "testTopic", + peerAddress: "testPeerAddress", + walletAddress: testWallet.address, + } satisfies CachedConversationWithId; + const testTextMessage = { + id: 1, + walletAddress: testWallet.address, + conversationTopic: "testTopic", + content: "test", + contentType: ContentTypeText.toString(), + isSending: false, + hasSendError: false, + sentAt: new Date(), + status: "processed", + senderAddress: "testWalletAddress", + uuid: "testUuid1", + xmtpID: "testXmtpId1", + } satisfies CachedMessageWithId; + + await saveMessage({ db, message: testTextMessage }); + + const testReplyContent = { + content: "test", + contentType: ContentTypeText, + reference: "testXmtpId1", + } satisfies Reply; + + const testReplyMessage = { + id: 2, + walletAddress: testWallet.address, + conversationTopic: "testTopic", + content: testReplyContent, + contentType: ContentTypeReply.toString(), + isSending: false, + hasSendError: false, + sentAt: new Date(), + status: "processed", + senderAddress: "testWalletAddress", + uuid: "testUuid2", + xmtpID: "testXmtpId2", + } satisfies CachedMessageWithId; + + const persist = vi.fn(); + const updateConversationMetadata = vi.fn(); + await processReply({ + client: testClient, + conversation: testConversation, + db, + message: testReplyMessage, + persist, + updateConversationMetadata, + processors: repliesCacheConfig.processors, + }); + expect(persist).toHaveBeenCalledWith(); + // since we mocked persist, we need to manually save the message + await saveMessage({ db, message: testReplyMessage }); + + const originalMessage = await getMessageByXmtpID("testXmtpId1", db); + const replies = getReplies(originalMessage!); + expect(replies.length).toEqual(1); + expect(replies).toEqual(["testXmtpId2"]); + expect(hasReply(originalMessage!)).toBe(true); + + const replyMessage = await getMessageByXmtpID("testXmtpId2", db); + const originalMessageFromReply = await getOriginalMessageFromReply( + replyMessage!, + db, + ); + expect(originalMessageFromReply).toEqual(originalMessage); + }); + + it("should not process a message with the wrong content type", async () => { + const testClient = await Client.create(testWallet, { env: "local" }); + const testConversation = { + id: 1, + createdAt: new Date(), + updatedAt: new Date(), + isReady: false, + topic: "testTopic", + peerAddress: "testPeerAddress", + walletAddress: testWallet.address, + } satisfies CachedConversationWithId; + const testMessage = { + id: 1, + walletAddress: testWallet.address, + conversationTopic: "testTopic", + content: "test", + contentType: ContentTypeText.toString(), + isSending: false, + hasSendError: false, + sentAt: new Date(), + status: "unprocessed", + senderAddress: "testWalletAddress", + uuid: "testUuid", + xmtpID: "testXmtpId", + } satisfies CachedMessageWithId; + + const persist = vi.fn(); + const updateConversationMetadata = vi.fn(); + await processReply({ + client: testClient, + conversation: testConversation, + db, + message: testMessage, + persist, + updateConversationMetadata, + processors: repliesCacheConfig.processors, + }); + expect(persist).not.toHaveBeenCalled(); + }); + }); + + describe("getOriginalMessageFromReply", () => { + it("should return undefined if the message isn't a processed reply", async () => { + const testTextMessage = { + id: 1, + walletAddress: testWallet.address, + conversationTopic: "testTopic", + content: "test", + contentType: ContentTypeText.toString(), + isSending: false, + hasSendError: false, + sentAt: new Date(), + status: "processed", + senderAddress: "testWalletAddress", + uuid: "testUuid1", + xmtpID: "testXmtpId1", + } satisfies CachedMessageWithId; + + const originalMessageFromReply = await getOriginalMessageFromReply( + testTextMessage, + db, + ); + expect(originalMessageFromReply).toBeUndefined(); + + const testReplyMessage = { + id: 2, + walletAddress: testWallet.address, + conversationTopic: "testTopic", + content: { + content: "test", + contentType: ContentTypeText, + reference: "testXmtpId1", + }, + contentType: ContentTypeReply.toString(), + isSending: false, + hasSendError: false, + sentAt: new Date(), + status: "unprocessed", + senderAddress: "testWalletAddress", + uuid: "testUuid2", + xmtpID: "testXmtpId2", + } satisfies CachedMessageWithId; + + const originalMessageFromReply2 = await getOriginalMessageFromReply( + testReplyMessage, + db, + ); + expect(originalMessageFromReply2).toBeUndefined(); + }); + }); + + describe("getReplies", () => { + it("should return empty array if no metadata is present", () => { + const testTextMessage = { + id: 1, + walletAddress: testWallet.address, + conversationTopic: "testTopic", + content: "test", + contentType: ContentTypeText.toString(), + isSending: false, + hasSendError: false, + sentAt: new Date(), + status: "processed", + senderAddress: "testWalletAddress", + uuid: "testUuid1", + xmtpID: "testXmtpId1", + } satisfies CachedMessageWithId; + + const replies = getReplies(testTextMessage); + expect(replies).toEqual([]); + }); + }); + + describe("addReply", () => { + it("should create multiple replies in message metadata", async () => { + const testTextMessage = { + id: 1, + walletAddress: testWallet.address, + conversationTopic: "testTopic", + content: "test", + contentType: ContentTypeText.toString(), + isSending: false, + hasSendError: false, + sentAt: new Date(), + status: "processed", + senderAddress: "testWalletAddress", + uuid: "testUuid1", + xmtpID: "testXmtpId1", + } satisfies CachedMessageWithId; + + await saveMessage({ db, message: testTextMessage }); + await addReply("testXmtpId1", "testXmtpId2", db); + await addReply("testXmtpId1", "testXmtpId3", db); + + const textMessage = await getMessageByXmtpID("testXmtpId1", db); + + const replies = getReplies(textMessage!); + expect(replies).toEqual(["testXmtpId2", "testXmtpId3"]); + }); + }); +}); diff --git a/packages/react-sdk/src/helpers/caching/contentTypes/reply.ts b/packages/react-sdk/src/helpers/caching/contentTypes/reply.ts new file mode 100644 index 00000000..af5ef308 --- /dev/null +++ b/packages/react-sdk/src/helpers/caching/contentTypes/reply.ts @@ -0,0 +1,99 @@ +import type { Reply } from "@xmtp/content-type-reply"; +import { ReplyCodec, ContentTypeReply } from "@xmtp/content-type-reply"; +import { ContentTypeId } from "@xmtp/xmtp-js"; +import type { Dexie } from "dexie"; +import type { CacheConfiguration, CachedMessageProcessor } from "../db"; +import type { CachedMessage } from "../messages"; +import { getMessageByXmtpID, updateMessageMetadata } from "../messages"; + +const NAMESPACE = "replies"; + +export type CachedRepliesMetadata = string[]; + +/** + * Add a reply to the metadata of a cached message + * + * Replies are stored as an array of XMTP message IDs in the metadata of + * the original message. + */ +export const addReply = async ( + xmtpID: Reply["reference"], + replyXmtpID: string, + db: Dexie, +) => { + const message = await getMessageByXmtpID(xmtpID, db); + if (message) { + const replies = (message.metadata?.[NAMESPACE] ?? + []) as CachedRepliesMetadata; + const exists = replies.some((reply) => reply === replyXmtpID); + if (!exists) { + replies.push(replyXmtpID); + await updateMessageMetadata(message, NAMESPACE, replies, db); + } + } +}; + +/** + * Retrieve all replies to a cached message + */ +export const getReplies = (message: CachedMessage) => { + const metadata = message?.metadata?.[NAMESPACE] ?? []; + return metadata as CachedRepliesMetadata; +}; + +/** + * Check if a cached message has any replies + */ +export const hasReply = (message: CachedMessage) => + getReplies(message).length > 0; + +/** + * Get the original message from a reply message + * + * @returns The original message, or `undefined` if the reply message is invalid + */ +export const getOriginalMessageFromReply = async ( + message: CachedMessage, + db: Dexie, +) => { + if ( + ContentTypeReply.sameAs(ContentTypeId.fromString(message.contentType)) && + message.status === "processed" && + message.content + ) { + const reply = message.content as Reply; + return getMessageByXmtpID(reply.reference, db); + } + return undefined; +}; + +/** + * Process a reply message + * + * This saves the reply message to the cache and updates the metadata of the + * original message with the new reply. + */ +export const processReply: CachedMessageProcessor = async ({ + message, + db, + persist, +}) => { + const contentType = ContentTypeId.fromString(message.contentType); + if (ContentTypeReply.sameAs(contentType)) { + const reply = message.content as Reply; + + // update replies metadata on the referenced message + await addReply(reply.reference, message.xmtpID, db); + + // save the message to cache + await persist(); + } +}; + +export const repliesCacheConfig: CacheConfiguration = { + codecs: [new ReplyCodec()], + namespace: NAMESPACE, + processors: { + [ContentTypeReply.toString()]: [processReply], + }, +}; diff --git a/packages/react-sdk/src/helpers/caching/contentTypes/text.test.ts b/packages/react-sdk/src/helpers/caching/contentTypes/text.test.ts new file mode 100644 index 00000000..7cd9e8d6 --- /dev/null +++ b/packages/react-sdk/src/helpers/caching/contentTypes/text.test.ts @@ -0,0 +1,113 @@ +import { it, expect, describe, vi } from "vitest"; +import { Client, ContentTypeText } from "@xmtp/xmtp-js"; +import { Wallet } from "ethers"; +import { + ContentTypeAttachment, + type Attachment, +} from "@xmtp/content-type-remote-attachment"; +import { type CachedMessageWithId } from "@/helpers/caching/messages"; +import { getDbInstance } from "@/helpers/caching/db"; +import type { CachedConversationWithId } from "@/helpers/caching/conversations"; +import { + processText, + textCacheConfig, +} from "@/helpers/caching/contentTypes/text"; + +const testWallet = Wallet.createRandom(); +const db = getDbInstance(); + +describe("ContentTypeText caching", () => { + it("should have the correct cache config", () => { + expect(textCacheConfig.namespace).toEqual("text"); + expect(textCacheConfig.codecs).toBeUndefined(); + expect(textCacheConfig.processors[ContentTypeText.toString()]).toEqual([ + processText, + ]); + }); + + describe("processText", () => { + it("should save a message to the cache", async () => { + const testClient = await Client.create(testWallet, { env: "local" }); + const testConversation = { + id: 1, + createdAt: new Date(), + updatedAt: new Date(), + isReady: false, + topic: "testTopic", + peerAddress: "testPeerAddress", + walletAddress: testWallet.address, + } satisfies CachedConversationWithId; + const testMessage = { + id: 1, + walletAddress: testWallet.address, + conversationTopic: "testTopic", + content: "test", + contentType: ContentTypeText.toString(), + isSending: false, + hasSendError: false, + sentAt: new Date(), + status: "unprocessed", + senderAddress: "testWalletAddress", + uuid: "testUuid", + xmtpID: "testXmtpId", + } satisfies CachedMessageWithId; + + const persist = vi.fn(); + const updateConversationMetadata = vi.fn(); + await processText({ + client: testClient, + conversation: testConversation, + db, + message: testMessage, + persist, + updateConversationMetadata, + processors: textCacheConfig.processors, + }); + expect(persist).toHaveBeenCalledWith(); + }); + + it("should not process a message with the wrong content type", async () => { + const testClient = await Client.create(testWallet, { env: "local" }); + const testConversation = { + id: 1, + createdAt: new Date(), + updatedAt: new Date(), + isReady: false, + topic: "testTopic", + peerAddress: "testPeerAddress", + walletAddress: testWallet.address, + } satisfies CachedConversationWithId; + const testMessage = { + id: 1, + walletAddress: testWallet.address, + conversationTopic: "testTopic", + content: { + filename: "testFilename", + mimeType: "testMimeType", + data: new Uint8Array(), + }, + contentType: ContentTypeAttachment.toString(), + isSending: false, + hasSendError: false, + sentAt: new Date(), + status: "unprocessed", + senderAddress: "testWalletAddress", + uuid: "testUuid", + xmtpID: "testXmtpId", + } satisfies CachedMessageWithId; + + const persist = vi.fn(); + const updateConversationMetadata = vi.fn(); + await processText({ + client: testClient, + conversation: testConversation, + db, + message: testMessage, + persist, + updateConversationMetadata, + processors: textCacheConfig.processors, + }); + expect(persist).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/react-sdk/src/helpers/caching/contentTypes/text.ts b/packages/react-sdk/src/helpers/caching/contentTypes/text.ts new file mode 100644 index 00000000..6ad00e01 --- /dev/null +++ b/packages/react-sdk/src/helpers/caching/contentTypes/text.ts @@ -0,0 +1,22 @@ +import { ContentTypeId, ContentTypeText } from "@xmtp/xmtp-js"; +import type { CacheConfiguration, CachedMessageProcessor } from "../db"; + +const NAMESPACE = "text"; + +export const processText: CachedMessageProcessor = async ({ + message, + persist, +}) => { + const contentType = ContentTypeId.fromString(message.contentType); + if (ContentTypeText.sameAs(contentType)) { + // no special processing, just persist the message to cache + await persist(); + } +}; + +export const textCacheConfig: CacheConfiguration = { + namespace: NAMESPACE, + processors: { + [ContentTypeText.toString()]: [processText], + }, +}; diff --git a/packages/react-sdk/src/helpers/caching/conversations.test.ts b/packages/react-sdk/src/helpers/caching/conversations.test.ts new file mode 100644 index 00000000..a399adb5 --- /dev/null +++ b/packages/react-sdk/src/helpers/caching/conversations.test.ts @@ -0,0 +1,280 @@ +import { it, expect, describe, beforeEach } from "vitest"; +import { Wallet } from "ethers"; +import { Client } from "@xmtp/xmtp-js"; +import { getDbInstance, clearCache } from "@/helpers/caching/db"; +import { + getCachedConversationBy, + getCachedConversationByPeerAddress, + getCachedConversationByTopic, + getConversationByTopic, + hasTopic, + saveConversation, + setConversationUpdatedAt, + toCachedConversation, + updateConversation, + updateConversationMetadata, +} from "@/helpers/caching/conversations"; +import type { + CachedConversation, + CachedConversationWithId, +} from "@/helpers/caching/conversations"; +import { adjustDate } from "@/helpers/adjustDate"; + +const testWallet1 = Wallet.createRandom(); +const testWallet2 = Wallet.createRandom(); +const db = getDbInstance(); + +beforeEach(async () => { + await clearCache(db); +}); + +describe("getCachedConversationBy", () => { + it("should return undefined if no conversation is found", async () => { + const conversation = await getCachedConversationBy( + "topic", + "testTopic", + db, + ); + expect(conversation).toBeUndefined(); + const conversation2 = await getCachedConversationBy("id", 1, db); + expect(conversation2).toBeUndefined(); + const conversation3 = await getCachedConversationBy( + "peerAddress", + "testPeerAddress", + db, + ); + expect(conversation3).toBeUndefined(); + const conversation4 = await getCachedConversationByTopic("testTopic", db); + expect(conversation4).toBeUndefined(); + const conversation5 = await getCachedConversationByPeerAddress( + "testPeerAddress", + db, + ); + expect(conversation5).toBeUndefined(); + }); + + it("should return a conversation if one is found", async () => { + const testConversation = { + id: 1, + createdAt: new Date(), + updatedAt: new Date(), + isReady: false, + topic: "testTopic", + peerAddress: "testPeerAddress", + walletAddress: "testWalletAddress", + } satisfies CachedConversationWithId; + const cachedConversation = await saveConversation(testConversation, db); + const conversation = await getCachedConversationBy( + "topic", + "testTopic", + db, + ); + expect(conversation).toEqual(cachedConversation); + const conversation2 = await getCachedConversationBy("id", 1, db); + expect(conversation2).toEqual(cachedConversation); + const conversation3 = await getCachedConversationBy( + "peerAddress", + "testPeerAddress", + db, + ); + expect(conversation3).toEqual(cachedConversation); + const conversation4 = await getCachedConversationByTopic("testTopic", db); + expect(conversation4).toEqual(cachedConversation); + const conversation5 = await getCachedConversationByPeerAddress( + "testPeerAddress", + db, + ); + expect(conversation5).toEqual(cachedConversation); + }); +}); + +describe("getConversationByTopic", () => { + it("should return undefined if no conversation is found", async () => { + const testClient = await Client.create(testWallet1, { env: "local" }); + const conversation = await getConversationByTopic("testTopic", testClient); + expect(conversation).toBeUndefined(); + }); + + it("should return a conversation if one is found", async () => { + const testClient1 = await Client.create(testWallet1, { env: "local" }); + await Client.create(testWallet2, { env: "local" }); + const testConversation = await testClient1.conversations.newConversation( + testWallet2.address, + undefined, + ); + const conversation = await getConversationByTopic( + testConversation.topic, + testClient1, + ); + expect(conversation).toEqual(testConversation); + }); +}); + +describe("updateConversation", () => { + it("should update conversation properties", async () => { + const createdAt = new Date(); + const testConversation = { + id: 1, + createdAt, + updatedAt: createdAt, + isReady: false, + topic: "testTopic", + peerAddress: "testPeerAddress", + walletAddress: "testWalletAddress", + } satisfies CachedConversationWithId; + const cachedConversation = await saveConversation(testConversation, db); + expect(cachedConversation).toEqual(testConversation); + + const updatedAt = adjustDate(createdAt, 1000); + + await updateConversation( + testConversation.topic, + { + isReady: true, + metadata: { test: "test" }, + updatedAt, + }, + db, + ); + + const updatedConversation = await getCachedConversationByTopic( + "testTopic", + db, + ); + + expect(updatedConversation?.isReady).toBe(true); + expect(updatedConversation?.metadata).toEqual({ test: "test" }); + expect(updatedConversation?.updatedAt).toEqual(updatedAt); + }); +}); + +describe("updateConversationMetadata", () => { + it("should update conversation metadata with the right namespace", async () => { + const createdAt = new Date(); + const testConversation = { + id: 1, + createdAt, + updatedAt: createdAt, + isReady: false, + topic: "testTopic", + peerAddress: "testPeerAddress", + walletAddress: "testWalletAddress", + } satisfies CachedConversationWithId; + const cachedConversation = await saveConversation(testConversation, db); + expect(cachedConversation).toEqual(testConversation); + + await updateConversationMetadata("testTopic", "test", { test: "test" }, db); + + const updatedConversation = await getCachedConversationByTopic( + "testTopic", + db, + ); + + expect(updatedConversation?.metadata).toEqual({ test: { test: "test" } }); + }); +}); + +describe("setConversationUpdatedAt", () => { + it("should update the `updatedAt` field of a cached conversation", async () => { + const createdAt = new Date(); + const testConversation = { + id: 1, + createdAt, + updatedAt: createdAt, + isReady: false, + topic: "testTopic", + peerAddress: "testPeerAddress", + walletAddress: "testWalletAddress", + } satisfies CachedConversationWithId; + const cachedConversation = await saveConversation(testConversation, db); + expect(cachedConversation.updatedAt).toEqual(createdAt); + + const updatedAt = adjustDate(createdAt, 1000); + + await setConversationUpdatedAt("testTopic", updatedAt, db); + + const conversation = await getCachedConversationByTopic("testTopic", db); + expect(conversation?.updatedAt).toEqual(updatedAt); + }); +}); + +describe("hasTopic", () => { + it("should return true if the topic exists", async () => { + const createdAt = new Date(); + const testConversation = { + id: 1, + createdAt, + updatedAt: createdAt, + isReady: false, + topic: "testTopic", + peerAddress: "testPeerAddress", + walletAddress: "testWalletAddress", + } satisfies CachedConversationWithId; + const cachedConversation = await saveConversation(testConversation, db); + expect(cachedConversation).toEqual(testConversation); + + expect(await hasTopic("testTopic", db)).toBe(true); + }); + + it("should return false if the topic does not exist", async () => { + expect(await hasTopic("testTopic", db)).toBe(false); + }); +}); + +describe("saveConversation", () => { + it("should save a conversation to the cache", async () => { + const createdAt = new Date(); + const testConversation = { + id: 1, + createdAt, + updatedAt: createdAt, + isReady: false, + topic: "testTopic", + peerAddress: "testPeerAddress", + walletAddress: "testWalletAddress", + } satisfies CachedConversationWithId; + const cachedConversation = await saveConversation(testConversation, db); + expect(cachedConversation).toEqual(testConversation); + }); + + it("should return a duplicate conversation", async () => { + const createdAt = new Date(); + const testConversation = { + createdAt, + updatedAt: createdAt, + isReady: false, + topic: "testTopic", + peerAddress: "testPeerAddress", + walletAddress: "testWalletAddress", + } satisfies CachedConversation; + const cachedConversation = await saveConversation(testConversation, db); + expect(cachedConversation).toEqual(testConversation); + const cachedConversation2 = await saveConversation(testConversation, db); + expect(cachedConversation2).toEqual(testConversation); + expect(cachedConversation2.id).toBe(cachedConversation.id); + }); +}); + +describe("toCachedConversation", () => { + it("should return a cached conversation in the right format", async () => { + const testClient1 = await Client.create(testWallet1, { env: "local" }); + await Client.create(testWallet2, { env: "local" }); + const testConversation = await testClient1.conversations.newConversation( + testWallet2.address, + undefined, + ); + const cachedConversation = toCachedConversation( + testConversation, + testWallet1.address, + ); + expect(cachedConversation).toEqual({ + context: undefined, + createdAt: testConversation.createdAt, + isReady: false, + peerAddress: testConversation.peerAddress, + topic: testConversation.topic, + updatedAt: testConversation.createdAt, + walletAddress: testWallet1.address, + }); + }); +}); diff --git a/packages/react-sdk/src/helpers/caching/conversations.ts b/packages/react-sdk/src/helpers/caching/conversations.ts new file mode 100644 index 00000000..08d07dff --- /dev/null +++ b/packages/react-sdk/src/helpers/caching/conversations.ts @@ -0,0 +1,204 @@ +import type { Conversation, Client, InvitationContext } from "@xmtp/xmtp-js"; +import type { Table } from "dexie"; +import type Dexie from "dexie"; +import { Mutex } from "async-mutex"; +import type { CachedMetadata, CachedMetadataValues } from "./db"; + +export type CachedConversation = { + context?: InvitationContext; + createdAt: Date; + id?: number; + isReady: boolean; + metadata?: M; + peerAddress: string; + topic: string; + updatedAt: Date; + walletAddress: string; +}; + +type SearchableProperties = Required< + Pick +>; + +type ToFunctionArgs = { + [P in keyof T]: [key: P, value: T[P]]; +}[keyof T]; + +type GetCachedConversationBy = ( + ...args: [...ToFunctionArgs, Dexie] +) => Promise; + +export type CachedConversationsTable = Table; + +export type CachedConversationWithId = CachedConversation & { + id: number; +}; + +/** + * Retrieve a cached conversation by a given key and value + * + * @returns The cached conversation if found, otherwise `undefined` + */ +export const getCachedConversationBy: GetCachedConversationBy = async ( + key, + value, + db, +) => { + const conversationsTable = db.table( + "conversations", + ) as CachedConversationsTable; + const conversation = await conversationsTable + .where(key) + .equals(value) + .first(); + return conversation ? (conversation as CachedConversationWithId) : undefined; +}; + +/** + * Retrieve a cached conversation by topic + * + * @returns The cached conversation if found, otherwise `undefined` + */ +export const getCachedConversationByTopic = async (topic: string, db: Dexie) => + getCachedConversationBy("topic", topic, db); + +/** + * Retrieve a cached conversation by peer address + * + * @returns The cached conversation if found, otherwise `undefined` + */ +export const getCachedConversationByPeerAddress = async ( + peerAddress: string, + db: Dexie, +) => getCachedConversationBy("peerAddress", peerAddress, db); + +/** + * Retrieve a conversation from the XMTP client by a topic + * + * @returns The conversation if found, otherwise `undefined` + */ +export const getConversationByTopic = async ( + topic: string, + client: Client, +): Promise => { + const conversations = await client.conversations.listFromCache(); + let conversation: Conversation | undefined; + conversations.some((convo) => { + if (convo.topic === topic) { + conversation = convo; + return true; + } + return false; + }); + return conversation; +}; + +const updateConversationMutex = new Mutex(); + +/** + * Update properties of a cached conversation + */ +export const updateConversation = async ( + topic: string, + update: Partial< + Pick + >, + db: Dexie, +) => + updateConversationMutex.runExclusive(async () => { + const conversationsTable = db.table( + "conversations", + ) as CachedConversationsTable; + const existing = await conversationsTable + .where("topic") + .equals(topic) + .first(); + if (existing) { + await conversationsTable.update(existing, update); + } + }); + +/** + * Update metadata of a cached conversation using the specified namespace + * + * This is not meant to be called directly + */ +export const updateConversationMetadata = async ( + topic: string, + namespace: string, + data: CachedMetadataValues, + db: Dexie, +) => { + const existing = await getCachedConversationByTopic(topic, db); + if (existing) { + const metadata = existing.metadata || {}; + metadata[namespace] = data; + await updateConversation(topic, { metadata }, db); + } +}; + +/** + * Sets the `updatedAt` field of a cached conversation + */ +export const setConversationUpdatedAt = async ( + topic: string, + updatedAt: CachedConversation["updatedAt"], + db: Dexie, +) => { + await updateConversation(topic, { updatedAt }, db); +}; + +/** + * Check to see if a topic exists in the conversations cache + */ +export const hasTopic = async (topic: string, db: Dexie) => { + const existing = await getCachedConversationByTopic(topic, db); + return !!existing; +}; + +/** + * Converts a Conversation from the XMTP network to its cached format + * + * @returns The conversation in cached format + */ +export const toCachedConversation = ( + conversation: Conversation, + walletAddress: string, +) => ({ + context: conversation.context, + createdAt: conversation.createdAt, + isReady: false, + peerAddress: conversation.peerAddress, + topic: conversation.topic, + updatedAt: conversation.createdAt, + walletAddress, +}); + +const saveConversationMutex = new Mutex(); + +/** + * Save a conversation to the cache + * + * @returns The saved cached conversation with ID + */ +export const saveConversation = async ( + conversation: CachedConversation, + db: Dexie, +) => + saveConversationMutex.runExclusive(async () => { + const conversations = db.table("conversations") as CachedConversationsTable; + + const existing = await conversations + .where("topic") + .equals(conversation.topic) + .first(); + + if (existing) { + return existing as CachedConversationWithId; + } + + // eslint-disable-next-line no-param-reassign + conversation.id = await conversations.add(conversation); + + return conversation as CachedConversationWithId; + }); diff --git a/packages/react-sdk/src/helpers/caching/db.ts b/packages/react-sdk/src/helpers/caching/db.ts new file mode 100644 index 00000000..3d3ab09c --- /dev/null +++ b/packages/react-sdk/src/helpers/caching/db.ts @@ -0,0 +1,117 @@ +import Dexie from "dexie"; +import type { Client, ContentCodec } from "@xmtp/xmtp-js"; +import type { + CachedMessage, + CachedMessageWithId, +} from "@/helpers/caching/messages"; +import type { CachedConversation } from "./conversations"; +import { textCacheConfig } from "./contentTypes/text"; + +export type CachedMetadataValue = + | string + | string[] + | number + | number[] + | boolean + | boolean[] + | null + | Uint8Array; + +export type CachedMetadataValues = + | CachedMetadataValue + | Record; + +export type CachedMetadata = { + [namespace: string]: CachedMetadataValues; +}; + +export type InternalPersistMessageOptions = { + update?: Partial>; + metadata?: CachedMetadataValues; +}; + +export type InternalPersistMessage = ( + options?: InternalPersistMessageOptions, +) => Promise>; + +export type CachedMessageProcessor = (options: { + client: Client; + conversation: CachedConversation; + db: Dexie; + message: CachedMessageWithId; + processors: CachedMessageProcessors; + persist: InternalPersistMessage; + updateConversationMetadata: (data: CachedMetadataValues) => Promise; +}) => Promise; + +export type CacheConfiguration = { + codecs?: ContentCodec[]; + namespace: string; + processors: CachedMessageProcessors; + schema?: Record; +}; + +export type CachedMessageProcessors = { + [contentType: string]: CachedMessageProcessor[]; +}; + +export type GetDBInstanceOptions = { + db?: Dexie; + cacheConfig?: CacheConfiguration[]; + version?: number; +}; + +/** + * Get a new DB instance using the passed options + */ +export const getDbInstance = (options?: GetDBInstanceOptions) => { + const db = options?.db ?? new Dexie("__XMTP__"); + + // note that duplicate keys will be overwritten + const customSchema = options?.cacheConfig?.reduce( + (result, { schema }) => ({ + ...result, + ...schema, + }), + {} as Record, + ); + + const version = options?.version ?? 1; + + db.version(version).stores({ + ...customSchema, + conversations: ` + ++id, + [topic+walletAddress], + createdAt, + peerAddress, + topic, + updatedAt, + walletAddress + `, + messages: ` + ++id, + [conversationTopic+walletAddress], + contentFallback, + contentType, + conversationTopic, + hasSendError, + senderAddress, + sentAt, + status, + uuid, + walletAddress, + xmtpID + `, + }); + + return db; +}; + +export const clearCache = async (db: Dexie) => { + // clear all data + await Promise.all(db.tables.map((table) => table.clear())); +}; + +// handle text messages by default +export const defaultCacheConfig = [textCacheConfig]; diff --git a/packages/react-sdk/src/helpers/caching/messages.test.ts b/packages/react-sdk/src/helpers/caching/messages.test.ts new file mode 100644 index 00000000..912ef25f --- /dev/null +++ b/packages/react-sdk/src/helpers/caching/messages.test.ts @@ -0,0 +1,1093 @@ +import type { ArgumentsType } from "vitest"; +import { it, expect, describe, beforeEach } from "vitest"; +import { Client, ContentTypeText, DecodedMessage } from "@xmtp/xmtp-js"; +import { Wallet } from "ethers"; +import type { CachedMessage } from "@/helpers/caching/messages"; +import { + saveMessage, + getMessageByXmtpID, + deleteMessage, + updateMessage, + updateMessageMetadata, + prepareMessageForSending, + updateMessageAfterSending, + getLastMessage, + getUnprocessedMessages, + toCachedMessage, + processMessage, + reprocessMessage, + processUnprocessedMessages, +} from "@/helpers/caching/messages"; +import type { CachedMessageProcessor } from "@/helpers/caching/db"; +import { getDbInstance, clearCache } from "@/helpers/caching/db"; +import { + saveConversation, + type CachedConversation, + getCachedConversationByTopic, +} from "@/helpers/caching/conversations"; +import { adjustDate } from "@/helpers/adjustDate"; + +const db = getDbInstance(); +const testWallet1 = Wallet.createRandom(); +const testWallet2 = Wallet.createRandom(); + +beforeEach(async () => { + await clearCache(db); +}); + +describe("toCachedMessage", () => { + it("should return a cached message in the right format", async () => { + const testClient1 = await Client.create(testWallet1, { env: "local" }); + await Client.create(testWallet2, { env: "local" }); + const testConversation = await testClient1.conversations.newConversation( + testWallet2.address, + undefined, + ); + const sentAt = new Date(); + const decodedMessage = new DecodedMessage({ + id: "testId", + messageVersion: "v2", + senderAddress: "testSenderAddress", + recipientAddress: "testRecipientAddress", + sent: sentAt, + content: "", + contentType: ContentTypeText, + contentBytes: new Uint8Array(), + contentTopic: "testTopic", + conversation: testConversation, + }); + + const cachedMessage = toCachedMessage(decodedMessage, "testWalletAddress"); + expect(cachedMessage.content).toBe(""); + expect(cachedMessage.contentBytes).toBe(undefined); + expect(cachedMessage.contentFallback).toBe(undefined); + expect(cachedMessage.contentType).toBe(ContentTypeText.toString()); + expect(cachedMessage.conversationTopic).toBe("testTopic"); + expect(cachedMessage.status).toBe("unprocessed"); + expect(cachedMessage.hasSendError).toBe(false); + expect(cachedMessage.isSending).toBe(false); + expect(cachedMessage.senderAddress).toBe("testSenderAddress"); + expect(cachedMessage.sentAt).toBe(sentAt); + expect(cachedMessage.walletAddress).toBe("testWalletAddress"); + expect(cachedMessage.xmtpID).toBe("testId"); + }); +}); + +describe("getMessageByXmtpID", () => { + it("should return a message from the cache", async () => { + const testMessage = { + id: 1, + walletAddress: "testWalletAddress", + conversationTopic: "testTopic", + content: "test", + contentType: "text", + isSending: false, + hasSendError: false, + sentAt: new Date(), + status: "processed", + senderAddress: "testWalletAddress", + uuid: "testUuid", + xmtpID: "testXmtpId", + } satisfies CachedMessage; + + await db.table("messages").add(testMessage); + const message = await getMessageByXmtpID("testXmtpId", db); + expect(message).toEqual(testMessage); + }); +}); + +describe("saveMessage", () => { + it("should save a message to the cache", async () => { + const testMessage = { + id: 1, + walletAddress: "testWalletAddress", + conversationTopic: "testTopic", + content: "test", + contentType: "text", + isSending: false, + hasSendError: false, + sentAt: new Date(), + status: "processed", + senderAddress: "testWalletAddress", + uuid: "testUuid", + xmtpID: "testXmtpId", + } satisfies CachedMessage; + + const cachedMessage = await saveMessage({ db, message: testMessage }); + expect(cachedMessage).toEqual(testMessage); + + const message = await getMessageByXmtpID("testXmtpId", db); + expect(message).toEqual(testMessage); + }); + + it("should return a duplicate message", async () => { + const testMessage = { + walletAddress: "testWalletAddress", + conversationTopic: "testTopic", + content: "test", + contentType: "text", + isSending: false, + hasSendError: false, + sentAt: new Date(), + status: "processed", + senderAddress: "testWalletAddress", + uuid: "testUuid", + xmtpID: "testXmtpId", + } satisfies CachedMessage; + const cachedMessage = await saveMessage({ db, message: testMessage }); + expect(cachedMessage).toEqual(testMessage); + const cachedMessage2 = await saveMessage({ db, message: testMessage }); + expect(cachedMessage2).toEqual(testMessage); + expect(cachedMessage.id).toBe(cachedMessage2.id); + }); +}); + +describe("deleteMessage", () => { + it("should delete a message from the cache", async () => { + const testMessage = { + id: 1, + walletAddress: "testWalletAddress", + conversationTopic: "testTopic", + content: "test", + contentType: "text", + isSending: false, + hasSendError: false, + sentAt: new Date(), + status: "unprocessed", + senderAddress: "testWalletAddress", + uuid: "testUuid", + xmtpID: "testXmtpId", + } satisfies CachedMessage; + + await saveMessage({ db, message: testMessage }); + const message = await getMessageByXmtpID("testXmtpId", db); + expect(message).toEqual(testMessage); + + if (message) { + await deleteMessage(message, db); + const deletedMessage = await getMessageByXmtpID("testXmtpId", db); + expect(deletedMessage).toBeUndefined(); + } + }); +}); + +describe("updateMessage", () => { + it("should update a message properties in the cache", async () => { + const testMessage = { + id: 1, + walletAddress: "testWalletAddress", + conversationTopic: "testTopic", + content: "test", + contentType: "text", + isSending: true, + hasSendError: false, + sentAt: new Date(), + status: "unprocessed", + senderAddress: "testWalletAddress", + uuid: "testUuid", + xmtpID: "testXmtpId", + } satisfies CachedMessage; + + const cachedMessage = await saveMessage({ db, message: testMessage }); + expect(cachedMessage).toEqual(testMessage); + + await updateMessage( + cachedMessage, + { + isSending: false, + status: "processed", + }, + db, + ); + + const updatedMessage = await getMessageByXmtpID("testXmtpId", db); + + expect(updatedMessage).toEqual({ + ...testMessage, + isSending: false, + status: "processed", + }); + }); +}); + +describe("updateMessageMetadata", () => { + it("should update a message metadata in the cache", async () => { + const testMessage = { + id: 1, + walletAddress: "testWalletAddress", + conversationTopic: "testTopic", + content: "test", + contentType: "text", + isSending: false, + hasSendError: false, + sentAt: new Date(), + status: "processed", + senderAddress: "testWalletAddress", + uuid: "testUuid", + xmtpID: "testXmtpId", + } satisfies CachedMessage; + + const cachedMessage = await saveMessage({ db, message: testMessage }); + expect(cachedMessage).toEqual(testMessage); + + await updateMessageMetadata( + cachedMessage, + "foo", + { + foo: "bar", + baz: "qux", + }, + db, + ); + + const updatedMessage = await getMessageByXmtpID("testXmtpId", db); + + expect(updatedMessage?.metadata?.foo).toEqual({ + foo: "bar", + baz: "qux", + }); + + await updateMessageMetadata( + cachedMessage, + "foo", + { + foo: "baz", + qux: "quux", + }, + db, + ); + + const updatedMessage2 = await getMessageByXmtpID("testXmtpId", db); + + expect(updatedMessage2?.metadata?.foo).toEqual({ + foo: "baz", + qux: "quux", + }); + }); +}); + +describe("prepareMessageForSending", () => { + it("should prepare a message for sending", () => { + const client = { + address: "testWalletAddress", + } as Client; + + const conversation = { + topic: "testTopic", + } as CachedConversation; + + const preparedMessage = prepareMessageForSending({ + client, + content: "test", + contentType: ContentTypeText.toString(), + conversation, + }); + + expect(preparedMessage.content).toBe("test"); + expect(preparedMessage.contentType).toBe(ContentTypeText.toString()); + expect(preparedMessage.conversationTopic).toBe("testTopic"); + expect(preparedMessage.hasSendError).toBe(false); + expect(preparedMessage.isSending).toBe(true); + expect(preparedMessage.status).toBe("unprocessed"); + expect(preparedMessage.walletAddress).toBe("testWalletAddress"); + }); + + it("should prepare a message for sending without a content type", () => { + const client = { + address: "testWalletAddress", + } as Client; + + const conversation = { + topic: "testTopic", + } as CachedConversation; + + const preparedMessage = prepareMessageForSending({ + client, + content: "test", + conversation, + }); + + expect(preparedMessage.content).toBe("test"); + expect(preparedMessage.contentType).toBe(ContentTypeText.toString()); + expect(preparedMessage.conversationTopic).toBe("testTopic"); + expect(preparedMessage.hasSendError).toBe(false); + expect(preparedMessage.isSending).toBe(true); + expect(preparedMessage.status).toBe("unprocessed"); + expect(preparedMessage.walletAddress).toBe("testWalletAddress"); + }); +}); + +describe("updateMessageAfterSending", () => { + it("should update specific properties of a message", async () => { + const testMessage = { + id: 1, + walletAddress: "testWalletAddress", + conversationTopic: "testTopic", + content: "test", + contentType: "text", + isSending: true, + hasSendError: false, + sentAt: new Date(), + status: "processed", + senderAddress: "testWalletAddress", + uuid: "testUuid", + xmtpID: "testXmtpId", + } satisfies CachedMessage; + + const cachedMessage = await saveMessage({ db, message: testMessage }); + expect(cachedMessage).toEqual(testMessage); + + const sentAt = new Date(); + + await updateMessageAfterSending(cachedMessage, sentAt, "testXmtpId2", db); + + const updatedMessage = await getMessageByXmtpID("testXmtpId2", db); + + expect(updatedMessage).toEqual({ + ...testMessage, + sentAt, + xmtpID: "testXmtpId2", + isSending: false, + sendOptions: undefined, + }); + }); +}); + +describe("getLastMessage", () => { + it("should get the last message of a conversation topic", async () => { + const sentAt = new Date(); + const testMessage = { + id: 1, + walletAddress: "testWalletAddress", + conversationTopic: "testTopic", + content: "test", + contentType: "text", + isSending: false, + hasSendError: false, + sentAt, + status: "processed", + senderAddress: "testWalletAddress", + uuid: "testUuid1", + xmtpID: "testXmtpId1", + } satisfies CachedMessage; + + const testMessage2 = { + id: 2, + walletAddress: "testWalletAddress", + conversationTopic: "testTopic", + content: "test", + contentType: "text", + isSending: false, + hasSendError: false, + sentAt: adjustDate(sentAt, 1000), + status: "processed", + senderAddress: "testWalletAddress", + uuid: "testUuid2", + xmtpID: "testXmtpId2", + } satisfies CachedMessage; + + const cachedMessage = await saveMessage({ db, message: testMessage }); + expect(cachedMessage).toEqual(testMessage); + const cachedMessage2 = await saveMessage({ db, message: testMessage2 }); + expect(cachedMessage2).toEqual(testMessage2); + + const lastMessage = await getLastMessage("testTopic", db); + expect(lastMessage).toEqual(cachedMessage2); + }); +}); + +describe("getUnprocessedMessages", () => { + it("should get all unprocessed messages", async () => { + const sentAt = new Date(); + const testMessage = { + id: 1, + walletAddress: "testWalletAddress", + conversationTopic: "testTopic", + content: "test", + contentType: "text", + isSending: false, + hasSendError: false, + sentAt, + status: "processed", + senderAddress: "testWalletAddress", + uuid: "testUuid1", + xmtpID: "testXmtpId1", + } satisfies CachedMessage; + + const testMessage2 = { + id: 2, + walletAddress: "testWalletAddress", + conversationTopic: "testTopic", + content: "test", + contentType: "text", + isSending: false, + hasSendError: false, + sentAt: adjustDate(sentAt, 1000), + status: "unprocessed", + senderAddress: "testWalletAddress", + uuid: "testUuid2", + xmtpID: "testXmtpId2", + } satisfies CachedMessage; + + const cachedMessage = await saveMessage({ db, message: testMessage }); + expect(cachedMessage).toEqual(testMessage); + const cachedMessage2 = await saveMessage({ db, message: testMessage2 }); + expect(cachedMessage2).toEqual(testMessage2); + + const unprocessedMessages = await getUnprocessedMessages(db); + expect(unprocessedMessages.length).toBe(1); + expect(unprocessedMessages[0]).toEqual(testMessage2); + }); +}); + +describe("processMessage", () => { + const mockProcessor1 = vi.fn< + ArgumentsType, + Promise + >(); + const mockProcessor2 = vi.fn(); + const mockProcessor3 = vi.fn(); + const testNamepaces = { + [ContentTypeText.toString()]: "text", + }; + const testProcessors = { + [ContentTypeText.toString()]: [mockProcessor1, mockProcessor2], + foo: [mockProcessor3], + }; + + beforeEach(() => { + mockProcessor1.mockReset(); + mockProcessor2.mockReset(); + mockProcessor3.mockReset(); + }); + + it("should process an unprocessed message with a supported content type", async () => { + const testClient = await Client.create(testWallet1, { env: "local" }); + const createdAt = new Date(); + const testConversation = { + createdAt, + updatedAt: createdAt, + isReady: false, + topic: "testTopic", + peerAddress: "testPeerAddress", + walletAddress: "testWalletAddress", + } satisfies CachedConversation; + const cachedConversation = await saveConversation(testConversation, db); + const sentAt = adjustDate(createdAt, 1000); + const testMessage = { + id: 1, + walletAddress: "testWalletAddress", + conversationTopic: "testTopic", + content: "test", + contentType: ContentTypeText.toString(), + isSending: false, + hasSendError: false, + sentAt, + status: "unprocessed", + senderAddress: "testWalletAddress", + uuid: "testUuid", + xmtpID: "testXmtpId", + } satisfies CachedMessage; + const cachedMessage = await processMessage({ + client: testClient, + conversation: cachedConversation, + db, + message: testMessage, + namespaces: testNamepaces, + processors: testProcessors, + }); + expect(cachedMessage).toEqual(testMessage); + expect(mockProcessor1).toHaveBeenCalledTimes(1); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(mockProcessor1.mock.calls[0][0].client).toBe(testClient); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(mockProcessor1.mock.calls[0][0].conversation).toBe( + cachedConversation, + ); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(mockProcessor1.mock.calls[0][0].db).toBe(db); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(mockProcessor1.mock.calls[0][0].message).toBe(cachedMessage); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(mockProcessor1.mock.calls[0][0].processors).toBe(testProcessors); + expect(mockProcessor2).toHaveBeenCalledTimes(1); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(mockProcessor2.mock.calls[0][0].client).toBe(testClient); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(mockProcessor2.mock.calls[0][0].conversation).toBe( + cachedConversation, + ); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(mockProcessor2.mock.calls[0][0].db).toBe(db); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(mockProcessor2.mock.calls[0][0].message).toBe(cachedMessage); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(mockProcessor2.mock.calls[0][0].processors).toBe(testProcessors); + expect(mockProcessor3).not.toHaveBeenCalled(); + + const updatedConversation = await getCachedConversationByTopic( + "testTopic", + db, + ); + expect(updatedConversation?.updatedAt).toEqual(sentAt); + + const savedMessage = await getMessageByXmtpID("testXmtpId", db); + expect(savedMessage).toBeUndefined(); + }); + + it("should not process an already processed message", async () => { + const testClient = await Client.create(testWallet1, { env: "local" }); + const createdAt = new Date(); + const testConversation = { + createdAt, + updatedAt: createdAt, + isReady: false, + topic: "testTopic", + peerAddress: "testPeerAddress", + walletAddress: "testWalletAddress", + } satisfies CachedConversation; + const cachedConversation = await saveConversation(testConversation, db); + const sentAt = adjustDate(createdAt, 1000); + const testMessage = { + id: 1, + walletAddress: "testWalletAddress", + conversationTopic: "testTopic", + content: "test", + contentType: ContentTypeText.toString(), + isSending: false, + hasSendError: false, + sentAt, + status: "processed", + senderAddress: "testWalletAddress", + uuid: "testUuid", + xmtpID: "testXmtpId", + } satisfies CachedMessage; + await saveMessage({ db, message: testMessage }); + const cachedMessage = await processMessage({ + client: testClient, + conversation: cachedConversation, + db, + message: testMessage, + namespaces: testNamepaces, + processors: testProcessors, + }); + expect(cachedMessage).toEqual(testMessage); + expect(mockProcessor1).not.toHaveBeenCalled(); + expect(mockProcessor2).not.toHaveBeenCalled(); + expect(mockProcessor3).not.toHaveBeenCalled(); + + const updatedConversation = await getCachedConversationByTopic( + "testTopic", + db, + ); + expect(updatedConversation?.updatedAt).not.toEqual(sentAt); + }); + + it("should remove an existing message if requested", async () => { + const testClient = await Client.create(testWallet1, { env: "local" }); + const createdAt = new Date(); + const testConversation = { + createdAt, + updatedAt: createdAt, + isReady: false, + topic: "testTopic", + peerAddress: "testPeerAddress", + walletAddress: "testWalletAddress", + } satisfies CachedConversation; + const cachedConversation = await saveConversation(testConversation, db); + const sentAt = adjustDate(createdAt, 1000); + const testMessage = { + id: 1, + walletAddress: "testWalletAddress", + conversationTopic: "testTopic", + content: "test", + contentType: ContentTypeText.toString(), + isSending: false, + hasSendError: false, + sentAt, + status: "unprocessed", + senderAddress: "testWalletAddress", + uuid: "testUuid", + xmtpID: "testXmtpId", + } satisfies CachedMessage; + const savedMessage = await saveMessage({ db, message: testMessage }); + expect(savedMessage).toEqual(testMessage); + const cachedMessage = await processMessage( + { + client: testClient, + conversation: cachedConversation, + db, + message: testMessage, + namespaces: testNamepaces, + processors: testProcessors, + }, + true, + ); + expect(cachedMessage).toEqual(testMessage); + expect(mockProcessor1).toHaveBeenCalled(); + expect(mockProcessor2).toHaveBeenCalled(); + expect(mockProcessor3).not.toHaveBeenCalled(); + + const removedMessage = await getMessageByXmtpID("testXmtpId", db); + expect(removedMessage).toBeUndefined(); + }); + + it("should cache but not process unsupported content types", async () => { + const testClient = await Client.create(testWallet1, { env: "local" }); + const createdAt = new Date(); + const testConversation = { + createdAt, + updatedAt: createdAt, + isReady: false, + topic: "testTopic", + peerAddress: "testPeerAddress", + walletAddress: "testWalletAddress", + } satisfies CachedConversation; + const cachedConversation = await saveConversation(testConversation, db); + const sentAt = adjustDate(createdAt, 1000); + const testMessage = { + id: 1, + walletAddress: "testWalletAddress", + conversationTopic: "testTopic", + content: undefined, + contentType: "foo", + isSending: false, + hasSendError: false, + sentAt, + status: "unprocessed", + senderAddress: "testWalletAddress", + uuid: "testUuid", + xmtpID: "testXmtpId", + } satisfies CachedMessage; + const cachedMessage = await processMessage( + { + client: testClient, + conversation: cachedConversation, + db, + message: testMessage, + namespaces: testNamepaces, + processors: testProcessors, + }, + true, + ); + expect(cachedMessage).toEqual(testMessage); + expect(mockProcessor1).not.toHaveBeenCalled(); + expect(mockProcessor2).not.toHaveBeenCalled(); + expect(mockProcessor3).not.toHaveBeenCalled(); + + const savedMessage = await getMessageByXmtpID("testXmtpId", db); + expect(savedMessage).toEqual(testMessage); + }); + + it("should cache a message if persist is called in its processor", async () => { + const testClient = await Client.create(testWallet1, { env: "local" }); + const createdAt = new Date(); + const testConversation = { + createdAt, + updatedAt: createdAt, + isReady: false, + topic: "testTopic", + peerAddress: "testPeerAddress", + walletAddress: "testWalletAddress", + } satisfies CachedConversation; + const cachedConversation = await saveConversation(testConversation, db); + const sentAt = adjustDate(createdAt, 1000); + const testMessage = { + id: 1, + walletAddress: "testWalletAddress", + conversationTopic: "testTopic", + content: "test", + contentType: ContentTypeText.toString(), + isSending: false, + hasSendError: false, + sentAt, + status: "unprocessed", + senderAddress: "testWalletAddress", + uuid: "testUuid", + xmtpID: "testXmtpId", + } satisfies CachedMessage; + mockProcessor1.mockImplementation(async ({ persist }) => { + await persist(); + }); + const cachedMessage = await processMessage({ + client: testClient, + conversation: cachedConversation, + db, + message: testMessage, + namespaces: testNamepaces, + processors: testProcessors, + }); + + const updatedMessage = { + ...testMessage, + status: "processed", + }; + + expect(cachedMessage).toEqual(updatedMessage); + expect(mockProcessor1).toHaveBeenCalledTimes(1); + expect(mockProcessor2).toHaveBeenCalledTimes(1); + expect(mockProcessor3).not.toHaveBeenCalled(); + + const savedMessage = await getMessageByXmtpID("testXmtpId", db); + + expect(savedMessage).toEqual(updatedMessage); + }); + + it("should cache a message with updated properties and metadata if persist is called in its processor", async () => { + const testClient = await Client.create(testWallet1, { env: "local" }); + const createdAt = new Date(); + const testConversation = { + createdAt, + updatedAt: createdAt, + isReady: false, + topic: "testTopic", + peerAddress: "testPeerAddress", + walletAddress: "testWalletAddress", + } satisfies CachedConversation; + const cachedConversation = await saveConversation(testConversation, db); + const sentAt = adjustDate(createdAt, 1000); + const testMessage = { + id: 1, + walletAddress: "testWalletAddress", + conversationTopic: "testTopic", + content: "test", + contentType: ContentTypeText.toString(), + isSending: false, + hasSendError: false, + sentAt, + status: "unprocessed", + senderAddress: "testWalletAddress", + uuid: "testUuid", + xmtpID: "testXmtpId", + } satisfies CachedMessage; + mockProcessor1.mockImplementation(async ({ persist }) => { + await persist({ + metadata: { + foo: "bar", + }, + update: { + content: "foo", + }, + }); + }); + const cachedMessage = await processMessage({ + client: testClient, + conversation: cachedConversation, + db, + message: testMessage, + namespaces: testNamepaces, + processors: testProcessors, + }); + + const updatedMessage = { + ...testMessage, + content: "foo", + status: "processed", + metadata: { + [testNamepaces[ContentTypeText.toString()]]: { + foo: "bar", + }, + }, + }; + + expect(cachedMessage).toEqual(updatedMessage); + expect(mockProcessor1).toHaveBeenCalledTimes(1); + expect(mockProcessor2).toHaveBeenCalledTimes(1); + expect(mockProcessor3).not.toHaveBeenCalled(); + + const savedMessage = await getMessageByXmtpID("testXmtpId", db); + + expect(savedMessage).toEqual(updatedMessage); + }); + + it("should update the conversation metadata of a cached message if updateConversationMetadata is called in its processor", async () => { + const testClient = await Client.create(testWallet1, { env: "local" }); + const createdAt = new Date(); + const testConversation = { + createdAt, + updatedAt: createdAt, + isReady: false, + topic: "testTopic", + peerAddress: "testPeerAddress", + walletAddress: "testWalletAddress", + } satisfies CachedConversation; + const cachedConversation = await saveConversation(testConversation, db); + const sentAt = adjustDate(createdAt, 1000); + const testMessage = { + id: 1, + walletAddress: "testWalletAddress", + conversationTopic: "testTopic", + content: "test", + contentType: ContentTypeText.toString(), + isSending: false, + hasSendError: false, + sentAt, + status: "unprocessed", + senderAddress: "testWalletAddress", + uuid: "testUuid", + xmtpID: "testXmtpId", + } satisfies CachedMessage; + mockProcessor1.mockImplementation( + async ({ updateConversationMetadata }) => { + await updateConversationMetadata({ + foo: "bar", + }); + }, + ); + const cachedMessage = await processMessage({ + client: testClient, + conversation: cachedConversation, + db, + message: testMessage, + namespaces: testNamepaces, + processors: testProcessors, + }); + + expect(cachedMessage).toEqual(testMessage); + expect(mockProcessor1).toHaveBeenCalledTimes(1); + expect(mockProcessor2).toHaveBeenCalledTimes(1); + expect(mockProcessor3).not.toHaveBeenCalled(); + + const updatedConversation = await getCachedConversationByTopic( + "testTopic", + db, + ); + expect(updatedConversation).toEqual({ + ...cachedConversation, + updatedAt: sentAt, + metadata: { + [testNamepaces[ContentTypeText.toString()]]: { + foo: "bar", + }, + }, + }); + }); +}); + +describe("reprocessMessage", () => { + const testNamepaces = { + [ContentTypeText.toString()]: "text", + foo: "bar", + }; + const testProcessors = { + [ContentTypeText.toString()]: [() => Promise.resolve()], + foo: [() => Promise.resolve()], + }; + + const processMessageMock = vi.fn(); + const decodeContentMock = vi.fn(); + beforeEach(() => { + processMessageMock.mockReset(); + decodeContentMock.mockReset(); + }); + + it("should reprocess a message under certain conditions", async () => { + const testClient = await Client.create(testWallet1, { env: "local" }); + const createdAt = new Date(); + const testConversation = { + createdAt, + updatedAt: createdAt, + isReady: false, + topic: "testTopic", + peerAddress: "testPeerAddress", + walletAddress: "testWalletAddress", + } satisfies CachedConversation; + const sentAt = adjustDate(createdAt, 1000); + const contentBytes = new TextEncoder().encode("test"); + const testMessage = { + id: 1, + walletAddress: "testWalletAddress", + conversationTopic: "testTopic", + content: undefined, + contentBytes, + contentType: ContentTypeText.toString(), + isSending: false, + hasSendError: false, + sentAt, + status: "unprocessed", + senderAddress: "testWalletAddress", + uuid: "testUuid", + xmtpID: "testXmtpId", + } satisfies CachedMessage; + decodeContentMock.mockImplementation((bytes: Uint8Array) => ({ + content: new TextDecoder().decode(bytes), + })); + await reprocessMessage({ + client: testClient, + conversation: testConversation, + db, + message: testMessage, + namespaces: testNamepaces, + processors: testProcessors, + decode: decodeContentMock, + process: processMessageMock, + }); + expect(decodeContentMock).toHaveBeenCalledWith(contentBytes, testClient); + expect(processMessageMock).toHaveBeenCalledWith( + { + client: testClient, + conversation: testConversation, + db, + message: { + ...testMessage, + content: "test", + contentBytes: undefined, + }, + namespaces: testNamepaces, + processors: testProcessors, + }, + true, + ); + }); + + it("should not reprocess a message with unsupported content", async () => { + const testClient = await Client.create(testWallet1, { env: "local" }); + const createdAt = new Date(); + const testConversation = { + createdAt, + updatedAt: createdAt, + isReady: false, + topic: "testTopic", + peerAddress: "testPeerAddress", + walletAddress: "testWalletAddress", + } satisfies CachedConversation; + const sentAt = adjustDate(createdAt, 1000); + const contentBytes = new TextEncoder().encode("foo"); + const testMessage = { + id: 1, + walletAddress: "testWalletAddress", + conversationTopic: "testTopic", + content: undefined, + contentBytes, + contentType: "foo", + isSending: false, + hasSendError: false, + sentAt, + status: "unprocessed", + senderAddress: "testWalletAddress", + uuid: "testUuid", + xmtpID: "testXmtpId", + } satisfies CachedMessage; + decodeContentMock.mockImplementation(() => ({ + content: undefined, + })); + await reprocessMessage({ + client: testClient, + conversation: testConversation, + db, + message: testMessage, + namespaces: testNamepaces, + processors: testProcessors, + decode: decodeContentMock, + process: processMessageMock, + }); + expect(decodeContentMock).toHaveBeenCalledWith(contentBytes, testClient); + expect(processMessageMock).not.toHaveBeenCalled(); + }); +}); + +describe("processUnprocessedMessages", () => { + it("should process unprocessed messages", async () => { + const testClient = await Client.create(testWallet1, { env: "local" }); + const createdAt = new Date(); + const sentAt = adjustDate(createdAt, 1000); + const testConversation = { + createdAt, + updatedAt: createdAt, + isReady: false, + topic: "testTopic", + peerAddress: "testPeerAddress", + walletAddress: testWallet1.address, + } satisfies CachedConversation; + const cachedConversation = await saveConversation(testConversation, db); + const testMessage1 = { + id: 1, + walletAddress: testWallet1.address, + conversationTopic: "testTopic", + content: "test", + contentType: ContentTypeText.toString(), + isSending: false, + hasSendError: false, + sentAt, + status: "unprocessed", + senderAddress: testWallet1.address, + uuid: "testUuid", + xmtpID: "testXmtpId", + } satisfies CachedMessage; + await saveMessage({ db, message: testMessage1 }); + const testMessage2 = { + id: 1, + walletAddress: testWallet1.address, + conversationTopic: "testTopic", + content: "test", + contentType: ContentTypeText.toString(), + isSending: false, + hasSendError: false, + sentAt, + status: "processed", + senderAddress: testWallet1.address, + uuid: "testUuid", + xmtpID: "testXmtpId", + } satisfies CachedMessage; + await saveMessage({ db, message: testMessage2 }); + const mockReprocessMessage = vi.fn(); + const namespaces = { + [ContentTypeText.toString()]: "text", + }; + const processors = { + [ContentTypeText.toString()]: [() => Promise.resolve()], + }; + await processUnprocessedMessages({ + client: testClient, + db, + namespaces, + processors, + reprocess: mockReprocessMessage, + }); + expect(mockReprocessMessage).toHaveBeenCalledTimes(1); + expect(mockReprocessMessage).toHaveBeenCalledWith({ + conversation: cachedConversation, + client: testClient, + db, + message: testMessage1, + namespaces, + processors, + }); + }); + + it("should not process unprocessed messages without a conversation", async () => { + const testClient = await Client.create(testWallet1, { env: "local" }); + const createdAt = new Date(); + const sentAt = adjustDate(createdAt, 1000); + const testMessage1 = { + id: 1, + walletAddress: testWallet1.address, + conversationTopic: "testTopic", + content: "test", + contentType: ContentTypeText.toString(), + isSending: false, + hasSendError: false, + sentAt, + status: "unprocessed", + senderAddress: testWallet1.address, + uuid: "testUuid", + xmtpID: "testXmtpId", + } satisfies CachedMessage; + await saveMessage({ db, message: testMessage1 }); + const mockReprocessMessage = vi.fn(); + const namespaces = { + [ContentTypeText.toString()]: "text", + }; + const processors = { + [ContentTypeText.toString()]: [() => Promise.resolve()], + }; + await processUnprocessedMessages({ + client: testClient, + db, + namespaces, + processors, + reprocess: mockReprocessMessage, + }); + expect(mockReprocessMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react-sdk/src/helpers/caching/messages.ts b/packages/react-sdk/src/helpers/caching/messages.ts new file mode 100644 index 00000000..49a7e3bd --- /dev/null +++ b/packages/react-sdk/src/helpers/caching/messages.ts @@ -0,0 +1,509 @@ +import type { Client, DecodedMessage, SendOptions } from "@xmtp/xmtp-js"; +import { ContentTypeText, decodeContent } from "@xmtp/xmtp-js"; +import type { Table } from "dexie"; +import type Dexie from "dexie"; +import { isAfter } from "date-fns"; +import { Mutex } from "async-mutex"; +import { v4 } from "uuid"; +import type { + CachedMessageProcessors, + CachedMetadata, + CachedMetadataValues, + InternalPersistMessage, +} from "./db"; +import type { CachedConversation } from "./conversations"; +import { + getCachedConversationByTopic, + setConversationUpdatedAt, + updateConversationMetadata as _updateConversationMetadata, +} from "./conversations"; + +export type CachedMessage = { + content: C; + contentBytes?: Uint8Array; + contentFallback?: string; + contentType: string; + conversationTopic: string; + hasSendError: boolean; + id?: number; + isSending: boolean; + metadata?: M; + senderAddress: string; + sendOptions?: SendOptions; + sentAt: Date; + status: "unprocessed" | "processed"; + uuid: string; + walletAddress: string; + xmtpID: string; +}; + +export type CachedMessagesTable = Table; + +export type CachedMessageWithId = CachedMessage & { + id: number; +}; + +/** + * Converts a DecodedMessage from the XMTP network to its cached format + * + * @returns The message in cached format + */ +export const toCachedMessage = ( + message: DecodedMessage, + walletAddress: string, +) => { + // if message content is undefined, its content type is not yet supported + // by the client + const isSupported = message.content !== undefined; + + return { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + content: message.content, + // store content bytes if the content type is not supported so we can + // process it later if support is added + contentBytes: !isSupported ? message.contentBytes : undefined, + contentFallback: message.contentFallback, + contentType: message.contentType.toString(), + conversationTopic: message.contentTopic, + status: "unprocessed", + hasSendError: false, + isSending: false, + senderAddress: message.senderAddress, + sentAt: message.sent, + uuid: v4(), + walletAddress, + xmtpID: message.id, + } satisfies CachedMessage; +}; + +/** + * Retrieve a message from the cache by its XMTP ID + * + * @returns The cached message, or `undefined` if not found + */ +export const getMessageByXmtpID = async (xmtpID: string, db: Dexie) => { + const messages = db.table("messages") as CachedMessagesTable; + const message = await messages.where("xmtpID").equals(xmtpID).first(); + return message ? (message as CachedMessageWithId) : undefined; +}; + +export type SaveMessageOptions = Omit< + ProcessMessageOptions, + "client" | "conversation" | "processors" | "namespaces" +>; + +/** + * Save a message to the cache + * + * @returns The newly cached message, or an already existing cached message + */ +export const saveMessage = async ({ db, message }: SaveMessageOptions) => { + const messages = db.table("messages") as CachedMessagesTable; + + // check if message already exists + const existing = await getMessageByXmtpID(message.xmtpID, db); + + if (existing) { + // return the existing message + return existing as CachedMessageWithId; + } + + // eslint-disable-next-line no-param-reassign + message.id = await messages.add(message); + + return message as CachedMessageWithId; +}; + +/** + * Remove a message from the cache + */ +export const deleteMessage = async ( + message: CachedMessageWithId, + db: Dexie, +) => { + const messagesTable = db.table("messages") as CachedMessagesTable; + + // make sure message exists + const existing = await messagesTable.where("id").equals(message.id).first(); + if (existing) { + await messagesTable.delete(message.id); + } +}; + +const updateMessageMutex = new Mutex(); + +/** + * Update properties of a cached message + */ +export const updateMessage = async ( + message: CachedMessage, + update: Partial< + Pick< + CachedMessage, + | "status" + | "isSending" + | "sentAt" + | "xmtpID" + | "metadata" + | "hasSendError" + | "sendOptions" + > + >, + db: Dexie, +) => { + await updateMessageMutex.runExclusive(async () => { + const messagesTable = db.table("messages") as CachedMessagesTable; + await messagesTable.update(message, update); + }); +}; + +const updateMessageMetadataMutex = new Mutex(); + +/** + * Update metadata of a cached message using the specified namespace + * + * This is not meant to be called directly + */ +export const updateMessageMetadata = async ( + message: CachedMessage, + namespace: string, + data: CachedMetadataValues, + db: Dexie, +) => + updateMessageMetadataMutex.runExclusive(async () => { + const metadata = message.metadata || {}; + metadata[namespace] = data; + return updateMessage(message, { metadata }, db); + }); + +export type PrepareMessageOptions = Pick & + Pick, "contentType"> & { + client: Client; + conversation: CachedConversation; + }; + +/** + * Prepare a message for sending by creating a new cached message based on all + * the passed properties + * + * @returns a new cached message + */ +export const prepareMessageForSending = ({ + client, + content, + contentType, + conversation, +}: PrepareMessageOptions): CachedMessage => { + // this will be updated after it's sent + const sentAt = new Date(); + return { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + content, + contentType: contentType ?? ContentTypeText.toString(), + conversationTopic: conversation.topic, + hasSendError: false, + isSending: true, + senderAddress: client.address, + sentAt, + status: "unprocessed", + uuid: v4(), + walletAddress: client.address, + // this will be updated after it's sent + xmtpID: sentAt.getTime().toString(), + }; +}; + +const updateMessageAfterSendingMutex = new Mutex(); + +/** + * Update some message properties after it's successfully sent + */ +export const updateMessageAfterSending = async ( + message: CachedMessage, + sentAt: Date, + xmtpID: string, + db: Dexie, +) => + updateMessageAfterSendingMutex.runExclusive(async () => + updateMessage( + message, + { + hasSendError: false, + isSending: false, + sendOptions: undefined, + sentAt, + xmtpID, + }, + db, + ), + ); + +const processMessageMutex = new Mutex(); + +export type ProcessMessageOptions = { + client: Client; + conversation: CachedConversation; + db: Dexie; + message: CachedMessage; + namespaces: Record; + processors: CachedMessageProcessors; +}; + +export type ReprocessMessageOptions = ProcessMessageOptions & { + /** + * This is a convenience option to override the default message processor + * for testing purposes and should not be used in production. + */ + process?: typeof processMessage; + /** + * This is a convenience option to override the default content decoder + * for testing purposes and should not be used in production. + */ + decode?: typeof decodeContent; +}; + +/** + * Process a cached message using the passed parameters. Optionally remove + * an existing message before processing. + */ +export const processMessage = async ( + { + client, + conversation, + db, + message, + namespaces, + processors, + }: ProcessMessageOptions, + removeExisting = false, +) => + processMessageMutex.runExclusive(async () => { + const existingMessage = await getMessageByXmtpID(message.xmtpID, db); + // don't re-process an existing message + if (existingMessage && existingMessage.status === "processed") { + return message; + } + + let persistedMessage: CachedMessageWithId | undefined; + const namespace = namespaces[message.contentType]; + + // internal persist function with preset namespace + const persist: InternalPersistMessage = async ({ + metadata, + update, + } = {}) => { + const updatedMetadata = { ...message.metadata }; + if (metadata && namespace) { + updatedMetadata[namespace] = metadata; + } + const updatedMessage = { + ...message, + ...update, + }; + + if (Object.keys(updatedMetadata).length > 0) { + updatedMessage.metadata = updatedMetadata; + } + + persistedMessage = await saveMessage({ + db, + message: updatedMessage, + }); + return persistedMessage; + }; + + // internal updater function with preset namespace + const updateConversationMetadata = async (data: CachedMetadataValues) => { + await _updateConversationMetadata( + conversation.topic, + namespace, + data, + db, + ); + }; + + // message content type is not supported, skip processing + if (message.content === undefined) { + // don't persist the message if it already exists in the cache + if (!(await getMessageByXmtpID(message.xmtpID, db))) { + // persist the message to cache so that it can be processed later + const savedMessage = await saveMessage({ + db, + message, + }); + return savedMessage; + } + return message; + } + + // remove existing message if requested + if ( + removeExisting && + message.id && + (await getMessageByXmtpID(message.xmtpID, db)) + ) { + await deleteMessage(message as CachedMessageWithId, db); + } + + if (processors[message.contentType]) { + // run all content processors for this content type + await Promise.all( + processors[message.contentType].map((processor) => + processor({ + client, + conversation, + db, + message: message as CachedMessageWithId, + processors, + persist, + updateConversationMetadata, + }), + ), + ); + } + + // update conversation's last message time + if (isAfter(message.sentAt, conversation.updatedAt)) { + await setConversationUpdatedAt(conversation.topic, message.sentAt, db); + } + + // if the message was cached, update its `status` to `processed` + if (persistedMessage) { + await updateMessage(persistedMessage, { status: "processed" }, db); + } + + return persistedMessage ?? message; + }); + +/** + * Reprocessing a message if it has the following requirements: + * + * - Message content must be undefined (not decoded) + * - Message content bytes must be defined + * - Client must be defined (for decoding) + * - Message content type must have a processor + * + * Reprocessing a message will remove its original entry in the cache and + * create a new one. + */ +export const reprocessMessage = async ({ + client, + conversation, + db, + message, + namespaces, + processors, + process = processMessage, + decode = decodeContent, +}: ReprocessMessageOptions) => { + if ( + message.content === undefined && + message.contentBytes && + client && + processors[message.contentType] + ) { + // decode message content + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const content = await decode(message.contentBytes, client); + + // content type is still not supported, skip processing + if (content.content === undefined) { + return message; + } + + // process message with decoded content, remove existing message + return process( + { + conversation, + client, + db, + message: { + ...message, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + content: content.content, + contentBytes: undefined, + }, + namespaces, + processors, + }, + true, + ); + } + + return message; +}; + +/** + * Retrieve the last message of conversation in the cache + */ +export const getLastMessage = async (topic: string, db: Dexie) => { + const messagesTable = db.table("messages") as CachedMessagesTable; + const messages = await messagesTable + .where({ + conversationTopic: topic, + }) + .reverse() + .sortBy("sentAt"); + return messages[0]; +}; + +/** + * Retrieve all unprocessed messages in the cache + */ +export const getUnprocessedMessages = async (db: Dexie) => { + const messagesTable = db.table("messages") as CachedMessagesTable; + const messages = await messagesTable + .where({ + status: "unprocessed", + }) + .toArray(); + return messages; +}; + +export type ProcessUnprocessedMessagesOptions = Omit< + ProcessMessageOptions, + "conversation" | "message" +> & { + /** + * This is a convenience option to override the default `reprocessMessage` + * for testing purposes and should not be used in production. + */ + reprocess?: typeof reprocessMessage; +}; + +const processUnprocessedMessagesMutex = new Mutex(); + +/** + * Process all unprocessed messages in the cache + */ +export const processUnprocessedMessages = async ({ + client, + db, + namespaces, + processors, + reprocess = reprocessMessage, +}: ProcessUnprocessedMessagesOptions) => { + await processUnprocessedMessagesMutex.runExclusive(async () => { + const unprocessed = await getUnprocessedMessages(db); + await Promise.all( + unprocessed.map(async (unprocessedMessage) => { + // get message's conversation from cache + const conversation = await getCachedConversationByTopic( + unprocessedMessage.conversationTopic, + db, + ); + // must have a conversation already in the cache + if (conversation) { + await reprocess({ + conversation, + client, + db, + message: unprocessedMessage, + namespaces, + processors, + }); + } + }), + ); + }); +}; diff --git a/packages/react-sdk/src/helpers/combineCodecs.test.ts b/packages/react-sdk/src/helpers/combineCodecs.test.ts new file mode 100644 index 00000000..d77558e5 --- /dev/null +++ b/packages/react-sdk/src/helpers/combineCodecs.test.ts @@ -0,0 +1,36 @@ +import { it, expect, describe } from "vitest"; +import { + AttachmentCodec, + RemoteAttachmentCodec, +} from "@xmtp/content-type-remote-attachment"; +import { ReactionCodec } from "@xmtp/content-type-reaction"; +import { ReadReceiptCodec } from "@xmtp/content-type-read-receipt"; +import { ReplyCodec } from "@xmtp/content-type-reply"; +import { combineCodecs } from "@/helpers/combineCodecs"; +import { attachmentsCacheConfig } from "@/helpers/caching/contentTypes/attachment"; +import { reactionsCacheConfig } from "@/helpers/caching/contentTypes/reaction"; +import { readReceiptsCacheConfig } from "@/helpers/caching/contentTypes/readReceipt"; +import { repliesCacheConfig } from "@/helpers/caching/contentTypes/reply"; + +const testCacheConfig = [ + attachmentsCacheConfig, + reactionsCacheConfig, + readReceiptsCacheConfig, + repliesCacheConfig, +]; + +describe("combineCodecs", () => { + it("should combine codecs from a cache config", () => { + expect(combineCodecs(testCacheConfig)).toEqual([ + new AttachmentCodec(), + new RemoteAttachmentCodec(), + new ReactionCodec(), + new ReadReceiptCodec(), + new ReplyCodec(), + ]); + }); + + it("should have no codecs without a cache config", () => { + expect(combineCodecs()).toEqual([]); + }); +}); diff --git a/packages/react-sdk/src/helpers/combineCodecs.ts b/packages/react-sdk/src/helpers/combineCodecs.ts new file mode 100644 index 00000000..56393088 --- /dev/null +++ b/packages/react-sdk/src/helpers/combineCodecs.ts @@ -0,0 +1,19 @@ +import type { ContentCodec } from "@xmtp/xmtp-js"; +import { + defaultCacheConfig, + type CacheConfiguration, +} from "@/helpers/caching/db"; + +/** + * Formats all codecs into a simple array + * + * @param cacheConfig The cache configuration to extract the codecs from + * @returns An array of codecs + */ +export const combineCodecs = (cacheConfig?: CacheConfiguration[]) => { + const finalCacheConfig = [...defaultCacheConfig, ...(cacheConfig ?? [])]; + return finalCacheConfig.reduce( + (result, config) => [...result, ...(config.codecs ?? [])], + [] as ContentCodec[], + ); +}; diff --git a/packages/react-sdk/src/helpers/combineMessageProcessors.test.ts b/packages/react-sdk/src/helpers/combineMessageProcessors.test.ts new file mode 100644 index 00000000..d0f3befe --- /dev/null +++ b/packages/react-sdk/src/helpers/combineMessageProcessors.test.ts @@ -0,0 +1,54 @@ +import { it, expect, describe } from "vitest"; +import { + ContentTypeAttachment, + ContentTypeRemoteAttachment, +} from "@xmtp/content-type-remote-attachment"; +import { ContentTypeReaction } from "@xmtp/content-type-reaction"; +import { ContentTypeReadReceipt } from "@xmtp/content-type-read-receipt"; +import { ContentTypeReply } from "@xmtp/content-type-reply"; +import { ContentTypeText } from "@xmtp/xmtp-js"; +import { combineMessageProcessors } from "@/helpers/combineMessageProcessors"; +import { + attachmentsCacheConfig, + processAttachment, + processRemoteAttachment, +} from "@/helpers/caching/contentTypes/attachment"; +import { + processReaction, + reactionsCacheConfig, +} from "@/helpers/caching/contentTypes/reaction"; +import { + processReadReceipt, + readReceiptsCacheConfig, +} from "@/helpers/caching/contentTypes/readReceipt"; +import { + processReply, + repliesCacheConfig, +} from "@/helpers/caching/contentTypes/reply"; +import { processText } from "@/helpers/caching/contentTypes/text"; + +const testCacheConfig = [ + attachmentsCacheConfig, + reactionsCacheConfig, + readReceiptsCacheConfig, + repliesCacheConfig, +]; + +describe("combineMessageProcessors", () => { + it("should combine message processors from a cache config", () => { + expect(combineMessageProcessors(testCacheConfig)).toEqual({ + [ContentTypeAttachment.toString()]: [processAttachment], + [ContentTypeRemoteAttachment.toString()]: [processRemoteAttachment], + [ContentTypeReaction.toString()]: [processReaction], + [ContentTypeReadReceipt.toString()]: [processReadReceipt], + [ContentTypeReply.toString()]: [processReply], + [ContentTypeText.toString()]: [processText], + }); + }); + + it("should only have text message processors without a cache config", () => { + expect(combineMessageProcessors()).toEqual({ + [ContentTypeText.toString()]: [processText], + }); + }); +}); diff --git a/packages/react-sdk/src/helpers/combineMessageProcessors.ts b/packages/react-sdk/src/helpers/combineMessageProcessors.ts new file mode 100644 index 00000000..84539b70 --- /dev/null +++ b/packages/react-sdk/src/helpers/combineMessageProcessors.ts @@ -0,0 +1,32 @@ +import { defaultCacheConfig } from "@/helpers/caching/db"; +import type { + CachedMessageProcessors, + CacheConfiguration, +} from "@/helpers/caching/db"; + +/** + * Formats message processors for easy consumption + * + * @param cacheConfig The cache configuration to extract the processors from + * @returns An object that maps content types to their respective message processors + */ +export const combineMessageProcessors = ( + cacheConfig?: CacheConfiguration[], +) => { + const finalCacheConfig = [...defaultCacheConfig, ...(cacheConfig ?? [])]; + return { + ...finalCacheConfig.reduce((result, config) => { + const update = Object.entries(config.processors).reduce( + (updateResult, [contentType, contentProcessors]) => ({ + ...updateResult, + [contentType]: [...(result[contentType] ?? []), ...contentProcessors], + }), + {} as CachedMessageProcessors, + ); + return { + ...result, + ...update, + }; + }, {} as CachedMessageProcessors), + }; +}; diff --git a/packages/react-sdk/src/helpers/combineNamespaces.test.ts b/packages/react-sdk/src/helpers/combineNamespaces.test.ts new file mode 100644 index 00000000..a5f32bdc --- /dev/null +++ b/packages/react-sdk/src/helpers/combineNamespaces.test.ts @@ -0,0 +1,53 @@ +import { it, expect, describe } from "vitest"; +import { + ContentTypeAttachment, + ContentTypeRemoteAttachment, +} from "@xmtp/content-type-remote-attachment"; +import { ContentTypeReaction } from "@xmtp/content-type-reaction"; +import { ContentTypeReadReceipt } from "@xmtp/content-type-read-receipt"; +import { ContentTypeReply } from "@xmtp/content-type-reply"; +import { ContentTypeText } from "@xmtp/xmtp-js"; +import { combineNamespaces } from "@/helpers/combineNamespaces"; +import { attachmentsCacheConfig } from "@/helpers/caching/contentTypes/attachment"; +import { reactionsCacheConfig } from "@/helpers/caching/contentTypes/reaction"; +import { readReceiptsCacheConfig } from "@/helpers/caching/contentTypes/readReceipt"; +import { repliesCacheConfig } from "@/helpers/caching/contentTypes/reply"; + +const testCacheConfig = [ + attachmentsCacheConfig, + reactionsCacheConfig, + readReceiptsCacheConfig, + repliesCacheConfig, +]; + +describe("combineNamespaces", () => { + it("should combine namespaces from a cache config", () => { + expect(combineNamespaces(testCacheConfig)).toEqual({ + [ContentTypeAttachment.toString()]: "attachment", + [ContentTypeRemoteAttachment.toString()]: "attachment", + [ContentTypeReaction.toString()]: "reactions", + [ContentTypeReadReceipt.toString()]: "readReceipt", + [ContentTypeReply.toString()]: "replies", + [ContentTypeText.toString()]: "text", + }); + }); + + it("should only have a text namespace without a cache config", () => { + expect(combineNamespaces()).toEqual({ + [ContentTypeText.toString()]: "text", + }); + }); + + it("should throw when there's a duplicate namespace", () => { + expect(() => + combineNamespaces([ + { + namespace: "text", + processors: { + foo: [() => Promise.resolve()], + }, + }, + ]), + ).toThrow(`Duplicate cache config namespace detected: "text"`); + }); +}); diff --git a/packages/react-sdk/src/helpers/combineNamespaces.ts b/packages/react-sdk/src/helpers/combineNamespaces.ts new file mode 100644 index 00000000..eb8d55a2 --- /dev/null +++ b/packages/react-sdk/src/helpers/combineNamespaces.ts @@ -0,0 +1,41 @@ +import { + defaultCacheConfig, + type CacheConfiguration, +} from "@/helpers/caching/db"; + +/** + * Formats namespaces for easy consumption + * + * @param cacheConfig The cache configuration to extract the namespaces from + * @returns An object that maps content types to their respective namespace + */ +export const combineNamespaces = (cacheConfig?: CacheConfiguration[]) => { + // merge default config with passed in config + const finalCacheConfig = [...defaultCacheConfig, ...(cacheConfig ?? [])]; + // array of namespaces for detecting duplicates + const namespaces: string[] = []; + return finalCacheConfig.reduce( + (result, config) => { + // prevent duplicate namespaces + if (namespaces.includes(config.namespace)) { + throw new Error( + `Duplicate cache config namespace detected: "${config.namespace}"`, + ); + } + namespaces.push(config.namespace); + // assign namespaces to content types + const names = Object.entries(config.processors).reduce( + (namespacesResult, [contentType]) => ({ + ...namespacesResult, + [contentType]: config.namespace, + }), + {} as Record, + ); + return { + ...result, + ...names, + }; + }, + {} as Record, + ); +}; diff --git a/packages/react-sdk/src/helpers/getConversationId.ts b/packages/react-sdk/src/helpers/getConversationId.ts deleted file mode 100644 index 44039e3e..00000000 --- a/packages/react-sdk/src/helpers/getConversationId.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { Conversation } from "@xmtp/xmtp-js"; - -/** - * Create a unique conversation ID based on sender/receiver addresses and - * context values - */ -export const getConversationId = (conversation?: Conversation): string => - [ - conversation?.clientAddress, - conversation?.peerAddress, - conversation?.context?.conversationId, - ] - .filter((v) => Boolean(v)) - .join("/"); diff --git a/packages/react-sdk/src/helpers/isValidAddress.test.ts b/packages/react-sdk/src/helpers/isValidAddress.test.ts new file mode 100644 index 00000000..bfef1970 --- /dev/null +++ b/packages/react-sdk/src/helpers/isValidAddress.test.ts @@ -0,0 +1,17 @@ +import { it, expect, describe } from "vitest"; +import { isValidAddress } from "@/helpers/isValidAddress"; + +describe("isValidAddress", () => { + it("should return true for a valid address", () => { + expect(isValidAddress("0x1234567890123456789012345678901234567890")).toBe( + true, + ); + }); + + it("should return false for an invalid address", () => { + expect(isValidAddress("0x123456789012345")).toBe(false); + expect(isValidAddress("123456789012345678901234567890123456789012")).toBe( + false, + ); + }); +}); diff --git a/packages/react-sdk/src/helpers/isValidAddress.ts b/packages/react-sdk/src/helpers/isValidAddress.ts index 05358a7a..e01db01c 100644 --- a/packages/react-sdk/src/helpers/isValidAddress.ts +++ b/packages/react-sdk/src/helpers/isValidAddress.ts @@ -1,5 +1,8 @@ /** * Check if a wallet address is valid. + * + * @param address The string to validate + * @returns `true` if address is valid */ export const isValidAddress = (address: string) => address.startsWith("0x") && address.length === 42; diff --git a/packages/react-sdk/src/helpers/messagesDb.ts b/packages/react-sdk/src/helpers/messagesDb.ts deleted file mode 100644 index 19e3bfe8..00000000 --- a/packages/react-sdk/src/helpers/messagesDb.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { DecodedMessage } from "@xmtp/xmtp-js"; -import type { Table } from "dexie"; -import Dexie from "dexie"; -import { getConversationId } from "./getConversationId"; - -export type CachedMessage = { - id: string; - cId: string; - bytes: Uint8Array; - recipientAddress?: string; - senderAddress: string; - sent: Date; -}; - -/** - * This must be a getter function as certain configurations of webpack - * (i.e. Next.js) will flag Dexie as an unused import and incorrectly transpile - * this code replacing Dexie with null. - */ -const getMessagesDb = () => { - class MessagesDB extends Dexie { - messages!: Table; - - constructor() { - super("__XMTP__"); - this.version(1).stores({ - messages: "id, [cId+sent]", - }); - } - - // persist message to cache - async persistMessage(message: DecodedMessage) { - const { id, sent, recipientAddress, senderAddress } = message; - await this.messages.put( - { - id, - cId: getConversationId(message.conversation), - bytes: message.toBytes(), - recipientAddress, - senderAddress, - sent, - }, - [id, sent], - ); - } - } - return new MessagesDB(); -}; - -export default getMessagesDb(); diff --git a/packages/react-sdk/src/helpers/updateLastEntry.ts b/packages/react-sdk/src/helpers/updateLastEntry.ts deleted file mode 100644 index e7620bfa..00000000 --- a/packages/react-sdk/src/helpers/updateLastEntry.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { SortDirection, type DecodedMessage } from "@xmtp/xmtp-js"; -import { adjustDate } from "./adjustDate"; - -export type UpdateLastEntryOptions = { - direction?: string; - endTimeRef: React.MutableRefObject; - startTimeRef: React.MutableRefObject; - lastEntry?: DecodedMessage; - lastEntryRef: React.MutableRefObject; -}; - -/** - * Update passed references for paging through messages - */ -export const updateLastEntry = ({ - direction, - endTimeRef, - startTimeRef, - lastEntry, - lastEntryRef, -}: UpdateLastEntryOptions) => { - if (lastEntry) { - // eslint-disable-next-line no-param-reassign - lastEntryRef.current = lastEntry; - if (direction === SortDirection.SORT_DIRECTION_DESCENDING) { - // eslint-disable-next-line no-param-reassign - endTimeRef.current = adjustDate(lastEntry.sent, -1); - } else { - // eslint-disable-next-line no-param-reassign - startTimeRef.current = adjustDate(lastEntry.sent, 1); - } - } -}; diff --git a/packages/react-sdk/src/hooks/useCachedConversations.test.ts b/packages/react-sdk/src/hooks/useCachedConversations.test.ts new file mode 100644 index 00000000..eb6e8723 --- /dev/null +++ b/packages/react-sdk/src/hooks/useCachedConversations.test.ts @@ -0,0 +1,62 @@ +import { it, expect, describe, beforeEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { getDbInstance, clearCache } from "@/helpers/caching/db"; +import type { CachedConversation } from "@/helpers/caching/conversations"; +import { saveConversation } from "@/helpers/caching/conversations"; + +const db = getDbInstance(); +const testWalletAddress = "testAddress"; +const testPeerAddress = "testPeerAddress"; + +vi.mock("./useDb", () => ({ + useDb: () => ({ + db, + }), +})); + +vi.mock("./useClient", () => ({ + useClient: () => ({ + client: { + address: testWalletAddress, + }, + }), +})); + +// this import must be after the mocks +// eslint-disable-next-line import/first +import { useCachedConversations } from "./useCachedConversations"; + +describe("useCachedConversations", () => { + beforeEach(async () => { + await clearCache(db); + }); + + it("should return no conversations when the cache is empty", async () => { + const { result } = renderHook(() => useCachedConversations()); + + await waitFor(() => { + expect(result.current).toEqual([]); + }); + }); + + it("should return conversations when they're added to the cache", async () => { + const testConversation = { + id: 1, + createdAt: new Date(), + updatedAt: new Date(), + isReady: false, + walletAddress: testWalletAddress, + topic: "testTopic", + peerAddress: testPeerAddress, + } satisfies CachedConversation; + + await saveConversation(testConversation, db); + + const { result } = renderHook(() => useCachedConversations()); + + await waitFor(() => { + expect(result.current.length).toBe(1); + expect(result.current[0]).toEqual(testConversation); + }); + }); +}); diff --git a/packages/react-sdk/src/hooks/useCachedConversations.ts b/packages/react-sdk/src/hooks/useCachedConversations.ts new file mode 100644 index 00000000..49d9ba65 --- /dev/null +++ b/packages/react-sdk/src/hooks/useCachedConversations.ts @@ -0,0 +1,28 @@ +import { useLiveQuery } from "dexie-react-hooks"; +import { useDb } from "./useDb"; +import type { CachedConversationsTable } from "@/helpers/caching/conversations"; +import { useClient } from "@/hooks/useClient"; + +/** + * This hook returns cached conversations from the local cache based on the + * current client's address + * + * It's intended to be used internally and is not exported from the SDK + */ +export const useCachedConversations = () => { + const { db } = useDb(); + const { client } = useClient(); + return ( + useLiveQuery(async () => { + // client required for address + if (!client) { + return []; + } + return (db.table("conversations") as CachedConversationsTable) + .where("walletAddress") + .equals(client.address) + .reverse() + .sortBy("updatedAt"); + }, [client?.address]) ?? [] + ); +}; diff --git a/packages/react-sdk/src/hooks/useCachedMessages.noclient.test.ts b/packages/react-sdk/src/hooks/useCachedMessages.noclient.test.ts new file mode 100644 index 00000000..4a845c65 --- /dev/null +++ b/packages/react-sdk/src/hooks/useCachedMessages.noclient.test.ts @@ -0,0 +1,36 @@ +import { it, expect, describe, beforeEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { getDbInstance, clearCache } from "@/helpers/caching/db"; + +const db = getDbInstance(); +const testTopic = "testTopic"; + +vi.mock("./useDb", () => ({ + useDb: () => ({ + db, + }), +})); + +vi.mock("./useClient", () => ({ + useClient: () => ({ + client: undefined, + }), +})); + +// this import must be after the mocks +// eslint-disable-next-line import/first +import { useCachedMessages } from "./useCachedMessages"; + +describe("useCachedMessages (no client)", () => { + beforeEach(async () => { + await clearCache(db); + }); + + it("should return no messages when the client is undefined", async () => { + const { result } = renderHook(() => useCachedMessages(testTopic)); + + await waitFor(() => { + expect(result.current).toEqual([]); + }); + }); +}); diff --git a/packages/react-sdk/src/hooks/useCachedMessages.test.ts b/packages/react-sdk/src/hooks/useCachedMessages.test.ts new file mode 100644 index 00000000..c8817744 --- /dev/null +++ b/packages/react-sdk/src/hooks/useCachedMessages.test.ts @@ -0,0 +1,68 @@ +import { it, expect, describe, beforeEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { ContentTypeText } from "@xmtp/xmtp-js"; +import { getDbInstance, clearCache } from "@/helpers/caching/db"; +import type { CachedMessage } from "@/helpers/caching/messages"; +import { saveMessage } from "@/helpers/caching/messages"; + +const db = getDbInstance(); +const testWalletAddress = "testAddress"; +const testTopic = "testTopic"; + +vi.mock("./useDb", () => ({ + useDb: () => ({ + db, + }), +})); + +vi.mock("./useClient", () => ({ + useClient: () => ({ + client: { + address: testWalletAddress, + }, + }), +})); + +// this import must be after the mocks +// eslint-disable-next-line import/first +import { useCachedMessages } from "./useCachedMessages"; + +describe("useCachedMessages", () => { + beforeEach(async () => { + await clearCache(db); + }); + + it("should return no messages when the cache is empty", async () => { + const { result } = renderHook(() => useCachedMessages(testTopic)); + + await waitFor(() => { + expect(result.current).toEqual([]); + }); + }); + + it("should return messages when they're added to the cache", async () => { + const testMessage = { + id: 1, + walletAddress: testWalletAddress, + conversationTopic: testTopic, + content: "test", + contentType: ContentTypeText.toString(), + isSending: false, + hasSendError: false, + sentAt: new Date(), + status: "processed", + senderAddress: testWalletAddress, + uuid: "testUuid", + xmtpID: "testXmtpId", + } satisfies CachedMessage; + + await saveMessage({ db, message: testMessage }); + + const { result } = renderHook(() => useCachedMessages(testTopic)); + + await waitFor(() => { + expect(result.current.length).toBe(1); + expect(result.current[0]).toEqual(testMessage); + }); + }); +}); diff --git a/packages/react-sdk/src/hooks/useCachedMessages.ts b/packages/react-sdk/src/hooks/useCachedMessages.ts index b7ef86e6..fb161bba 100644 --- a/packages/react-sdk/src/hooks/useCachedMessages.ts +++ b/packages/react-sdk/src/hooks/useCachedMessages.ts @@ -1,252 +1,29 @@ -import type { Client, Conversation, ListMessagesOptions } from "@xmtp/xmtp-js"; -import { DecodedMessage, SortDirection } from "@xmtp/xmtp-js"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { Dexie, type Collection, type IndexableType } from "dexie"; -import type { CachedMessage } from "../helpers/messagesDb"; -import messagesDb from "../helpers/messagesDb"; -import { getConversationId } from "../helpers/getConversationId"; -import { useClient } from "./useClient"; -import { updateLastEntry } from "../helpers/updateLastEntry"; -import { adjustDate } from "../helpers/adjustDate"; -import type { OnError } from "../sharedTypes"; - -type GetCachedMessagesOptions = { - /** - * Conversation ID - */ - cId: string; - /** - * XMTP client, needed for decryption - */ - client: Client; - /** - * Sort direction - */ - direction?: string; - /** - * Is this the initial fetch? - */ - initial?: boolean; - /** - * The position of the cursor - */ - lastEntry?: DecodedMessage; - /** - * Page size - */ - limit?: number; -}; - -// fetch messages from the cache and decode them -const getCachedMessages = async ({ - cId, - client, - direction, - initial, - lastEntry, - limit, -}: GetCachedMessagesOptions) => { - let messagesQuery: Collection; - - messagesQuery = messagesDb.messages - // order by conversation ID, then sent timestamp - .where("[cId+sent]") - // only fetch messages that match the current conversation ID - .between( - [ - cId, - initial - ? // initially, get all messages - Dexie.minKey - : lastEntry?.sent - ? // when paging through messages, only fetch messages sent after - // the current latest entry - adjustDate(lastEntry.sent, 1) - : 0, - ], - [cId, Dexie.maxKey], - ); - - // apply limit - if (limit) { - messagesQuery = messagesQuery.limit(limit); - } - - // apply sorting - if (direction === SortDirection.SORT_DIRECTION_DESCENDING) { - messagesQuery = messagesQuery.reverse(); - } - - const cachedMessages = await messagesQuery.toArray(); - - // decode cached messages - return Promise.all( - cachedMessages.map(async (m) => DecodedMessage.fromBytes(m.bytes, client)), - ); -}; - -export type UseCachedMessagesOptions = ListMessagesOptions & - OnError & { - /** - * Callback function to execute when new messages are fetched - */ - onMessages?: ( - messages: DecodedMessage[], - options: ListMessagesOptions, - ) => void; - }; +import { useLiveQuery } from "dexie-react-hooks"; +import { useDb } from "./useDb"; +import type { CachedMessagesTable } from "@/helpers/caching/messages"; +import { useClient } from "@/hooks/useClient"; /** - * This hook fetches a list of all messages within a conversation on mount, - * backed by a cache stored in IndexedDB. Like the `useMessages` hook, it also - * exposes loading and error states and whether or not there are more messages - * based on the options passed. + * This hook returns cached conversations from the local cache based on the + * current client's address + * + * It's intended to be used internally and is not exported from the SDK */ -export const useCachedMessages = ( - conversation?: Conversation, - options?: UseCachedMessagesOptions, -) => { - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const [hasMore, setHasMore] = useState(false); - const [messages, setMessages] = useState([]); +export const useCachedMessages = (topic: string) => { + const { db } = useDb(); const { client } = useClient(); - // internal references to start/end times for paging results - const startTimeRef = useRef(options?.startTime); - const endTimeRef = useRef(options?.endTime); - // internal reference to the last entry in the cache - const lastCacheEntryRef = useRef(); - - // destructure options for more granular dependency arrays - const { - checkAddresses, - direction, - limit, - onError, - onMessages, - startTime, - endTime, - } = options ?? {}; - - // reset start/end time refs when the options or conversation change - useEffect(() => { - startTimeRef.current = startTime; - endTimeRef.current = endTime; - }, [endTime, startTime, conversation]); - - const getMessages = useCallback( - async (initial?: boolean) => { - // client and conversation are required - if (!client || !conversation) { - return; - } - - const cId = getConversationId(conversation); - setError(null); - - try { - // get cached messages and decode them - const decodedMessages = await getCachedMessages({ - cId, - client, - direction, - initial, - lastEntry: lastCacheEntryRef.current, - limit, - }); - - setMessages(decodedMessages); - - updateLastEntry({ - direction, - endTimeRef, - startTimeRef, - lastEntry: decodedMessages[decodedMessages.length - 1], - lastEntryRef: lastCacheEntryRef, - }); - - // only fetch more messages from the network if necessary - // no limit specified, or - // number of cached messages is less than the limit - if (!(!limit || (limit && decodedMessages.length < limit))) { - return; - } - - let finalLimit = limit; - if (limit && decodedMessages.length < limit) { - finalLimit = limit - decodedMessages.length; - } - - const finalOptions = { - checkAddresses, - direction, - endTime: endTimeRef.current, - limit: finalLimit, - startTime: startTimeRef.current, - }; - - setIsLoading(true); - - const networkMessages = await conversation.messages(finalOptions); - - networkMessages.forEach((m) => { - void messagesDb.persistMessage(m); - }); - - if (networkMessages.length > 0) { - updateLastEntry({ - direction, - endTimeRef, - startTimeRef, - lastEntry: networkMessages[networkMessages.length - 1], - lastEntryRef: lastCacheEntryRef, - }); - } - - const newMessages: DecodedMessage[] = [ - ...decodedMessages, - ...networkMessages, - ]; - - setMessages(newMessages); - onMessages?.(newMessages, finalOptions); - - if (limit) { - setHasMore(newMessages.length > 0 && newMessages.length === limit); - } - } catch (e) { - setError(e); - onError?.(e); - // re-throw error for upstream consumption - throw e; - } finally { - setIsLoading(false); + return ( + useLiveQuery(async () => { + // client required for address + if (!client) { + return []; } - }, - [ - checkAddresses, - client, - conversation, - direction, - limit, - onError, - onMessages, - ], + return (db.table("messages") as CachedMessagesTable) + .where({ + conversationTopic: topic, + walletAddress: client.address, + }) + .sortBy("sentAt"); + }, [topic]) ?? [] ); - - // fetch the next set of messages - const next = useCallback(async () => getMessages(), [getMessages]); - - // fetch conversation messages on mount - useEffect(() => { - void getMessages(true); - }, [getMessages]); - - return { - error, - hasMore, - isLoading, - messages, - next, - }; }; diff --git a/packages/react-sdk/src/hooks/useCanMessage.test.ts b/packages/react-sdk/src/hooks/useCanMessage.test.ts new file mode 100644 index 00000000..672a2ca9 --- /dev/null +++ b/packages/react-sdk/src/hooks/useCanMessage.test.ts @@ -0,0 +1,180 @@ +import { it, expect, describe, vi, beforeEach } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import { Client } from "@xmtp/xmtp-js"; +import { useCanMessage } from "@/hooks/useCanMessage"; + +const useClientMock = vi.hoisted(() => vi.fn()); +const canMessageMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/hooks/useClient", () => ({ + useClient: useClientMock, +})); + +describe("useCanMessage", () => { + beforeEach(() => { + useClientMock.mockReset(); + canMessageMock.mockReset(); + }); + + describe("canMessage", () => { + it("should throw an error if no client is available", async () => { + useClientMock.mockImplementation(() => ({ + client: undefined, + })); + const onErrorMock = vi.fn(); + + const { result } = renderHook(() => useCanMessage(onErrorMock)); + await act(async () => { + await expect( + result.current.canMessage("testWalletAddress"), + ).rejects.toThrow( + "XMTP client is required to check if an address is on the network", + ); + }); + }); + + it("should check if an address is on the network", async () => { + useClientMock.mockImplementation(() => ({ + client: { + canMessage: canMessageMock, + }, + })); + + const { result } = renderHook(() => useCanMessage()); + + await act(async () => { + await result.current.canMessage("testAddress"); + }); + + expect(canMessageMock).toHaveBeenCalledOnce(); + expect(canMessageMock).toHaveBeenCalledWith("testAddress"); + expect(result.current.error).toBe(null); + expect(result.current.isLoading).toBe(false); + }); + + it("should check if multiple addresses are on the network", async () => { + useClientMock.mockImplementation(() => ({ + client: { + canMessage: canMessageMock, + }, + })); + + const { result } = renderHook(() => useCanMessage()); + + await act(async () => { + await result.current.canMessage(["testAddress1", "testAddress2"]); + }); + + expect(canMessageMock).toHaveBeenCalledOnce(); + expect(canMessageMock).toHaveBeenCalledWith([ + "testAddress1", + "testAddress2", + ]); + expect(result.current.error).toBe(null); + expect(result.current.isLoading).toBe(false); + }); + + it("should have an error when it rejects", async () => { + const testError = new Error("testError"); + canMessageMock.mockRejectedValueOnce(testError); + useClientMock.mockImplementation(() => ({ + client: { + canMessage: canMessageMock, + }, + })); + const onErrorMock = vi.fn(); + + const { result } = renderHook(() => useCanMessage(onErrorMock)); + + await act(async () => { + try { + await result.current.canMessage("testAddress"); + } catch (e) { + expect(e).toBe(testError); + } + }); + + expect(result.current.error).toBe(testError); + expect(onErrorMock).toHaveBeenCalledOnce(); + expect(onErrorMock).toHaveBeenCalledWith(testError); + expect(result.current.isLoading).toBe(false); + }); + }); + + describe("canMessageStatic", () => { + it("should check if an address is on the network", async () => { + useClientMock.mockImplementation(() => ({ + client: undefined, + })); + const canMessageStaticSpy = vi + .spyOn(Client, "canMessage") + .mockImplementation((peerAddress: string | string[]) => + typeof peerAddress === "string" + ? Promise.resolve([true]) + : Promise.resolve( + Array.from({ length: peerAddress.length }).fill( + true, + ) as boolean[], + ), + ); + + const { result } = renderHook(() => useCanMessage()); + + await act(async () => { + await result.current.canMessageStatic("testAddress"); + }); + + expect(canMessageStaticSpy).toHaveBeenCalledOnce(); + expect(canMessageStaticSpy).toHaveBeenCalledWith( + "testAddress", + undefined, + ); + expect(result.current.error).toBe(null); + expect(result.current.isLoading).toBe(false); + + await act(async () => { + await result.current.canMessageStatic(["testAddress1", "testAddress2"]); + }); + + expect(canMessageStaticSpy).toHaveBeenCalledTimes(2); + expect(canMessageStaticSpy).toHaveBeenLastCalledWith( + ["testAddress1", "testAddress2"], + undefined, + ); + }); + + it("should have an error when it rejects", async () => { + useClientMock.mockImplementation(() => ({ + client: undefined, + })); + + const testError = new Error("testError"); + + const canMessageStaticSpy = vi + .spyOn(Client, "canMessage") + .mockRejectedValueOnce(testError); + + const onErrorMock = vi.fn(); + + const { result } = renderHook(() => useCanMessage(onErrorMock)); + + await act(async () => { + try { + await result.current.canMessageStatic("testAddress"); + } catch (e) { + expect(e).toBe(testError); + } + }); + + expect(canMessageStaticSpy).toHaveBeenCalledOnce(); + expect(canMessageStaticSpy).toHaveBeenCalledWith( + "testAddress", + undefined, + ); + expect(result.current.error).toBe(testError); + expect(onErrorMock).toHaveBeenCalledOnce(); + expect(onErrorMock).toHaveBeenCalledWith(testError); + expect(result.current.isLoading).toBe(false); + }); + }); +}); diff --git a/packages/react-sdk/src/hooks/useCanMessage.ts b/packages/react-sdk/src/hooks/useCanMessage.ts index 89090c3d..bd0a6746 100644 --- a/packages/react-sdk/src/hooks/useCanMessage.ts +++ b/packages/react-sdk/src/hooks/useCanMessage.ts @@ -1,7 +1,7 @@ -import { useCallback, useContext, useState } from "react"; +import { useCallback, useState } from "react"; import { Client } from "@xmtp/xmtp-js"; -import { XMTPContext } from "../contexts/XMTPContext"; import type { CanMessageReturns, OnError } from "../sharedTypes"; +import { useClient } from "@/hooks/useClient"; /** * This hook exposes both the client and static instances of the `canMessage` @@ -10,24 +10,33 @@ import type { CanMessageReturns, OnError } from "../sharedTypes"; export const useCanMessage = (onError?: OnError["onError"]) => { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); - const xmtpContext = useContext(XMTPContext); - - if (xmtpContext === undefined) { - console.error("This hook must be used within the context of XMTPProvider"); - } - - const { canMessage: cm } = xmtpContext; + const { client } = useClient(); /** - * Check if a wallet address is on the XMTP network using the client instance + * Check if one or more wallet addresses is on the XMTP network using the */ - const canMessage = useCallback( - async (peerAddress) => { + const canMessage = useCallback( + async ( + peerAddress: T, + ): Promise> => { + if (!client) { + throw new Error( + "XMTP client is required to check if an address is on the network", + ); + } + setIsLoading(false); setError(null); try { - return await cm(peerAddress); + // this weirdness is required to get proper typing + return typeof peerAddress === "string" + ? await (client.canMessage(peerAddress) as Promise< + CanMessageReturns + >) + : await (client.canMessage(peerAddress) as Promise< + CanMessageReturns + >); } catch (e) { setError(e); onError?.(e); @@ -37,7 +46,7 @@ export const useCanMessage = (onError?: OnError["onError"]) => { setIsLoading(false); } }, - [cm, onError], + [client, onError], ); /** diff --git a/packages/react-sdk/src/hooks/useClient.test.tsx b/packages/react-sdk/src/hooks/useClient.test.tsx new file mode 100644 index 00000000..1c513291 --- /dev/null +++ b/packages/react-sdk/src/hooks/useClient.test.tsx @@ -0,0 +1,156 @@ +import { it, expect, describe, vi, beforeEach } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { Client } from "@xmtp/xmtp-js"; +import { Wallet } from "ethers"; +import type { PropsWithChildren } from "react"; +import { useClient } from "@/hooks/useClient"; +import { XMTPProvider } from "@/contexts/XMTPContext"; + +const processUnprocessedMessagesMock = vi.hoisted(() => vi.fn()); + +const TestWrapper: React.FC = ({ + children, + client, +}) => {children}; + +vi.mock("@/helpers/caching/messages", async () => { + const actual = await import("@/helpers/caching/messages"); + return { + ...actual, + processUnprocessedMessages: processUnprocessedMessagesMock, + }; +}); + +describe("useClient", () => { + beforeEach(() => { + processUnprocessedMessagesMock.mockReset(); + }); + + it("should disconnect an active client", async () => { + const disconnectClientMock = vi.fn(); + const mockClient = { + close: disconnectClientMock, + }; + const { result } = renderHook(() => useClient(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current.client).toBeDefined(); + + await act(async () => { + await result.current.disconnect(); + }); + + expect(disconnectClientMock).toHaveBeenCalledTimes(1); + expect(result.current.client).toBeUndefined(); + }); + + it("should not initialize a client if one is already active", async () => { + const mockClient = { + address: "testWalletAddress", + }; + const clientCreateSpy = vi.spyOn(Client, "create"); + const testWallet = Wallet.createRandom(); + + const { result } = renderHook(() => useClient(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + await act(async () => { + await result.current.initialize({ signer: testWallet }); + }); + + expect(clientCreateSpy).not.toHaveBeenCalled(); + + await waitFor(() => { + expect(processUnprocessedMessagesMock).toBeCalledTimes(1); + }); + }); + + it("should initialize a client if one is not active", async () => { + const testWallet = Wallet.createRandom(); + const mockClient = { + address: "testWalletAddress", + } as unknown as Client; + const clientCreateSpy = vi + .spyOn(Client, "create") + .mockResolvedValue(mockClient); + + const { result } = renderHook(() => useClient(), { + wrapper: ({ children }) => {children}, + }); + + await act(async () => { + await result.current.initialize({ signer: testWallet }); + }); + + expect(clientCreateSpy).toHaveBeenCalledWith(testWallet, { + codecs: [], + privateKeyOverride: undefined, + }); + expect(result.current.client).toBe(mockClient); + expect(result.current.signer).toBe(testWallet); + + await waitFor(() => { + expect(processUnprocessedMessagesMock).toHaveBeenCalledTimes(1); + }); + }); + + it("should throw an error if client initialization fails", async () => { + const testWallet = Wallet.createRandom(); + const testError = new Error("testError"); + vi.spyOn(Client, "create").mockRejectedValue(testError); + const onErrorMock = vi.fn(); + + const { result } = renderHook(() => useClient(onErrorMock)); + + await act(async () => { + await expect( + result.current.initialize({ signer: testWallet }), + ).rejects.toThrow(testError); + }); + + expect(onErrorMock).toBeCalledTimes(1); + expect(onErrorMock).toHaveBeenCalledWith(testError); + expect(result.current.client).toBeUndefined(); + expect(result.current.signer).toBeUndefined(); + expect(result.current.error).toEqual(testError); + }); + + it("should should call the onError callback if processing unprocessed messages fails", async () => { + const testWallet = Wallet.createRandom(); + const testError = new Error("testError"); + const mockClient = { + address: "testWalletAddress", + } as unknown as Client; + const onErrorMock = vi.fn(); + vi.spyOn(Client, "create").mockResolvedValue(mockClient); + processUnprocessedMessagesMock.mockRejectedValue(testError); + + const { result } = renderHook(() => useClient(onErrorMock), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + await act(async () => { + await result.current.initialize({ signer: testWallet }); + }); + + await waitFor(() => { + expect(onErrorMock).toHaveBeenCalledTimes(1); + expect(onErrorMock).toHaveBeenCalledWith(testError); + expect(result.current.error).toBe(null); + }); + }); +}); diff --git a/packages/react-sdk/src/hooks/useClient.ts b/packages/react-sdk/src/hooks/useClient.ts index ec44f96e..279eef0e 100644 --- a/packages/react-sdk/src/hooks/useClient.ts +++ b/packages/react-sdk/src/hooks/useClient.ts @@ -1,22 +1,147 @@ -import { useContext } from "react"; +import { useCallback, useContext, useEffect, useRef, useState } from "react"; +import type { ClientOptions, Signer } from "@xmtp/xmtp-js"; +import { Client } from "@xmtp/xmtp-js"; import { XMTPContext } from "../contexts/XMTPContext"; +import type { OnError } from "@/sharedTypes"; +import { processUnprocessedMessages } from "@/helpers/caching/messages"; + +export type InitClientArgs = { + /** + * Provide a XMTP PrivateKeyBundle encoded as a Uint8Array for signing + * + * This is required if `signer` is not specified + */ + keys?: Uint8Array; + /** + * XMTP client options + */ + options?: Partial; + /** + * The signer (wallet) to associate with the XMTP client + */ + signer?: Signer | null; +}; /** * This hook allows you to initialize, disconnect, and access the XMTP client * instance. It also exposes the error and loading states of the client. */ -export const useClient = () => { - const xmtpContext = useContext(XMTPContext); - if (xmtpContext === undefined) { - console.error("This hook must be used within the context of XMTPProvider"); - } +export const useClient = (onError?: OnError["onError"]) => { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + // client is initializing + const initializingRef = useRef(false); + // unprocessed messages are being processed + const processingRef = useRef(false); + // unprocessed messages have been processed + const processedRef = useRef(false); + + const { + client, + setClient, + setClientSigner, + signer: clientSigner, + codecs, + db, + processors, + namespaces, + } = useContext(XMTPContext); + + /** + * Initialize an XMTP client + */ + const initialize = useCallback( + async ({ keys, options, signer }: InitClientArgs) => { + // 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(keys ? null : signer, { + ...options, + codecs: [...(options?.codecs ?? []), ...codecs], + privateKeyOverride: keys, + }); + setClient(xmtpClient); + setClientSigner(signer); + } catch (e) { + setClient(undefined); + setClientSigner(undefined); + setError(e); + onError?.(e); + // re-throw error for upstream consumption + throw e; + } + + setIsLoading(false); + initializingRef.current = false; + + return xmtpClient; + } + return client; + }, + [client, codecs, onError, setClient, setClientSigner], + ); + + /** + * Disconnect the XMTP client + */ + const disconnect = useCallback(async () => { + if (client) { + await client.close(); + setClient(undefined); + setClientSigner(undefined); + } + }, [client, setClient, setClientSigner]); + + /** + * Process unprocessed messages when there's an available client, but only + * do it once + */ + useEffect(() => { + if (client && !processingRef.current && !processedRef.current) { + processingRef.current = true; + setIsLoading(true); + const reprocess = async () => { + try { + await processUnprocessedMessages({ + client, + db, + processors, + namespaces, + }); + processedRef.current = true; + } catch (e) { + onError?.(e); + } finally { + processingRef.current = false; + setIsLoading(false); + } + }; + void reprocess(); + } + }, [client, db, namespaces, onError, processors]); return { - client: xmtpContext.client, - disconnect: xmtpContext.closeClient, - error: xmtpContext.error, - initialize: xmtpContext.initClient, - isLoading: xmtpContext.isLoading, - signer: xmtpContext.signer, + client, + disconnect, + error, + initialize, + isLoading, + signer: clientSigner, }; }; diff --git a/packages/react-sdk/src/hooks/useConversation.ts b/packages/react-sdk/src/hooks/useConversation.ts new file mode 100644 index 00000000..4082b11b --- /dev/null +++ b/packages/react-sdk/src/hooks/useConversation.ts @@ -0,0 +1,91 @@ +import { useCallback } from "react"; +import { getLastMessage as _getLastMessage } from "@/helpers/caching/messages"; +import type { CachedConversation } from "@/helpers/caching/conversations"; +import { + getCachedConversationByTopic, + getConversationByTopic, + hasTopic as _hasTopic, + saveConversation as _saveConversation, + updateConversation as _updateConversation, + updateConversationMetadata, + getCachedConversationByPeerAddress, +} from "@/helpers/caching/conversations"; +import type { RemoveLastParameter } from "@/sharedTypes"; +import { useClient } from "@/hooks/useClient"; +import { useDb } from "@/hooks/useDb"; + +/** + * This hook returns helper functions for working with conversations in the + * local cache. + */ +export const useConversation = () => { + const { client } = useClient(); + const { db } = useDb(); + + const saveConversation = useCallback( + (conversation: CachedConversation) => + client ? _saveConversation(conversation, db) : undefined, + [client, db], + ); + + const updateConversation = useCallback< + RemoveLastParameter + >( + async (conversation, update) => { + await _updateConversation(conversation, update, db); + }, + [db], + ); + + const updateMetadata = useCallback< + RemoveLastParameter + >( + async (conversation, namespace, data) => { + await updateConversationMetadata(conversation, namespace, data, db); + }, + [db], + ); + + const getByTopic = useCallback< + RemoveLastParameter + >( + async (topic) => { + if (client) { + return getConversationByTopic(topic, client); + } + return undefined; + }, + [client], + ); + + const getCachedByTopic = useCallback< + RemoveLastParameter + >(async (topic) => getCachedConversationByTopic(topic, db), [db]); + + const getCachedByPeerAddress = useCallback< + RemoveLastParameter + >( + async (peerAddress) => getCachedConversationByPeerAddress(peerAddress, db), + [db], + ); + + const getLastMessage = useCallback< + RemoveLastParameter + >(async (topic) => _getLastMessage(topic, db), [db]); + + const hasTopic = useCallback>( + async (topic) => _hasTopic(topic, db), + [db], + ); + + return { + getByTopic, + getCachedByTopic, + getCachedByPeerAddress, + getLastMessage, + hasTopic, + saveConversation, + updateConversation, + updateMetadata, + }; +}; diff --git a/packages/react-sdk/src/hooks/useConversations.test.ts b/packages/react-sdk/src/hooks/useConversations.test.ts new file mode 100644 index 00000000..4071e857 --- /dev/null +++ b/packages/react-sdk/src/hooks/useConversations.test.ts @@ -0,0 +1,43 @@ +import { it, expect, describe, beforeEach, vi } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { getDbInstance, clearCache } from "@/helpers/caching/db"; +import { useConversations } from "@/hooks/useConversations"; + +const useClientMock = vi.hoisted(() => vi.fn()); +const db = getDbInstance(); + +vi.mock("@/hooks/useDb", () => ({ + useDb: () => ({ + db, + }), +})); + +vi.mock("@/hooks/useClient", () => ({ + useClient: useClientMock, +})); + +describe("useConversations", () => { + beforeEach(async () => { + await clearCache(db); + useClientMock.mockReset(); + }); + + it("should have an error when the client is undefined", async () => { + useClientMock.mockImplementation(() => ({ + client: undefined, + })); + const onErrorMock = vi.fn(); + + const { result } = renderHook(() => + useConversations({ onError: onErrorMock }), + ); + + await waitFor(() => { + expect(onErrorMock).toHaveBeenCalledTimes(1); + expect(result.current.error).toEqual( + new Error("XMTP client is required to fetch conversations"), + ); + expect(result.current.conversations).toEqual([]); + }); + }); +}); diff --git a/packages/react-sdk/src/hooks/useConversations.ts b/packages/react-sdk/src/hooks/useConversations.ts index 96efdd34..4d8eb3da 100644 --- a/packages/react-sdk/src/hooks/useConversations.ts +++ b/packages/react-sdk/src/hooks/useConversations.ts @@ -1,7 +1,12 @@ -import type { Conversation } from "@xmtp/xmtp-js"; -import { useEffect, useState } from "react"; +import { SortDirection, type Conversation } from "@xmtp/xmtp-js"; +import { useEffect, useRef, useState } from "react"; import { useClient } from "./useClient"; import type { OnError } from "../sharedTypes"; +import { useCachedConversations } from "./useCachedConversations"; +import { toCachedMessage } from "@/helpers/caching/messages"; +import { useConversation } from "@/hooks/useConversation"; +import { useMessage } from "@/hooks/useMessage"; +import { toCachedConversation } from "@/helpers/caching/conversations"; export type UseConversationsOptions = OnError & { /** @@ -17,17 +22,23 @@ export type UseConversationsOptions = OnError & { export const useConversations = (options?: UseConversationsOptions) => { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); - const [conversations, setConversations] = useState([]); const { client } = useClient(); + const { processMessage } = useMessage(); + const { saveConversation, hasTopic } = useConversation(); + const conversations = useCachedConversations(); + // to prevent conversations from being fetched multiple times + const loadingRef = useRef(false); // destructure options for more granular dependency arrays const { onConversations, onError } = options ?? {}; // attempt to fetch conversations on mount useEffect(() => { - // we can't do anything without a client - if (client === undefined) { - const clientError = new Error("XMTP client is not available"); + // client is required + if (!client) { + const clientError = new Error( + "XMTP client is required to fetch conversations", + ); setError(clientError); onError?.(clientError); // do not throw the error in this case @@ -35,12 +46,43 @@ export const useConversations = (options?: UseConversationsOptions) => { } const getConversations = async () => { + // already in progress + if (loadingRef.current) { + return; + } + + loadingRef.current = true; + setIsLoading(true); setError(null); try { - const conversationList = (await client?.conversations.list()) ?? []; - setConversations(conversationList); + const conversationList = (await client.conversations.list()) ?? []; + await Promise.all( + conversationList.map(async (conversation) => { + // only save the conversation and fetch its latest message if it + // doesn't already exist + if (!(await hasTopic(conversation.topic))) { + const cachedConversation = await saveConversation( + toCachedConversation(conversation, client.address), + ); + + // fetch the latest message for each conversation + const latestMessages = await conversation.messages({ + direction: SortDirection.SORT_DIRECTION_DESCENDING, + limit: 1, + }); + + if (latestMessages.length > 0 && cachedConversation) { + const latestMessage = latestMessages[0]; + await processMessage( + cachedConversation, + toCachedMessage(latestMessage, client.address), + ); + } + } + }), + ); onConversations?.(conversationList); } catch (e) { setError(e); @@ -49,11 +91,19 @@ export const useConversations = (options?: UseConversationsOptions) => { throw e; } finally { setIsLoading(false); + loadingRef.current = false; } }; void getConversations(); - }, [onConversations, onError, client]); + }, [ + onConversations, + onError, + client, + saveConversation, + processMessage, + hasTopic, + ]); return { conversations, diff --git a/packages/react-sdk/src/hooks/useDb.ts b/packages/react-sdk/src/hooks/useDb.ts new file mode 100644 index 00000000..402c070d --- /dev/null +++ b/packages/react-sdk/src/hooks/useDb.ts @@ -0,0 +1,22 @@ +import { useCallback, useContext } from "react"; +import { XMTPContext } from "@/contexts/XMTPContext"; +import { clearCache as _clearCache } from "@/helpers/caching/db"; + +/** + * This hook returns the local DB instance and a method for clearing all of + * its data + */ +export const useDb = () => { + const xmtpContext = useContext(XMTPContext); + const { db } = xmtpContext; + + /** + * Clear all data in the local cache + */ + const clearCache = useCallback(async () => { + // clear all data + await _clearCache(db); + }, [db]); + + return { clearCache, db }; +}; diff --git a/packages/react-sdk/src/hooks/useLastMessage.test.ts b/packages/react-sdk/src/hooks/useLastMessage.test.ts new file mode 100644 index 00000000..a5ad9470 --- /dev/null +++ b/packages/react-sdk/src/hooks/useLastMessage.test.ts @@ -0,0 +1,67 @@ +import { it, expect, describe, beforeEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { ContentTypeText } from "@xmtp/xmtp-js"; +import { getDbInstance, clearCache } from "@/helpers/caching/db"; +import type { CachedMessage } from "@/helpers/caching/messages"; +import { saveMessage } from "@/helpers/caching/messages"; + +const db = getDbInstance(); +const testWalletAddress = "testAddress"; +const testTopic = "testTopic"; + +vi.mock("./useDb", () => ({ + useDb: () => ({ + db, + }), +})); + +vi.mock("./useClient", () => ({ + useClient: () => ({ + client: { + address: testWalletAddress, + }, + }), +})); + +// this import must be after the mocks +// eslint-disable-next-line import/first +import { useLastMessage } from "./useLastMessage"; + +describe("useLastMessage", () => { + beforeEach(async () => { + await clearCache(db); + }); + + it("should return no messages when the cache is empty", async () => { + const { result } = renderHook(() => useLastMessage(testTopic)); + + await waitFor(() => { + expect(result.current).toBeUndefined(); + }); + }); + + it("should return a message when one is in the cache", async () => { + const testMessage = { + id: 1, + walletAddress: testWalletAddress, + conversationTopic: testTopic, + content: "test", + contentType: ContentTypeText.toString(), + isSending: false, + hasSendError: false, + sentAt: new Date(), + status: "processed", + senderAddress: testWalletAddress, + uuid: "testUuid", + xmtpID: "testXmtpId", + } satisfies CachedMessage; + + await saveMessage({ db, message: testMessage }); + + const { result } = renderHook(() => useLastMessage(testTopic)); + + await waitFor(() => { + expect(result.current).toEqual(testMessage); + }); + }); +}); diff --git a/packages/react-sdk/src/hooks/useLastMessage.ts b/packages/react-sdk/src/hooks/useLastMessage.ts new file mode 100644 index 00000000..2d41fa71 --- /dev/null +++ b/packages/react-sdk/src/hooks/useLastMessage.ts @@ -0,0 +1,21 @@ +import { useLiveQuery } from "dexie-react-hooks"; +import { useDb } from "./useDb"; +import type { CachedMessagesTable } from "@/helpers/caching/messages"; + +/** + * This hook returns the last message from a conversation from the local cache + */ +export const useLastMessage = (topic: string) => { + const { db } = useDb(); + + return useLiveQuery(async () => { + const messagesTable = db.table("messages") as CachedMessagesTable; + const messages = await messagesTable + .where({ + conversationTopic: topic, + }) + .reverse() + .sortBy("sentAt"); + return messages.filter((message) => message.content !== undefined)[0]; + }, [topic]); +}; diff --git a/packages/react-sdk/src/hooks/useMessage.test.ts b/packages/react-sdk/src/hooks/useMessage.test.ts new file mode 100644 index 00000000..3ca3a3c8 --- /dev/null +++ b/packages/react-sdk/src/hooks/useMessage.test.ts @@ -0,0 +1,420 @@ +import { it, expect, describe, vi, beforeEach } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import { ContentTypeText } from "@xmtp/xmtp-js"; +import { createContext } from "react"; +import { useMessage } from "@/hooks/useMessage"; +import { clearCache, getDbInstance } from "@/helpers/caching/db"; +import type { CachedConversation } from "@/helpers/caching/conversations"; +import type { CachedMessage } from "@/helpers/caching/messages"; + +const useClientMock = vi.hoisted(() => vi.fn()); +const prepareMessageForSendingMock = vi.hoisted(() => vi.fn()); +const processMessageMock = vi.hoisted(() => vi.fn()); +const updateMessageMock = vi.hoisted(() => vi.fn()); +const updateMessageAfterSendingMock = vi.hoisted(() => vi.fn()); +const getConversationByTopicMock = vi.hoisted(() => vi.fn()); +const db = getDbInstance(); +const testWalletAddress = "testAddress"; +const testPeerAddress = "testPeerAddress"; + +vi.mock("@/contexts/XMTPContext", () => ({ + XMTPContext: createContext({ + namespaces: "namespaces", + processors: "processors", + }), +})); + +vi.mock("@/hooks/useDb", () => ({ + useDb: () => ({ + db, + }), +})); + +vi.mock("@/hooks/useClient", () => ({ + useClient: useClientMock, +})); + +vi.mock("@/helpers/caching/messages", async () => { + const actual = await import("@/helpers/caching/messages"); + + return { + ...actual, + prepareMessageForSending: prepareMessageForSendingMock, + processMessage: processMessageMock, + updateMessage: updateMessageMock, + updateMessageAfterSending: updateMessageAfterSendingMock, + }; +}); + +vi.mock("@/helpers/caching/conversations", async () => { + const actual = await import("@/helpers/caching/conversations"); + + return { + ...actual, + getConversationByTopic: getConversationByTopicMock, + }; +}); + +describe("useMessage", () => { + beforeEach(async () => { + await clearCache(db); + useClientMock.mockReset(); + prepareMessageForSendingMock.mockReset(); + processMessageMock.mockReset(); + updateMessageMock.mockReset(); + updateMessageAfterSendingMock.mockReset(); + getConversationByTopicMock.mockReset(); + }); + + describe("sendMessage", () => { + it("should throw an error if no client is available", async () => { + useClientMock.mockImplementation(() => ({ + client: undefined, + })); + + const { result } = renderHook(() => useMessage()); + + await act(async () => { + await expect( + result.current.sendMessage( + { + id: 1, + createdAt: new Date(), + updatedAt: new Date(), + isReady: false, + walletAddress: testWalletAddress, + topic: "testTopic", + peerAddress: testPeerAddress, + } satisfies CachedConversation, + "testMessage", + ContentTypeText, + ), + ).rejects.toThrow("XMTP client is required to send a message"); + }); + }); + + it("should throw an error if sending failed", async () => { + useClientMock.mockImplementation(() => ({ + client: { + address: testWalletAddress, + }, + })); + const testError = new Error("testError"); + const sendMock = vi.fn().mockRejectedValueOnce(testError); + const onErrorMock = vi.fn(); + const onSuccessMock = vi.fn(); + prepareMessageForSendingMock.mockImplementationOnce( + () => "preparedMessage", + ); + processMessageMock.mockImplementationOnce(() => ({ + id: 1, + })); + getConversationByTopicMock.mockImplementationOnce(() => ({ + send: sendMock, + })); + const testConversation = { + id: 1, + createdAt: new Date(), + updatedAt: new Date(), + isReady: false, + walletAddress: testWalletAddress, + topic: "testTopic", + peerAddress: testPeerAddress, + } satisfies CachedConversation; + + const { result } = renderHook(() => useMessage()); + + await act(async () => { + await expect( + result.current.sendMessage( + testConversation, + "testMessage", + ContentTypeText, + { + onSuccess: onSuccessMock, + onError: onErrorMock, + }, + ), + ).rejects.toThrow("testError"); + }); + + expect(onSuccessMock).not.toHaveBeenCalled(); + expect(onErrorMock).toHaveBeenCalledWith(testError); + expect(updateMessageMock).toHaveBeenCalledWith( + { id: 1 }, + { + hasSendError: true, + sendOptions: { + contentType: ContentTypeText, + }, + }, + db, + ); + }); + + it("should throw an error if a conversation is not found", async () => { + useClientMock.mockImplementation(() => ({ + client: { + address: testWalletAddress, + }, + })); + const onErrorMock = vi.fn(); + const onSuccessMock = vi.fn(); + prepareMessageForSendingMock.mockImplementationOnce( + () => "preparedMessage", + ); + processMessageMock.mockImplementationOnce(() => ({ + id: 1, + })); + getConversationByTopicMock.mockImplementationOnce(() => undefined); + const testConversation = { + id: 1, + createdAt: new Date(), + updatedAt: new Date(), + isReady: false, + walletAddress: testWalletAddress, + topic: "testTopic", + peerAddress: testPeerAddress, + } satisfies CachedConversation; + + const { result } = renderHook(() => useMessage()); + + await act(async () => { + await expect( + result.current.sendMessage( + testConversation, + "testMessage", + ContentTypeText, + { + onSuccess: onSuccessMock, + onError: onErrorMock, + }, + ), + ).rejects.toThrow( + "Conversation not found in XMTP client, unable to send message", + ); + }); + + expect(onSuccessMock).not.toHaveBeenCalled(); + expect(onErrorMock).toHaveBeenCalledWith( + new Error( + "Conversation not found in XMTP client, unable to send message", + ), + ); + expect(updateMessageMock).not.toHaveBeenCalled(); + }); + + it("should send a message", async () => { + useClientMock.mockImplementation(() => ({ + client: { + address: testWalletAddress, + }, + })); + const sentAt = new Date(); + const sendMock = vi.fn().mockImplementation(() => ({ + id: 1, + sent: sentAt, + })); + const onSuccessMock = vi.fn(); + prepareMessageForSendingMock.mockImplementationOnce( + () => "preparedMessage", + ); + processMessageMock.mockImplementationOnce(() => ({ + id: 1, + })); + getConversationByTopicMock.mockImplementationOnce(() => ({ + send: sendMock, + })); + const testConversation = { + id: 1, + createdAt: new Date(), + updatedAt: new Date(), + isReady: false, + walletAddress: testWalletAddress, + topic: "testTopic", + peerAddress: testPeerAddress, + } satisfies CachedConversation; + + const { result } = renderHook(() => useMessage()); + + await act(async () => { + const { cachedMessage, sentMessage } = await result.current.sendMessage( + testConversation, + "testMessage", + ContentTypeText, + { + onSuccess: onSuccessMock, + }, + ); + expect(cachedMessage).toEqual({ id: 1 }); + expect(sentMessage).toEqual({ + id: 1, + sent: sentAt, + }); + }); + + expect(prepareMessageForSendingMock).toHaveBeenCalledWith({ + client: { + address: testWalletAddress, + }, + content: "testMessage", + contentType: ContentTypeText.toString(), + conversation: testConversation, + }); + + expect(processMessageMock).toHaveBeenCalledWith({ + client: { + address: testWalletAddress, + }, + conversation: testConversation, + db, + message: "preparedMessage", + namespaces: "namespaces", + processors: "processors", + }); + + expect(getConversationByTopicMock).toHaveBeenCalledWith("testTopic", { + address: testWalletAddress, + }); + + expect(sendMock).toHaveBeenCalledWith("testMessage", { + contentType: { + authorityId: "xmtp.org", + typeId: "text", + versionMajor: 1, + versionMinor: 0, + }, + }); + expect(onSuccessMock).toHaveBeenCalledWith({ + id: 1, + sent: sentAt, + }); + expect(updateMessageAfterSendingMock).toHaveBeenCalledWith( + { id: 1 }, + sentAt, + 1, + db, + ); + }); + }); + + describe("resendMessage", () => { + it("should throw an error if no client is available", async () => { + useClientMock.mockImplementation(() => ({ + client: undefined, + })); + + const { result } = renderHook(() => useMessage()); + + await act(async () => { + await expect( + result.current.resendMessage({ + id: 1, + content: "test", + contentType: ContentTypeText.toString(), + hasSendError: true, + conversationTopic: "testTopic", + isSending: false, + senderAddress: testWalletAddress, + sentAt: new Date(), + status: "processed", + uuid: "testUuid", + walletAddress: testWalletAddress, + xmtpID: "testXmtpId", + } satisfies CachedMessage), + ).rejects.toThrow("XMTP client is required to send a message"); + }); + }); + + it("should throw an error if a conversation is not found", async () => { + useClientMock.mockImplementation(() => ({ + client: { + address: testWalletAddress, + }, + })); + getConversationByTopicMock.mockImplementationOnce(() => undefined); + + const { result } = renderHook(() => useMessage()); + + await act(async () => { + await expect( + result.current.resendMessage({ + id: 1, + content: "test", + contentType: ContentTypeText.toString(), + hasSendError: true, + conversationTopic: "testTopic", + isSending: false, + senderAddress: testWalletAddress, + sentAt: new Date(), + status: "processed", + uuid: "testUuid", + walletAddress: testWalletAddress, + xmtpID: "testXmtpId", + } satisfies CachedMessage), + ).rejects.toThrow( + "Conversation not found in XMTP client, unable to send message", + ); + }); + }); + + it("should resend a message", async () => { + useClientMock.mockImplementation(() => ({ + client: { + address: testWalletAddress, + }, + })); + const sentAt = new Date(); + const sendMock = vi.fn().mockImplementation(() => ({ + id: 1, + sent: sentAt, + })); + getConversationByTopicMock.mockImplementationOnce(() => ({ + send: sendMock, + })); + + const { result } = renderHook(() => useMessage()); + + const cachedMessage = { + id: 1, + content: "test", + contentType: ContentTypeText.toString(), + hasSendError: true, + conversationTopic: "testTopic", + isSending: false, + senderAddress: testWalletAddress, + sentAt: new Date(), + status: "processed", + uuid: "testUuid", + walletAddress: testWalletAddress, + xmtpID: "testXmtpId", + sendOptions: { + contentType: ContentTypeText, + }, + } satisfies CachedMessage; + + await act(async () => { + const sentMessage = await result.current.resendMessage(cachedMessage); + expect(sentMessage).toEqual({ + id: 1, + sent: sentAt, + }); + }); + + expect(sendMock).toHaveBeenCalledWith("test", { + contentType: { + authorityId: "xmtp.org", + typeId: "text", + versionMajor: 1, + versionMinor: 0, + }, + }); + expect(updateMessageAfterSendingMock).toHaveBeenCalledWith( + cachedMessage, + sentAt, + 1, + db, + ); + }); + }); +}); diff --git a/packages/react-sdk/src/hooks/useMessage.ts b/packages/react-sdk/src/hooks/useMessage.ts new file mode 100644 index 00000000..3775b90b --- /dev/null +++ b/packages/react-sdk/src/hooks/useMessage.ts @@ -0,0 +1,208 @@ +import { useCallback, useContext } from "react"; +import { ContentTypeText } from "@xmtp/xmtp-js"; +import type { DecodedMessage, SendOptions, ContentTypeId } from "@xmtp/xmtp-js"; +import { XMTPContext } from "@/contexts/XMTPContext"; +import { + updateMessage as _updateMessage, + processMessage as _processMessage, + updateMessageAfterSending as _updateMessageAfterSending, + prepareMessageForSending, + getMessageByXmtpID as _getMessageByXmtpID, +} from "@/helpers/caching/messages"; +import type { CachedMessage } from "@/helpers/caching/messages"; +import { getConversationByTopic } from "@/helpers/caching/conversations"; +import type { CachedConversation } from "@/helpers/caching/conversations"; +import type { RemoveLastParameter } from "@/sharedTypes"; +import type { UseSendMessageOptions } from "@/hooks/useSendMessage"; +import { useClient } from "@/hooks/useClient"; +import { useDb } from "@/hooks/useDb"; + +type ProcessMessageCallback = ( + conversation: CachedConversation, + message: CachedMessage, +) => Promise; + +export type SendMessageOptions = Omit & + Pick; + +/** + * This hook returns + */ +export const useMessage = () => { + const xmtpContext = useContext(XMTPContext); + const { processors, namespaces } = xmtpContext; + const { client } = useClient(); + const { db } = useDb(); + + const processMessage = useCallback( + async (conversation, message) => { + if (client) { + return _processMessage({ + client, + conversation, + db, + message, + namespaces, + processors, + }); + } + return message; + }, + [client, db, namespaces, processors], + ); + + const updateMessage = useCallback>( + async (message, update) => { + await _updateMessage(message, update, db); + }, + [db], + ); + + const updateMessageAfterSending = useCallback< + RemoveLastParameter + >( + async (message, sentAt, xmtpID) => + _updateMessageAfterSending(message, sentAt, xmtpID, db), + [db], + ); + + const getMessageByXmtpID = useCallback< + RemoveLastParameter + >(async (xmtpID) => _getMessageByXmtpID(xmtpID, db), [db]); + + /** + * Send a message to a conversation on the XMTP network + * + * @param conversation - The conversation to send the message to + * @param content - The content of the message + * @param contentType - The content type of the message + * @param options - Additional options for sending the message + * @returns The sent message and the cached message + */ + const sendMessage = useCallback( + async ( + conversation: CachedConversation, + content: any, + contentType?: ContentTypeId, + options?: SendMessageOptions, + ) => { + if (!client) { + throw new Error("XMTP client is required to send a message"); + } + + const { onSuccess, onError, ...sendOptions } = options ?? {}; + + const finalSendOptions = { + ...sendOptions, + contentType: contentType ?? ContentTypeText, + }; + + const preparedMessage = prepareMessageForSending({ + client, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + content, + contentType: finalSendOptions.contentType.toString(), + conversation, + }); + + const cachedMessage = await processMessage(conversation, preparedMessage); + + const networkConversation = await getConversationByTopic( + conversation.topic, + client, + ); + + if (!networkConversation) { + const noConversationError = new Error( + "Conversation not found in XMTP client, unable to send message", + ); + onError?.(noConversationError); + throw noConversationError; + } + + let sentMessage: DecodedMessage | undefined; + + try { + sentMessage = await networkConversation.send(content, finalSendOptions); + } catch (e) { + await updateMessage(cachedMessage, { + hasSendError: true, + sendOptions: finalSendOptions, + }); + onError?.(e); + // re-throw error for upstream consumption + throw e; + } + + if (sentMessage) { + onSuccess?.(sentMessage); + + // before updating, make sure the message was added to cache + if (cachedMessage.id) { + await updateMessageAfterSending( + cachedMessage, + sentMessage.sent, + sentMessage.id, + ); + } + } + + return { + cachedMessage, + sentMessage, + }; + }, + [client, processMessage, updateMessage, updateMessageAfterSending], + ); + + /** + * Resend a message that's already in the cache, which is likely due to + * a previous send error + * + * @param message - The cached message to resend + * @returns The sent message, or `undefined` if there's no XMTP client + */ + const resendMessage = useCallback( + async (message: CachedMessage) => { + if (!client) { + throw new Error("XMTP client is required to send a message"); + } + + // find the conversation in the client + const networkConversation = await getConversationByTopic( + message.conversationTopic, + client, + ); + + // can't send a message without a conversation + if (!networkConversation) { + const noConversationError = new Error( + "Conversation not found in XMTP client, unable to send message", + ); + throw noConversationError; + } + + const sentMessage = await networkConversation.send( + message.content, + message.sendOptions, + ); + + // update cached message sentAt and xmtpID properties + await updateMessageAfterSending( + message, + sentMessage.sent, + sentMessage.id, + ); + + return sentMessage; + }, + [client, updateMessageAfterSending], + ); + + return { + getMessageByXmtpID, + processMessage, + resendMessage, + sendMessage, + }; +}; diff --git a/packages/react-sdk/src/hooks/useMessages.ts b/packages/react-sdk/src/hooks/useMessages.ts index 3d024496..c633e98b 100644 --- a/packages/react-sdk/src/hooks/useMessages.ts +++ b/packages/react-sdk/src/hooks/useMessages.ts @@ -1,22 +1,21 @@ -import type { - Conversation, - DecodedMessage, - ListMessagesOptions, -} from "@xmtp/xmtp-js"; +import { SortDirection, type DecodedMessage } from "@xmtp/xmtp-js"; import { useCallback, useEffect, useRef, useState } from "react"; -import { updateLastEntry } from "../helpers/updateLastEntry"; import type { OnError } from "../sharedTypes"; - -export type UseMessagesOptions = ListMessagesOptions & - OnError & { - /** - * Callback function to execute when new messages are fetched - */ - onMessages?: ( - messages: DecodedMessage[], - options: ListMessagesOptions, - ) => void; - }; +import { useCachedMessages } from "./useCachedMessages"; +import { toCachedMessage } from "@/helpers/caching/messages"; +import { adjustDate } from "@/helpers/adjustDate"; +import { getConversationByTopic } from "@/helpers/caching/conversations"; +import type { CachedConversation } from "@/helpers/caching/conversations"; +import { useClient } from "./useClient"; +import { useConversation } from "@/hooks/useConversation"; +import { useMessage } from "@/hooks/useMessage"; + +export type UseMessagesOptions = OnError & { + /** + * Callback function to execute when new messages are fetched + */ + onMessages?: (messages: DecodedMessage[]) => void; +}; /** * This hook fetches a list of all messages within a conversation on mount. It @@ -24,81 +23,77 @@ export type UseMessagesOptions = ListMessagesOptions & * messages based on the options passed. */ export const useMessages = ( - conversation?: Conversation, + conversation: CachedConversation, options?: UseMessagesOptions, ) => { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); - const [hasMore, setHasMore] = useState(false); - const [messages, setMessages] = useState([]); - // internal references to start/end times for paging results - const startTimeRef = useRef(options?.startTime); - const endTimeRef = useRef(options?.endTime); - // internal reference to the last message fetched - const lastEntryRef = useRef(); + const { processMessage } = useMessage(); + const { updateConversation } = useConversation(); + const messages = useCachedMessages(conversation.topic); + const { client } = useClient(); + // to prevent messages from being fetched multiple times + const loadingRef = useRef(false); // destructure options for more granular dependency arrays - const { - checkAddresses, - direction, - limit, - onError, - onMessages, - endTime, - startTime, - } = options ?? {}; - - // reset start/end time refs when the options or conversation change - useEffect(() => { - startTimeRef.current = startTime; - endTimeRef.current = endTime; - }, [endTime, startTime, conversation]); - - // reset messages when the conversation changes - useEffect(() => { - setMessages([]); - }, [conversation]); + const { onError, onMessages } = options ?? {}; const getMessages = useCallback(async () => { - // conversation is required - if (!conversation) { - return []; + // already in progress + if (loadingRef.current) { + return; + } + + // client is required + if (!client) { + const clientError = new Error("XMTP client is not available"); + setError(clientError); + onError?.(clientError); + return; } + loadingRef.current = true; + + // reset loading state setIsLoading(true); + // reset error state setError(null); - const finalOptions = { - checkAddresses, - direction, - endTime: endTimeRef.current, - limit, - startTime: startTimeRef.current, - }; + let startTime: Date | undefined; + // if the conversation messages have already been loaded + if (conversation.isReady) { + // only fetch messages after the most recent message in the conversation + startTime = adjustDate(conversation.updatedAt, 1); + } try { - const networkMessages = await conversation.messages(finalOptions); - - if (networkMessages.length > 0) { - updateLastEntry({ - direction, - endTimeRef, - startTimeRef, - lastEntry: networkMessages[networkMessages.length - 1], - lastEntryRef, - }); - } - - setMessages(networkMessages); - onMessages?.(networkMessages, finalOptions); - - if (limit) { - setHasMore( - networkMessages.length > 0 && networkMessages.length === limit, - ); + const networkConversation = await getConversationByTopic( + conversation.topic, + client, + ); + const networkMessages = + (await networkConversation?.messages({ + // be explicit in case the default changes + direction: SortDirection.SORT_DIRECTION_ASCENDING, + startTime, + })) ?? []; + + await Promise.all( + networkMessages.map((message) => + processMessage( + conversation, + toCachedMessage(message, client.address), + ), + ), + ); + + // this is the first time the conversation messages have been loaded + if (!conversation.isReady) { + // mark the conversation as ready + await updateConversation(conversation.topic, { isReady: true }); } - return networkMessages; + onMessages?.(networkMessages); } catch (e) { setError(e); onError?.(e); @@ -106,13 +101,16 @@ export const useMessages = ( throw e; } finally { setIsLoading(false); + loadingRef.current = false; } - - return []; - }, [checkAddresses, conversation, direction, limit, onError, onMessages]); - - // fetch the next set of messages - const next = useCallback(async () => getMessages(), [getMessages]); + }, [ + client, + conversation, + onError, + onMessages, + processMessage, + updateConversation, + ]); // fetch conversation messages on mount useEffect(() => { @@ -121,9 +119,7 @@ export const useMessages = ( return { error, - hasMore, isLoading, messages, - next, }; }; diff --git a/packages/react-sdk/src/hooks/useReactions.ts b/packages/react-sdk/src/hooks/useReactions.ts new file mode 100644 index 00000000..b0cc883f --- /dev/null +++ b/packages/react-sdk/src/hooks/useReactions.ts @@ -0,0 +1,26 @@ +import { useLiveQuery } from "dexie-react-hooks"; +import { useDb } from "./useDb"; +import type { CachedMessage } from "@/helpers/caching/messages"; +import type { CachedReactionsTable } from "@/helpers/caching/contentTypes/reaction"; + +/** + * This hook returns cached reactions to a message from the local cache + */ +export const useReactions = (message?: CachedMessage) => { + const { db } = useDb(); + + return ( + useLiveQuery(async () => { + if (!message) return []; + try { + const reactionsTable = db.table("reactions") as CachedReactionsTable; + return await reactionsTable + .where("referenceXmtpID") + .equals(message.xmtpID) + .toArray(); + } catch { + return []; + } + }, [message]) ?? [] + ); +}; diff --git a/packages/react-sdk/src/hooks/useReply.ts b/packages/react-sdk/src/hooks/useReply.ts new file mode 100644 index 00000000..5ea1b1cb --- /dev/null +++ b/packages/react-sdk/src/hooks/useReply.ts @@ -0,0 +1,29 @@ +import { useEffect, useState } from "react"; +import { useDb } from "./useDb"; +import type { CachedMessage } from "@/helpers/caching/messages"; +import { getOriginalMessageFromReply } from "@/helpers/caching/contentTypes/reply"; + +/** + * This hook returns the original message of a cached reply + */ +export const useReply = (message?: CachedMessage) => { + const { db } = useDb(); + + const [originalMessage, setOriginalMessage] = useState< + CachedMessage | undefined + >(undefined); + + useEffect(() => { + const getOriginalMessage = async () => { + if (message) { + const msg = await getOriginalMessageFromReply(message, db); + if (msg) { + setOriginalMessage(msg); + } + } + }; + void getOriginalMessage(); + }, [db, message]); + + return { originalMessage }; +}; diff --git a/packages/react-sdk/src/hooks/useSendMessage.test.ts b/packages/react-sdk/src/hooks/useSendMessage.test.ts new file mode 100644 index 00000000..c28ca979 --- /dev/null +++ b/packages/react-sdk/src/hooks/useSendMessage.test.ts @@ -0,0 +1,147 @@ +import { it, expect, describe, vi, beforeEach } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { + ContentTypeAttachment, + type Attachment, +} from "@xmtp/content-type-remote-attachment"; +import { useSendMessage } from "@/hooks/useSendMessage"; +import type { CachedConversation } from "@/helpers/caching/conversations"; + +const sendMessageMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/hooks/useMessage", async () => { + const actual = await import("@/hooks/useMessage"); + return { + useMessage: () => ({ + ...actual.useMessage, + sendMessage: sendMessageMock, + }), + }; +}); + +describe("useSendMessage", () => { + beforeEach(() => { + sendMessageMock.mockReset(); + }); + + it("should send a message", async () => { + sendMessageMock.mockResolvedValueOnce({ + sentMessage: { id: 1 }, + }); + const testConversation = { + id: 1, + createdAt: new Date(), + updatedAt: new Date(), + isReady: false, + walletAddress: "testWalletAddress", + topic: "testTopic", + peerAddress: "testPeerAddress", + } satisfies CachedConversation; + + const { result } = renderHook(() => useSendMessage()); + + await act(async () => { + const sentMessage = await result.current.sendMessage( + testConversation, + "test", + ); + expect(sentMessage).toEqual({ id: 1 }); + }); + + expect(sendMessageMock).toHaveBeenCalledTimes(1); + expect(sendMessageMock).toHaveBeenCalledWith( + testConversation, + "test", + undefined, + { + onError: undefined, + onSuccess: undefined, + }, + ); + }); + + it("should send a message with a custom content type and options", async () => { + const onErrorMock = vi.fn(); + const onSuccessMock = vi.fn(); + sendMessageMock.mockResolvedValueOnce({ + sentMessage: { id: 1 }, + }); + const testConversation = { + id: 1, + createdAt: new Date(), + updatedAt: new Date(), + isReady: false, + walletAddress: "testWalletAddress", + topic: "testTopic", + peerAddress: "testPeerAddress", + } satisfies CachedConversation; + + const testAttachment = { + filename: "test", + data: new Uint8Array(), + mimeType: "test", + } satisfies Attachment; + + const { result } = renderHook(() => + useSendMessage({ + onError: onErrorMock, + onSuccess: onSuccessMock, + }), + ); + + await act(async () => { + const sentMessage = await result.current.sendMessage( + testConversation, + testAttachment, + { + contentType: ContentTypeAttachment, + contentFallback: "test", + }, + ); + expect(sentMessage).toEqual({ id: 1 }); + }); + + expect(sendMessageMock).toHaveBeenCalledTimes(1); + expect(sendMessageMock).toHaveBeenCalledWith( + testConversation, + testAttachment, + ContentTypeAttachment, + { + contentFallback: "test", + contentType: ContentTypeAttachment, + onError: onErrorMock, + onSuccess: onSuccessMock, + }, + ); + }); + + it("should have an error when sending fails", async () => { + const testError = new Error("testError"); + sendMessageMock.mockRejectedValueOnce(testError); + const testConversation = { + id: 1, + createdAt: new Date(), + updatedAt: new Date(), + isReady: false, + walletAddress: "testWalletAddress", + topic: "testTopic", + peerAddress: "testPeerAddress", + } satisfies CachedConversation; + + const { result } = renderHook(() => useSendMessage()); + + await act(async () => { + try { + await result.current.sendMessage(testConversation, "test"); + } catch (e) { + expect(e).toEqual(testError); + } finally { + expect(sendMessageMock).toHaveBeenCalledTimes(1); + } + }); + + await waitFor(() => { + expect(result.current.error).toEqual(testError); + }); + }); +}); diff --git a/packages/react-sdk/src/hooks/useSendMessage.ts b/packages/react-sdk/src/hooks/useSendMessage.ts index 2fa6186c..d794fb56 100644 --- a/packages/react-sdk/src/hooks/useSendMessage.ts +++ b/packages/react-sdk/src/hooks/useSendMessage.ts @@ -1,82 +1,60 @@ -import type { Conversation, DecodedMessage, SendOptions } from "@xmtp/xmtp-js"; +import { type DecodedMessage, type SendOptions } from "@xmtp/xmtp-js"; import { useCallback, useState } from "react"; import type { OnError } from "../sharedTypes"; -import messagesDb from "../helpers/messagesDb"; +import { type CachedConversation } from "@/helpers/caching/conversations"; +import { useMessage } from "@/hooks/useMessage"; -export type UseSendMessageOptions = SendOptions & - OnError & { - /** - * Callback function to execute when a message has been sent successfully - */ - onSuccess?: (message: DecodedMessage) => void; - /** - * Automatically persist a successfully sent message to messages DB cache - */ - persist?: boolean; - }; +export type UseSendMessageOptions = OnError & { + /** + * Callback function to execute when a message has been sent successfully + */ + onSuccess?: (message: DecodedMessage) => void; +}; /** - * This hook sends a new message into a conversation. + * This hook sends a new message into a conversation */ -export const useSendMessage = ( - conversation: Conversation, - options?: UseSendMessageOptions, -) => { +export const useSendMessage = (options?: UseSendMessageOptions) => { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); + const { sendMessage: _sendMessage } = useMessage(); // destructure options for more granular dependency array - const { - compression, - contentFallback, - contentType, - ephemeral, - onError, - onSuccess, - persist, - timestamp, - } = options ?? {}; + const { onError, onSuccess } = options ?? {}; const sendMessage = useCallback( - async (message: T, optionsOverride?: SendOptions) => { + async ( + conversation: CachedConversation, + content: T, + sendOptions?: SendOptions, + ) => { setIsLoading(true); setError(null); + const contentType = sendOptions?.contentType; + try { - const sentMessage = await conversation?.send( - message, - optionsOverride ?? { - compression, - contentFallback, - contentType, - ephemeral, - timestamp, + const { sentMessage } = await _sendMessage( + conversation, + content, + contentType, + { + ...sendOptions, + onSuccess, + onError, }, ); - onSuccess?.(sentMessage); - if (persist) { - await messagesDb.persistMessage(sentMessage); - } + + return sentMessage; } catch (e) { setError(e); - onError?.(e); // re-throw error for upstream consumption throw e; } finally { setIsLoading(false); } }, - [ - compression, - contentFallback, - contentType, - conversation, - ephemeral, - onError, - onSuccess, - persist, - timestamp, - ], + [_sendMessage, onError, onSuccess], ); return { diff --git a/packages/react-sdk/src/hooks/useStartConversation.test.ts b/packages/react-sdk/src/hooks/useStartConversation.test.ts new file mode 100644 index 00000000..d25fb91f --- /dev/null +++ b/packages/react-sdk/src/hooks/useStartConversation.test.ts @@ -0,0 +1,286 @@ +import { it, expect, describe, vi, beforeEach } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { ConversationV2 } from "@xmtp/xmtp-js"; +import { ContentTypeText } from "@xmtp/xmtp-js"; +import { useStartConversation } from "@/hooks/useStartConversation"; +import { + toCachedConversation, + type CachedConversation, +} from "@/helpers/caching/conversations"; +import type { CachedMessageWithId } from "@/helpers/caching/messages"; + +const useClientMock = vi.hoisted(() => vi.fn()); +const sendMessageMock = vi.hoisted(() => vi.fn()); +const saveConversationMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/hooks/useClient", () => ({ + useClient: useClientMock, +})); + +vi.mock("@/hooks/useMessage", async () => { + const actual = await import("@/hooks/useMessage"); + return { + useMessage: () => ({ + ...actual.useMessage, + sendMessage: sendMessageMock, + }), + }; +}); + +vi.mock("@/hooks/useConversation", async () => { + const actual = await import("@/hooks/useConversation"); + return { + useConversation: () => ({ + ...actual.useConversation, + saveConversation: saveConversationMock, + }), + }; +}); + +describe("useStartConversation", () => { + beforeEach(() => { + useClientMock.mockReset(); + sendMessageMock.mockReset(); + saveConversationMock.mockReset(); + }); + + it("should have an error if no client is available", async () => { + const onErrorMock = vi.fn(); + useClientMock.mockImplementation(() => ({ + client: undefined, + })); + const clientError = new Error( + "XMTP client is required to start a conversation", + ); + + const { result } = renderHook(() => + useStartConversation({ + onError: onErrorMock, + }), + ); + + await act(async () => { + const { cachedConversation, cachedMessage, conversation } = + await result.current.startConversation("testPeerAddress", "test"); + expect(cachedConversation).toBeUndefined(); + expect(cachedMessage).toBeUndefined(); + expect(conversation).toBeUndefined(); + expect(onErrorMock).toHaveBeenCalledTimes(1); + expect(onErrorMock).toHaveBeenCalledWith(clientError); + }); + + await waitFor(() => { + expect(result.current.error).toEqual(clientError); + }); + }); + + it("should throw an error if newConversation fails", async () => { + const onErrorMock = vi.fn(); + const testError = new Error("testError"); + const newConversationMock = vi.fn().mockRejectedValueOnce(testError); + useClientMock.mockImplementation(() => ({ + client: { + address: "testWalletAddress", + conversations: { + newConversation: newConversationMock, + }, + }, + })); + + const { result } = renderHook(() => + useStartConversation({ + onError: onErrorMock, + }), + ); + + await act(async () => { + try { + await result.current.startConversation("testPeerAddress", "test"); + } catch (e) { + expect(e).toEqual(testError); + } finally { + expect(saveConversationMock).not.toHaveBeenCalled(); + expect(sendMessageMock).not.toHaveBeenCalled(); + expect(onErrorMock).toHaveBeenCalledTimes(1); + expect(onErrorMock).toHaveBeenCalledWith(testError); + } + }); + + await waitFor(() => { + expect(result.current.error).toEqual(testError); + }); + }); + + it("should throw an error if saveConversation fails", async () => { + const onErrorMock = vi.fn(); + const testError = new Error("testError"); + const createdAt = new Date(); + const mockConversation = { + context: undefined, + createdAt, + peerAddress: "testPeerAddress", + topic: "testTopic", + updatedAt: createdAt, + }; + const newConversationMock = vi.fn().mockResolvedValueOnce(mockConversation); + useClientMock.mockImplementation(() => ({ + client: { + address: "testWalletAddress", + conversations: { + newConversation: newConversationMock, + }, + }, + })); + saveConversationMock.mockRejectedValueOnce(testError); + + const { result } = renderHook(() => + useStartConversation({ + onError: onErrorMock, + }), + ); + + await act(async () => { + try { + await result.current.startConversation("testPeerAddress", "test"); + } catch (e) { + expect(e).toEqual(testError); + } finally { + expect(saveConversationMock).toBeCalledTimes(1); + expect(sendMessageMock).not.toHaveBeenCalled(); + expect(onErrorMock).toHaveBeenCalledTimes(1); + expect(onErrorMock).toHaveBeenCalledWith(testError); + } + }); + + await waitFor(() => { + expect(result.current.error).toEqual(testError); + }); + }); + + it("should throw an error if sendMessage fails", async () => { + const onErrorMock = vi.fn(); + const testError = new Error("testError"); + const createdAt = new Date(); + const mockConversation = { + context: undefined, + createdAt, + peerAddress: "testPeerAddress", + topic: "testTopic", + updatedAt: createdAt, + }; + const newConversationMock = vi.fn().mockResolvedValueOnce(mockConversation); + useClientMock.mockImplementation(() => ({ + client: { + address: "testWalletAddress", + conversations: { + newConversation: newConversationMock, + }, + }, + })); + const savedConversation = { + id: 1, + createdAt, + updatedAt: createdAt, + isReady: false, + walletAddress: "testWalletAddress", + topic: "testTopic", + peerAddress: "testPeerAddress", + } satisfies CachedConversation; + saveConversationMock.mockResolvedValueOnce(savedConversation); + sendMessageMock.mockRejectedValueOnce(testError); + + const { result } = renderHook(() => + useStartConversation({ + onError: onErrorMock, + }), + ); + + await act(async () => { + try { + await result.current.startConversation("testPeerAddress", "test"); + } catch (e) { + expect(e).toEqual(testError); + } finally { + expect(newConversationMock).toBeCalledTimes(1); + expect(saveConversationMock).toBeCalledTimes(1); + expect(onErrorMock).toHaveBeenCalledTimes(1); + expect(onErrorMock).toHaveBeenCalledWith(testError); + } + }); + + await waitFor(() => { + expect(result.current.error).toEqual(testError); + }); + }); + + it("should start a new conversation and send a message", async () => { + const createdAt = new Date(); + const mockConversation = { + context: undefined, + createdAt, + peerAddress: "testPeerAddress", + topic: "testTopic", + updatedAt: createdAt, + }; + const newConversationMock = vi.fn().mockResolvedValueOnce(mockConversation); + useClientMock.mockImplementation(() => ({ + client: { + address: "testWalletAddress", + conversations: { + newConversation: newConversationMock, + }, + }, + })); + const savedConversation = { + id: 1, + createdAt, + updatedAt: createdAt, + isReady: false, + walletAddress: "testWalletAddress", + topic: "testTopic", + peerAddress: "testPeerAddress", + } satisfies CachedConversation; + const savedMessage = { + id: 1, + sentAt: new Date(), + conversationTopic: "testTopic", + content: "test", + contentType: ContentTypeText.toString(), + hasSendError: false, + isSending: false, + senderAddress: "testWalletAddress", + status: "processed", + walletAddress: "testWalletAddress", + uuid: "testUuid", + xmtpID: "testXmtpId", + } satisfies CachedMessageWithId; + saveConversationMock.mockResolvedValueOnce(savedConversation); + sendMessageMock.mockResolvedValueOnce({ + cachedMessage: savedMessage, + }); + + const { result } = renderHook(() => useStartConversation()); + + await act(async () => { + const { cachedConversation, cachedMessage, conversation } = + await result.current.startConversation("testPeerAddress", "test"); + expect(cachedConversation).toEqual(savedConversation); + expect(cachedMessage).toEqual(savedMessage); + expect(conversation).toEqual(mockConversation); + }); + + expect(newConversationMock).toHaveBeenCalledTimes(1); + expect(newConversationMock).toHaveBeenCalledWith( + "testPeerAddress", + undefined, + ); + expect(saveConversationMock).toHaveBeenCalledTimes(1); + expect(saveConversationMock).toHaveBeenCalledWith( + toCachedConversation( + mockConversation as unknown as ConversationV2, + "testWalletAddress", + ), + ); + expect(sendMessageMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/react-sdk/src/hooks/useStartConversation.ts b/packages/react-sdk/src/hooks/useStartConversation.ts index 0c2bcd1b..03ae3851 100644 --- a/packages/react-sdk/src/hooks/useStartConversation.ts +++ b/packages/react-sdk/src/hooks/useStartConversation.ts @@ -1,40 +1,53 @@ import { useCallback, useState } from "react"; -import type { SendOptions, InvitationContext } from "@xmtp/xmtp-js"; +import type { ContentTypeId, InvitationContext } from "@xmtp/xmtp-js"; import { useClient } from "./useClient"; import type { OnError } from "../sharedTypes"; +import type { SendMessageOptions } from "@/hooks/useMessage"; +import { useConversation } from "@/hooks/useConversation"; +import { useMessage } from "@/hooks/useMessage"; +import { toCachedConversation } from "@/helpers/caching/conversations"; -export type UseStartConversation = InvitationContext & OnError; +export type UseStartConversation = Partial & OnError; /** * This hook starts a new conversation and sends an initial message to it. */ -export const useStartConversation = ( - options?: UseStartConversation, -) => { +export const useStartConversation = (options?: UseStartConversation) => { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const { client } = useClient(); + const { sendMessage: _sendMessage } = useMessage(); + const { saveConversation } = useConversation(); // destructure options for more granular dependency arrays const { conversationId, metadata, onError } = options ?? {}; const startConversation = useCallback( - async (peerAddress: string, message: T, sendOptions?: SendOptions) => { + async ( + peerAddress: string, + content: T, + contentType?: ContentTypeId, + sendOptions?: SendMessageOptions, + ) => { // we can't do anything without a client if (client === undefined) { - const clientError = new Error("XMTP client is not available"); + const clientError = new Error( + "XMTP client is required to start a conversation", + ); setError(clientError); - onError?.(client); - // do not throw the error in this case - // return undefined - return undefined; + onError?.(clientError); + return { + cachedConversation: undefined, + cachedMessage: undefined, + conversation: undefined, + }; } setIsLoading(true); setError(null); try { - const conversation = await client?.conversations.newConversation( + const conversation = await client.conversations.newConversation( peerAddress, conversationId && metadata ? { @@ -44,8 +57,38 @@ export const useStartConversation = ( : undefined, ); - await conversation.send(message, sendOptions); - return conversation; + const cachedConversation = await saveConversation( + toCachedConversation(conversation, client.address), + ); + + if (content === undefined) { + return { + cachedConversation, + cachedMessage: undefined, + conversation: undefined, + }; + } + + if (!cachedConversation) { + return { + cachedConversation: undefined, + cachedMessage: undefined, + conversation: undefined, + }; + } + + const { cachedMessage } = await _sendMessage( + cachedConversation, + content, + contentType, + sendOptions, + ); + + return { + cachedConversation, + cachedMessage, + conversation, + }; } catch (e) { setError(e); onError?.(e); @@ -55,7 +98,7 @@ export const useStartConversation = ( setIsLoading(false); } }, - [client, conversationId, metadata, onError], + [_sendMessage, client, conversationId, metadata, onError, saveConversation], ); return { diff --git a/packages/react-sdk/src/hooks/useStreamAllMessages.ts b/packages/react-sdk/src/hooks/useStreamAllMessages.ts index 99ea7be3..aa26acb6 100644 --- a/packages/react-sdk/src/hooks/useStreamAllMessages.ts +++ b/packages/react-sdk/src/hooks/useStreamAllMessages.ts @@ -2,6 +2,9 @@ import type { DecodedMessage } from "@xmtp/xmtp-js"; import { useEffect, useRef, useState } from "react"; import { useClient } from "./useClient"; import type { OnError } from "../sharedTypes"; +import { toCachedMessage } from "../helpers/caching/messages"; +import { useConversation } from "@/hooks/useConversation"; +import { useMessage } from "@/hooks/useMessage"; export type AllMessagesStream = Promise>; @@ -10,7 +13,7 @@ export type AllMessagesStream = Promise>; * an error state. */ export const useStreamAllMessages = ( - onMessage: (message: DecodedMessage) => void, + onMessage: (message: DecodedMessage) => void | Promise, onError?: OnError["onError"], ) => { const [error, setError] = useState(null); @@ -25,6 +28,8 @@ export const useStreamAllMessages = ( void (await stream).return(undefined); } }); + const { processMessage } = useMessage(); + const { getCachedByTopic } = useConversation(); const { client } = useClient(); @@ -40,7 +45,7 @@ export const useStreamAllMessages = ( if (client === undefined) { const clientError = new Error("XMTP client is not available"); setError(clientError); - onError?.(client); + onError?.(clientError); // do not throw the error in this case return; } @@ -57,7 +62,16 @@ export const useStreamAllMessages = ( stream = streamRef.current; for await (const message of await stream) { - onMessage(message); + const cachedConversation = await getCachedByTopic( + message.conversation.topic, + ); + if (cachedConversation) { + await processMessage( + cachedConversation, + toCachedMessage(message, client.address), + ); + } + void onMessage(message); } } catch (e) { setError(e); @@ -74,7 +88,7 @@ export const useStreamAllMessages = ( return () => { void endStream(stream); }; - }, [onMessage, client, onError]); + }, [onMessage, client, onError, processMessage, getCachedByTopic]); return { error, diff --git a/packages/react-sdk/src/hooks/useStreamConversations.ts b/packages/react-sdk/src/hooks/useStreamConversations.ts index a1351ad3..5ffe628a 100644 --- a/packages/react-sdk/src/hooks/useStreamConversations.ts +++ b/packages/react-sdk/src/hooks/useStreamConversations.ts @@ -2,16 +2,22 @@ import type { Conversation, Stream } from "@xmtp/xmtp-js"; import { useEffect, useRef, useState } from "react"; import { useClient } from "./useClient"; import type { OnError } from "../sharedTypes"; +import { useConversation } from "@/hooks/useConversation"; +import { toCachedConversation } from "@/helpers/caching/conversations"; export type ConversationStream = Promise>; +export type UseStreamConversationsOptions = { + onConversation?: (conversation: Conversation) => void; + onError?: OnError["onError"]; +}; + /** * This hook listens for new conversations in real-time and calls the passed * callback when a new conversation is created. It also exposes an error state. */ export const useStreamConversations = ( - onConversation: (conversation: Conversation) => void, - onError?: OnError["onError"], + options?: UseStreamConversationsOptions, ) => { const [error, setError] = useState(null); const streamRef = useRef(undefined); @@ -27,6 +33,10 @@ export const useStreamConversations = ( }); const { client } = useClient(); + const { saveConversation } = useConversation(); + + // destructure options for more granular dependency array + const { onConversation, onError } = options ?? {}; /** * Attempt to stream conversations on mount @@ -41,7 +51,7 @@ export const useStreamConversations = ( if (client === undefined) { const clientError = new Error("XMTP client is not available"); setError(clientError); - onError?.(client); + onError?.(clientError); // do not throw the error in this case return; } @@ -58,7 +68,10 @@ export const useStreamConversations = ( stream = streamRef.current; for await (const conversation of await stream) { - onConversation(conversation); + await saveConversation( + toCachedConversation(conversation, client.address), + ); + onConversation?.(conversation); } } catch (e) { setError(e); @@ -75,7 +88,7 @@ export const useStreamConversations = ( return () => { void endStream(stream); }; - }, [onConversation, client, onError]); + }, [client, saveConversation, onError, onConversation]); return { error, diff --git a/packages/react-sdk/src/hooks/useStreamMessages.ts b/packages/react-sdk/src/hooks/useStreamMessages.ts index 888eb9e4..d59f04d4 100644 --- a/packages/react-sdk/src/hooks/useStreamMessages.ts +++ b/packages/react-sdk/src/hooks/useStreamMessages.ts @@ -1,16 +1,27 @@ -import type { Conversation, DecodedMessage, Stream } from "@xmtp/xmtp-js"; +import type { DecodedMessage, Stream } from "@xmtp/xmtp-js"; import { useEffect, useRef, useState } from "react"; import type { OnError } from "../sharedTypes"; +import { toCachedMessage } from "../helpers/caching/messages"; +import { + getConversationByTopic, + type CachedConversation, +} from "@/helpers/caching/conversations"; +import { useClient } from "./useClient"; +import { useMessage } from "@/hooks/useMessage"; export type MessageStream = Promise>; +export type UseStreamMessagesOptions = { + onError?: OnError["onError"]; + onMessage?: (message: DecodedMessage) => void; +}; + /** * This hook streams new conversation messages on mount and exposes an error state. */ export const useStreamMessages = ( - conversation: Conversation, - onMessage: (message: DecodedMessage) => void, - onError?: OnError["onError"], + conversation: CachedConversation, + options?: UseStreamMessagesOptions, ) => { const [error, setError] = useState(null); const streamRef = useRef(undefined); @@ -24,11 +35,21 @@ export const useStreamMessages = ( void (await stream).return(); } }); + const { processMessage } = useMessage(); + const { client } = useClient(); + + // destructure options for more granular dependency array + const { onError, onMessage } = options ?? {}; // attempt to stream conversation messages on mount useEffect(() => { - // if there's no conversation, don't do anything - if (!conversation) { + // conversation and client are required + if (!conversation || !client) { + const clientError = new Error( + "XMTP client and/or conversation is not available", + ); + setError(clientError); + onError?.(clientError); return () => {}; } @@ -42,14 +63,30 @@ export const useStreamMessages = ( return; } + const networkConversation = await getConversationByTopic( + conversation.topic, + client, + ); + + // don't start a stream if there's no network conversation + if (!networkConversation) { + // TODO: should this throw instead? + return; + } + try { // it's important not to await the stream here so that we can cleanup // consistently if this hook unmounts during this call - streamRef.current = conversation.streamMessages(); + streamRef.current = networkConversation.streamMessages(); stream = streamRef.current; for await (const message of await stream) { - onMessage(message); + // TODO: figure out why this is happening twice per send + await processMessage( + conversation, + toCachedMessage(message, client.address), + ); + onMessage?.(message); } } catch (e) { setError(e); @@ -66,7 +103,7 @@ export const useStreamMessages = ( return () => { void endStream(stream); }; - }, [conversation, onError, onMessage]); + }, [client, conversation, onError, onMessage, processMessage]); return { error, diff --git a/packages/react-sdk/src/index.ts b/packages/react-sdk/src/index.ts index a6318327..1bebe311 100644 --- a/packages/react-sdk/src/index.ts +++ b/packages/react-sdk/src/index.ts @@ -3,21 +3,99 @@ export { XMTPProvider } from "./contexts/XMTPContext"; // helpers export { isValidAddress } from "./helpers/isValidAddress"; -export { default as messagesDb } from "./helpers/messagesDb"; // hooks -export { useCanMessage } from "./hooks/useCanMessage"; -export { useConversations } from "./hooks/useConversations"; export { useClient } from "./hooks/useClient"; -export { useCachedMessages } from "./hooks/useCachedMessages"; +export { useDb } from "./hooks/useDb"; + +// conversations +export { useConversation } from "./hooks/useConversation"; +export { useConversations } from "./hooks/useConversations"; +export { useStartConversation } from "./hooks/useStartConversation"; +export { useStreamConversations } from "./hooks/useStreamConversations"; + +// messages +export { useLastMessage } from "./hooks/useLastMessage"; +export { useCanMessage } from "./hooks/useCanMessage"; export { useMessages } from "./hooks/useMessages"; +export { useMessage } from "./hooks/useMessage"; export { useSendMessage } from "./hooks/useSendMessage"; -export { useStartConversation } from "./hooks/useStartConversation"; export { useStreamAllMessages } from "./hooks/useStreamAllMessages"; -export { useStreamConversations } from "./hooks/useStreamConversations"; export { useStreamMessages } from "./hooks/useStreamMessages"; -export { Client, SortDirection } from "@xmtp/xmtp-js"; +// reactions +export { useReactions } from "./hooks/useReactions"; + +// replies +export { useReply } from "./hooks/useReply"; + +// caching +export { getDbInstance } from "./helpers/caching/db"; + +// conversations +export type { + CachedConversation, + CachedConversationWithId, + CachedConversationsTable, +} from "./helpers/caching/conversations"; + +// messages +export type { + CachedMessage, + CachedMessageWithId, + CachedMessagesTable, + ProcessUnprocessedMessagesOptions, +} from "./helpers/caching/messages"; +export { + getMessageByXmtpID, + processUnprocessedMessages, + toCachedMessage, +} from "./helpers/caching/messages"; + +// attachments +export type { CachedAttachmentsMetadata } from "./helpers/caching/contentTypes/attachment"; +export { + attachmentsCacheConfig, + getAttachment, +} from "./helpers/caching/contentTypes/attachment"; + +// reactions +export type { + CachedReaction, + CachedReactionsMetadata, + CachedReactionsTable, +} from "./helpers/caching/contentTypes/reaction"; +export { + getReactionsByXmtpID, + hasReaction, + reactionsCacheConfig, +} from "./helpers/caching/contentTypes/reaction"; + +// read receipts +export type { CachedReadReceiptMetadata } from "./helpers/caching/contentTypes/readReceipt"; +export { + readReceiptsCacheConfig, + hasReadReceipt, + getReadReceipt, +} from "./helpers/caching/contentTypes/readReceipt"; + +// replies +export type { CachedRepliesMetadata } from "./helpers/caching/contentTypes/reply"; +export { + hasReply, + getOriginalMessageFromReply, + repliesCacheConfig, +} from "./helpers/caching/contentTypes/reply"; + +// text +export { textCacheConfig } from "./helpers/caching/contentTypes/text"; + +export { + Client, + SortDirection, + ContentTypeId, + ContentTypeText, +} from "@xmtp/xmtp-js"; // re-export types from the JS SDK export type * from "@xmtp/xmtp-js"; diff --git a/packages/react-sdk/src/sharedTypes.ts b/packages/react-sdk/src/sharedTypes.ts index 96422fcf..9973b151 100644 --- a/packages/react-sdk/src/sharedTypes.ts +++ b/packages/react-sdk/src/sharedTypes.ts @@ -10,3 +10,7 @@ export type CanMessageReturns = T extends string : T extends string[] ? boolean[] : never; + +export type RemoveLastParameter = F extends (...args: infer A) => infer R + ? (...args: A extends [...infer U, any] ? U : never) => R + : never; diff --git a/packages/react-sdk/tsconfig.json b/packages/react-sdk/tsconfig.json index afe1b9ea..afc7ad14 100644 --- a/packages/react-sdk/tsconfig.json +++ b/packages/react-sdk/tsconfig.json @@ -1,4 +1,10 @@ { "extends": "@xmtp/tsconfig/react-sdk.json", - "include": ["src", ".eslintrc.cjs"] + "include": ["src", ".eslintrc.cjs", "vitest.setup.ts"], + "compilerOptions": { + "module": "esnext", + "paths": { + "@/*": ["./src/*"] + } + } } diff --git a/packages/react-sdk/vite.config.ts b/packages/react-sdk/vite.config.ts index 9cc50ead..b91d026a 100644 --- a/packages/react-sdk/vite.config.ts +++ b/packages/react-sdk/vite.config.ts @@ -1,7 +1,15 @@ +/// + import { defineConfig } from "vite"; +import tsconfigPaths from "vite-tsconfig-paths"; import react from "@vitejs/plugin-react"; // https://vitejs.dev/config/ export default defineConfig({ - plugins: [react()], + plugins: [tsconfigPaths(), react()], + test: { + globals: true, + environment: "jsdom", + setupFiles: "./vitest.setup.ts", + }, }); diff --git a/packages/react-sdk/vitest.config.ts b/packages/react-sdk/vitest.config.ts deleted file mode 100644 index 8b312569..00000000 --- a/packages/react-sdk/vitest.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - globals: true, - environment: "jsdom", - }, -}); diff --git a/packages/react-sdk/vitest.setup.ts b/packages/react-sdk/vitest.setup.ts new file mode 100644 index 00000000..e5ac28ef --- /dev/null +++ b/packages/react-sdk/vitest.setup.ts @@ -0,0 +1,7 @@ +import "fake-indexeddb/auto"; +import "@testing-library/jest-dom/vitest"; +import { Buffer } from "buffer"; +import { webcrypto } from "crypto"; + +globalThis.Buffer = Buffer; +globalThis.crypto = webcrypto as unknown as Crypto; diff --git a/turbo.json b/turbo.json index 9dc78af6..7440fe2f 100644 --- a/turbo.json +++ b/turbo.json @@ -27,6 +27,10 @@ "dependsOn": ["^generate:types"], "outputs": [] }, + "quickstart": { + "dependsOn": ["^build"], + "persistent": true + }, "test": { "outputs": [] }, diff --git a/yarn.lock b/yarn.lock index c36888dd..57526aca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12,6 +12,20 @@ __metadata: languageName: node linkType: hard +"@adobe/css-tools@npm:^4.0.1": + version: 4.3.0 + resolution: "@adobe/css-tools@npm:4.3.0" + checksum: 63c9702a91bfda13767fcb3107056ed19463e9a4dcb3b5918d64c8eb7e609357c59b1355c0226765b60323adbb0abd88564acfa1e2b20067219c3ad23576c0b3 + languageName: node + linkType: hard + +"@adraffy/ens-normalize@npm:1.9.2": + version: 1.9.2 + resolution: "@adraffy/ens-normalize@npm:1.9.2" + checksum: a9fdeb9e080774c19e4b7f9f60aa5b37cf23fe0e8fe80284bf8221f7396e9f78642bfd39a09a94a4dc3fb8e70f2ac81545204bdcaf222d93f4c4c2ae1f0dca0b + languageName: node + linkType: hard + "@ampproject/remapping@npm:^2.2.0": version: 2.2.0 resolution: "@ampproject/remapping@npm:2.2.0" @@ -22,6 +36,16 @@ __metadata: languageName: node linkType: hard +"@ampproject/remapping@npm:^2.2.1": + version: 2.2.1 + resolution: "@ampproject/remapping@npm:2.2.1" + dependencies: + "@jridgewell/gen-mapping": ^0.3.0 + "@jridgewell/trace-mapping": ^0.3.9 + checksum: 03c04fd526acc64a1f4df22651186f3e5ef0a9d6d6530ce4482ec9841269cf7a11dbb8af79237c282d721c5312024ff17529cd72cc4768c11e999b58e2302079 + languageName: node + linkType: hard + "@aw-web-design/x-default-browser@npm:1.4.88": version: 1.4.88 resolution: "@aw-web-design/x-default-browser@npm:1.4.88" @@ -51,6 +75,16 @@ __metadata: languageName: node linkType: hard +"@babel/code-frame@npm:^7.22.10": + version: 7.22.10 + resolution: "@babel/code-frame@npm:7.22.10" + dependencies: + "@babel/highlight": ^7.22.10 + chalk: ^2.4.2 + checksum: 89a06534ad19759da6203a71bad120b1d7b2ddc016c8e07d4c56b35dea25e7396c6da60a754e8532a86733092b131ae7f661dbe6ba5d165ea777555daa2ed3c9 + languageName: node + linkType: hard + "@babel/code-frame@npm:^7.22.5": version: 7.22.5 resolution: "@babel/code-frame@npm:7.22.5" @@ -81,13 +115,6 @@ __metadata: languageName: node linkType: hard -"@babel/compat-data@npm:^7.22.5": - version: 7.22.5 - resolution: "@babel/compat-data@npm:7.22.5" - checksum: eb1a47ebf79ae268b4a16903e977be52629339806e248455eb9973897c503a04b701f36a9de64e19750d6e081d0561e77a514c8dc470babbeba59ae94298ed18 - languageName: node - linkType: hard - "@babel/compat-data@npm:^7.22.6": version: 7.22.6 resolution: "@babel/compat-data@npm:7.22.6" @@ -95,6 +122,13 @@ __metadata: languageName: node linkType: hard +"@babel/compat-data@npm:^7.22.9": + version: 7.22.9 + resolution: "@babel/compat-data@npm:7.22.9" + checksum: bed77d9044ce948b4327b30dd0de0779fa9f3a7ed1f2d31638714ed00229fa71fc4d1617ae0eb1fad419338d3658d0e9a5a083297451e09e73e078d0347ff808 + languageName: node + linkType: hard + "@babel/core@npm:^7.11.6, @babel/core@npm:^7.13.16, @babel/core@npm:^7.20.2, @babel/core@npm:~7.21.0": version: 7.21.4 resolution: "@babel/core@npm:7.21.4" @@ -164,29 +198,6 @@ __metadata: languageName: node linkType: hard -"@babel/core@npm:^7.22.5": - version: 7.22.5 - resolution: "@babel/core@npm:7.22.5" - dependencies: - "@ampproject/remapping": ^2.2.0 - "@babel/code-frame": ^7.22.5 - "@babel/generator": ^7.22.5 - "@babel/helper-compilation-targets": ^7.22.5 - "@babel/helper-module-transforms": ^7.22.5 - "@babel/helpers": ^7.22.5 - "@babel/parser": ^7.22.5 - "@babel/template": ^7.22.5 - "@babel/traverse": ^7.22.5 - "@babel/types": ^7.22.5 - convert-source-map: ^1.7.0 - debug: ^4.1.0 - gensync: ^1.0.0-beta.2 - json5: ^2.2.2 - semver: ^6.3.0 - checksum: 173ae426958c90c7bbd7de622c6f13fcab8aef0fac3f138e2d47bc466d1cd1f86f71ca82ae0acb9032fd8794abed8efb56fea55c031396337eaec0d673b69d56 - languageName: node - linkType: hard - "@babel/core@npm:^7.22.8": version: 7.22.8 resolution: "@babel/core@npm:7.22.8" @@ -210,6 +221,29 @@ __metadata: languageName: node linkType: hard +"@babel/core@npm:^7.22.9": + version: 7.22.10 + resolution: "@babel/core@npm:7.22.10" + dependencies: + "@ampproject/remapping": ^2.2.0 + "@babel/code-frame": ^7.22.10 + "@babel/generator": ^7.22.10 + "@babel/helper-compilation-targets": ^7.22.10 + "@babel/helper-module-transforms": ^7.22.9 + "@babel/helpers": ^7.22.10 + "@babel/parser": ^7.22.10 + "@babel/template": ^7.22.5 + "@babel/traverse": ^7.22.10 + "@babel/types": ^7.22.10 + convert-source-map: ^1.7.0 + debug: ^4.1.0 + gensync: ^1.0.0-beta.2 + json5: ^2.2.2 + semver: ^6.3.1 + checksum: cc4efa09209fe1f733cf512e9e4bb50870b191ab2dee8014e34cd6e731f204e48476cc53b4bbd0825d4d342304d577ae43ff5fd8ab3896080673c343321acb32 + languageName: node + linkType: hard + "@babel/generator@npm:^7.12.11, @babel/generator@npm:^7.21.3": version: 7.21.3 resolution: "@babel/generator@npm:7.21.3" @@ -258,6 +292,18 @@ __metadata: languageName: node linkType: hard +"@babel/generator@npm:^7.22.10": + version: 7.22.10 + resolution: "@babel/generator@npm:7.22.10" + dependencies: + "@babel/types": ^7.22.10 + "@jridgewell/gen-mapping": ^0.3.2 + "@jridgewell/trace-mapping": ^0.3.17 + jsesc: ^2.5.1 + checksum: 59a79730abdff9070692834bd3af179e7a9413fa2ff7f83dff3eb888765aeaeb2bfc7b0238a49613ed56e1af05956eff303cc139f2407eda8df974813e486074 + languageName: node + linkType: hard + "@babel/generator@npm:^7.22.5": version: 7.22.5 resolution: "@babel/generator@npm:7.22.5" @@ -346,18 +392,16 @@ __metadata: languageName: node linkType: hard -"@babel/helper-compilation-targets@npm:^7.22.5": - version: 7.22.5 - resolution: "@babel/helper-compilation-targets@npm:7.22.5" +"@babel/helper-compilation-targets@npm:^7.22.10": + version: 7.22.10 + resolution: "@babel/helper-compilation-targets@npm:7.22.10" dependencies: - "@babel/compat-data": ^7.22.5 + "@babel/compat-data": ^7.22.9 "@babel/helper-validator-option": ^7.22.5 - browserslist: ^4.21.3 + browserslist: ^4.21.9 lru-cache: ^5.1.1 - semver: ^6.3.0 - peerDependencies: - "@babel/core": ^7.0.0 - checksum: a479460615acffa0f4fd0a29b740eafb53a93694265207d23a6038ccd18d183a382cacca515e77b7c9b042c3ba80b0aca0da5f1f62215140e81660d2cf721b68 + semver: ^6.3.1 + checksum: f6f1896816392bcff671bbe6e277307729aee53befb4a66ea126e2a91eda78d819a70d06fa384c74ef46c1595544b94dca50bef6c78438d9ffd31776dafbd435 languageName: node linkType: hard @@ -574,6 +618,21 @@ __metadata: languageName: node linkType: hard +"@babel/helper-module-transforms@npm:^7.22.9": + version: 7.22.9 + resolution: "@babel/helper-module-transforms@npm:7.22.9" + dependencies: + "@babel/helper-environment-visitor": ^7.22.5 + "@babel/helper-module-imports": ^7.22.5 + "@babel/helper-simple-access": ^7.22.5 + "@babel/helper-split-export-declaration": ^7.22.6 + "@babel/helper-validator-identifier": ^7.22.5 + peerDependencies: + "@babel/core": ^7.0.0 + checksum: 2751f77660518cf4ff027514d6f4794f04598c6393be7b04b8e46c6e21606e11c19f3f57ab6129a9c21bacdf8b3ffe3af87bb401d972f34af2d0ffde02ac3001 + languageName: node + linkType: hard + "@babel/helper-optimise-call-expression@npm:^7.18.6": version: 7.18.6 resolution: "@babel/helper-optimise-call-expression@npm:7.18.6" @@ -767,14 +826,14 @@ __metadata: languageName: node linkType: hard -"@babel/helpers@npm:^7.22.5": - version: 7.22.5 - resolution: "@babel/helpers@npm:7.22.5" +"@babel/helpers@npm:^7.22.10": + version: 7.22.10 + resolution: "@babel/helpers@npm:7.22.10" dependencies: "@babel/template": ^7.22.5 - "@babel/traverse": ^7.22.5 - "@babel/types": ^7.22.5 - checksum: a96e785029dff72f171190943df895ab0f76e17bf3881efd630bc5fae91215042d1c2e9ed730e8e4adf4da6f28b24bd1f54ed93b90ffbca34c197351872a084e + "@babel/traverse": ^7.22.10 + "@babel/types": ^7.22.10 + checksum: 3b1219e362df390b6c5d94b75a53fc1c2eb42927ced0b8022d6a29b833a839696206b9bdad45b4805d05591df49fc16b6fb7db758c9c2ecfe99e3e94cb13020f languageName: node linkType: hard @@ -800,6 +859,17 @@ __metadata: languageName: node linkType: hard +"@babel/highlight@npm:^7.22.10": + version: 7.22.10 + resolution: "@babel/highlight@npm:7.22.10" + dependencies: + "@babel/helper-validator-identifier": ^7.22.5 + chalk: ^2.4.2 + js-tokens: ^4.0.0 + checksum: f714a1e1a72dd9d72f6383f4f30fd342e21a8df32d984a4ea8f5eab691bb6ba6db2f8823d4b4cf135d98869e7a98925b81306aa32ee3c429f8cfa52c75889e1b + languageName: node + linkType: hard + "@babel/highlight@npm:^7.22.5": version: 7.22.5 resolution: "@babel/highlight@npm:7.22.5" @@ -847,6 +917,15 @@ __metadata: languageName: node linkType: hard +"@babel/parser@npm:^7.22.10": + version: 7.22.10 + resolution: "@babel/parser@npm:7.22.10" + bin: + parser: ./bin/babel-parser.js + checksum: af51567b7d3cdf523bc608eae057397486c7fa6c2e5753027c01fe5c36f0767b2d01ce3049b222841326cc5b8c7fda1d810ac1a01af0a97bb04679e2ef9f7049 + languageName: node + linkType: hard + "@babel/parser@npm:^7.22.5": version: 7.22.5 resolution: "@babel/parser@npm:7.22.5" @@ -2095,6 +2174,15 @@ __metadata: languageName: node linkType: hard +"@babel/runtime@npm:^7.9.2": + version: 7.22.10 + resolution: "@babel/runtime@npm:7.22.10" + dependencies: + regenerator-runtime: ^0.14.0 + checksum: 524d41517e68953dbc73a4f3616b8475e5813f64e28ba89ff5fca2c044d535c2ea1a3f310df1e5bb06162e1f0b401b5c4af73fe6e2519ca2450d9d8c44cf268d + languageName: node + linkType: hard + "@babel/template@npm:^7.18.10, @babel/template@npm:^7.20.7": version: 7.20.7 resolution: "@babel/template@npm:7.20.7" @@ -2189,6 +2277,24 @@ __metadata: languageName: node linkType: hard +"@babel/traverse@npm:^7.22.10": + version: 7.22.10 + resolution: "@babel/traverse@npm:7.22.10" + dependencies: + "@babel/code-frame": ^7.22.10 + "@babel/generator": ^7.22.10 + "@babel/helper-environment-visitor": ^7.22.5 + "@babel/helper-function-name": ^7.22.5 + "@babel/helper-hoist-variables": ^7.22.5 + "@babel/helper-split-export-declaration": ^7.22.6 + "@babel/parser": ^7.22.10 + "@babel/types": ^7.22.10 + debug: ^4.1.0 + globals: ^11.1.0 + checksum: 9f7b358563bfb0f57ac4ed639f50e5c29a36b821a1ce1eea0c7db084f5b925e3275846d0de63bde01ca407c85d9804e0efbe370d92cd2baaafde3bd13b0f4cdb + languageName: node + linkType: hard + "@babel/traverse@npm:^7.22.5": version: 7.22.5 resolution: "@babel/traverse@npm:7.22.5" @@ -2269,6 +2375,17 @@ __metadata: languageName: node linkType: hard +"@babel/types@npm:^7.22.10": + version: 7.22.10 + resolution: "@babel/types@npm:7.22.10" + dependencies: + "@babel/helper-string-parser": ^7.22.5 + "@babel/helper-validator-identifier": ^7.22.5 + to-fast-properties: ^2.0.0 + checksum: 095c4f4b7503fa816e4094113f0ec2351ef96ff32012010b771693066ff628c7c664b21c6bd3fb93aeb46fe7c61f6b3a3c9e4ed0034d6a2481201c417371c8af + languageName: node + linkType: hard + "@babel/types@npm:^7.22.5": version: 7.22.5 resolution: "@babel/types@npm:7.22.5" @@ -2969,13 +3086,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/android-arm64@npm:0.16.17": - version: 0.16.17 - resolution: "@esbuild/android-arm64@npm:0.16.17" - conditions: os=android & cpu=arm64 - languageName: node - linkType: hard - "@esbuild/android-arm64@npm:0.17.14": version: 0.17.14 resolution: "@esbuild/android-arm64@npm:0.17.14" @@ -2997,13 +3107,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/android-arm@npm:0.16.17": - version: 0.16.17 - resolution: "@esbuild/android-arm@npm:0.16.17" - conditions: os=android & cpu=arm - languageName: node - linkType: hard - "@esbuild/android-arm@npm:0.17.14": version: 0.17.14 resolution: "@esbuild/android-arm@npm:0.17.14" @@ -3025,13 +3128,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/android-x64@npm:0.16.17": - version: 0.16.17 - resolution: "@esbuild/android-x64@npm:0.16.17" - conditions: os=android & cpu=x64 - languageName: node - linkType: hard - "@esbuild/android-x64@npm:0.17.14": version: 0.17.14 resolution: "@esbuild/android-x64@npm:0.17.14" @@ -3053,13 +3149,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/darwin-arm64@npm:0.16.17": - version: 0.16.17 - resolution: "@esbuild/darwin-arm64@npm:0.16.17" - conditions: os=darwin & cpu=arm64 - languageName: node - linkType: hard - "@esbuild/darwin-arm64@npm:0.17.14": version: 0.17.14 resolution: "@esbuild/darwin-arm64@npm:0.17.14" @@ -3081,13 +3170,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/darwin-x64@npm:0.16.17": - version: 0.16.17 - resolution: "@esbuild/darwin-x64@npm:0.16.17" - conditions: os=darwin & cpu=x64 - languageName: node - linkType: hard - "@esbuild/darwin-x64@npm:0.17.14": version: 0.17.14 resolution: "@esbuild/darwin-x64@npm:0.17.14" @@ -3109,13 +3191,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/freebsd-arm64@npm:0.16.17": - version: 0.16.17 - resolution: "@esbuild/freebsd-arm64@npm:0.16.17" - conditions: os=freebsd & cpu=arm64 - languageName: node - linkType: hard - "@esbuild/freebsd-arm64@npm:0.17.14": version: 0.17.14 resolution: "@esbuild/freebsd-arm64@npm:0.17.14" @@ -3137,13 +3212,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/freebsd-x64@npm:0.16.17": - version: 0.16.17 - resolution: "@esbuild/freebsd-x64@npm:0.16.17" - conditions: os=freebsd & cpu=x64 - languageName: node - linkType: hard - "@esbuild/freebsd-x64@npm:0.17.14": version: 0.17.14 resolution: "@esbuild/freebsd-x64@npm:0.17.14" @@ -3165,13 +3233,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-arm64@npm:0.16.17": - version: 0.16.17 - resolution: "@esbuild/linux-arm64@npm:0.16.17" - conditions: os=linux & cpu=arm64 - languageName: node - linkType: hard - "@esbuild/linux-arm64@npm:0.17.14": version: 0.17.14 resolution: "@esbuild/linux-arm64@npm:0.17.14" @@ -3193,13 +3254,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-arm@npm:0.16.17": - version: 0.16.17 - resolution: "@esbuild/linux-arm@npm:0.16.17" - conditions: os=linux & cpu=arm - languageName: node - linkType: hard - "@esbuild/linux-arm@npm:0.17.14": version: 0.17.14 resolution: "@esbuild/linux-arm@npm:0.17.14" @@ -3221,13 +3275,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-ia32@npm:0.16.17": - version: 0.16.17 - resolution: "@esbuild/linux-ia32@npm:0.16.17" - conditions: os=linux & cpu=ia32 - languageName: node - linkType: hard - "@esbuild/linux-ia32@npm:0.17.14": version: 0.17.14 resolution: "@esbuild/linux-ia32@npm:0.17.14" @@ -3249,13 +3296,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-loong64@npm:0.16.17": - version: 0.16.17 - resolution: "@esbuild/linux-loong64@npm:0.16.17" - conditions: os=linux & cpu=loong64 - languageName: node - linkType: hard - "@esbuild/linux-loong64@npm:0.17.14": version: 0.17.14 resolution: "@esbuild/linux-loong64@npm:0.17.14" @@ -3277,13 +3317,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-mips64el@npm:0.16.17": - version: 0.16.17 - resolution: "@esbuild/linux-mips64el@npm:0.16.17" - conditions: os=linux & cpu=mips64el - languageName: node - linkType: hard - "@esbuild/linux-mips64el@npm:0.17.14": version: 0.17.14 resolution: "@esbuild/linux-mips64el@npm:0.17.14" @@ -3305,13 +3338,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-ppc64@npm:0.16.17": - version: 0.16.17 - resolution: "@esbuild/linux-ppc64@npm:0.16.17" - conditions: os=linux & cpu=ppc64 - languageName: node - linkType: hard - "@esbuild/linux-ppc64@npm:0.17.14": version: 0.17.14 resolution: "@esbuild/linux-ppc64@npm:0.17.14" @@ -3333,13 +3359,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-riscv64@npm:0.16.17": - version: 0.16.17 - resolution: "@esbuild/linux-riscv64@npm:0.16.17" - conditions: os=linux & cpu=riscv64 - languageName: node - linkType: hard - "@esbuild/linux-riscv64@npm:0.17.14": version: 0.17.14 resolution: "@esbuild/linux-riscv64@npm:0.17.14" @@ -3361,13 +3380,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-s390x@npm:0.16.17": - version: 0.16.17 - resolution: "@esbuild/linux-s390x@npm:0.16.17" - conditions: os=linux & cpu=s390x - languageName: node - linkType: hard - "@esbuild/linux-s390x@npm:0.17.14": version: 0.17.14 resolution: "@esbuild/linux-s390x@npm:0.17.14" @@ -3389,13 +3401,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-x64@npm:0.16.17": - version: 0.16.17 - resolution: "@esbuild/linux-x64@npm:0.16.17" - conditions: os=linux & cpu=x64 - languageName: node - linkType: hard - "@esbuild/linux-x64@npm:0.17.14": version: 0.17.14 resolution: "@esbuild/linux-x64@npm:0.17.14" @@ -3417,13 +3422,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/netbsd-x64@npm:0.16.17": - version: 0.16.17 - resolution: "@esbuild/netbsd-x64@npm:0.16.17" - conditions: os=netbsd & cpu=x64 - languageName: node - linkType: hard - "@esbuild/netbsd-x64@npm:0.17.14": version: 0.17.14 resolution: "@esbuild/netbsd-x64@npm:0.17.14" @@ -3445,13 +3443,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/openbsd-x64@npm:0.16.17": - version: 0.16.17 - resolution: "@esbuild/openbsd-x64@npm:0.16.17" - conditions: os=openbsd & cpu=x64 - languageName: node - linkType: hard - "@esbuild/openbsd-x64@npm:0.17.14": version: 0.17.14 resolution: "@esbuild/openbsd-x64@npm:0.17.14" @@ -3473,13 +3464,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/sunos-x64@npm:0.16.17": - version: 0.16.17 - resolution: "@esbuild/sunos-x64@npm:0.16.17" - conditions: os=sunos & cpu=x64 - languageName: node - linkType: hard - "@esbuild/sunos-x64@npm:0.17.14": version: 0.17.14 resolution: "@esbuild/sunos-x64@npm:0.17.14" @@ -3501,13 +3485,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/win32-arm64@npm:0.16.17": - version: 0.16.17 - resolution: "@esbuild/win32-arm64@npm:0.16.17" - conditions: os=win32 & cpu=arm64 - languageName: node - linkType: hard - "@esbuild/win32-arm64@npm:0.17.14": version: 0.17.14 resolution: "@esbuild/win32-arm64@npm:0.17.14" @@ -3529,13 +3506,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/win32-ia32@npm:0.16.17": - version: 0.16.17 - resolution: "@esbuild/win32-ia32@npm:0.16.17" - conditions: os=win32 & cpu=ia32 - languageName: node - linkType: hard - "@esbuild/win32-ia32@npm:0.17.14": version: 0.17.14 resolution: "@esbuild/win32-ia32@npm:0.17.14" @@ -3557,13 +3527,6 @@ __metadata: languageName: node linkType: hard -"@esbuild/win32-x64@npm:0.16.17": - version: 0.16.17 - resolution: "@esbuild/win32-x64@npm:0.16.17" - conditions: os=win32 & cpu=x64 - languageName: node - linkType: hard - "@esbuild/win32-x64@npm:0.17.14": version: 0.17.14 resolution: "@esbuild/win32-x64@npm:0.17.14" @@ -4211,6 +4174,17 @@ __metadata: languageName: node linkType: hard +"@jridgewell/gen-mapping@npm:^0.3.0": + version: 0.3.3 + resolution: "@jridgewell/gen-mapping@npm:0.3.3" + dependencies: + "@jridgewell/set-array": ^1.0.1 + "@jridgewell/sourcemap-codec": ^1.4.10 + "@jridgewell/trace-mapping": ^0.3.9 + checksum: 4a74944bd31f22354fc01c3da32e83c19e519e3bbadafa114f6da4522ea77dd0c2842607e923a591d60a76699d819a2fbb6f3552e277efdb9b58b081390b60ab + languageName: node + linkType: hard + "@jridgewell/gen-mapping@npm:^0.3.2": version: 0.3.2 resolution: "@jridgewell/gen-mapping@npm:0.3.2" @@ -4475,6 +4449,13 @@ __metadata: languageName: node linkType: hard +"@noble/hashes@npm:1.1.2": + version: 1.1.2 + resolution: "@noble/hashes@npm:1.1.2" + checksum: 3c2a8cb7c2e053811032f242155d870c5eb98844d924d69702244d48804cb03b42d4a666c49c2b71164420d8229cb9a6f242b972d50d5bb2f1d673b98b041de2 + languageName: node + linkType: hard + "@noble/hashes@npm:^1.1.2": version: 1.2.0 resolution: "@noble/hashes@npm:1.2.0" @@ -4482,7 +4463,7 @@ __metadata: languageName: node linkType: hard -"@noble/secp256k1@npm:^1.5.2, @noble/secp256k1@npm:^1.6.3": +"@noble/secp256k1@npm:1.7.1, @noble/secp256k1@npm:^1.5.2, @noble/secp256k1@npm:^1.6.3, @noble/secp256k1@npm:^1.7.1": version: 1.7.1 resolution: "@noble/secp256k1@npm:1.7.1" checksum: d2301f1f7690368d8409a3152450458f27e54df47e3f917292de3de82c298770890c2de7c967d237eff9c95b70af485389a9695f73eb05a43e2bd562d18b18cb @@ -5899,6 +5880,36 @@ __metadata: languageName: node linkType: hard +"@testing-library/jest-dom@npm:^6.0.0": + version: 6.0.0 + resolution: "@testing-library/jest-dom@npm:6.0.0" + dependencies: + "@adobe/css-tools": ^4.0.1 + "@babel/runtime": ^7.9.2 + aria-query: ^5.0.0 + chalk: ^3.0.0 + css.escape: ^1.5.1 + dom-accessibility-api: ^0.5.6 + lodash: ^4.17.15 + redent: ^3.0.0 + peerDependencies: + "@jest/globals": ">= 28" + "@types/jest": ">= 28" + jest: ">= 28" + vitest: ">= 0.32" + peerDependenciesMeta: + "@jest/globals": + optional: true + "@types/jest": + optional: true + jest: + optional: true + vitest: + optional: true + checksum: 9b978e993f084553f8e39215b08f6e09e84a4c78e578db35d361bb4259aed84418f1b17ce52f23ead4d81cc7cad5ae5b18a6596d6d531a6d2252be66409e7167 + languageName: node + linkType: hard + "@testing-library/react@npm:^14.0.0": version: 14.0.0 resolution: "@testing-library/react@npm:14.0.0" @@ -6246,6 +6257,13 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:18.15.13": + version: 18.15.13 + resolution: "@types/node@npm:18.15.13" + checksum: 79cc5a2b5f98e8973061a4260a781425efd39161a0e117a69cd089603964816c1a14025e1387b4590c8e82d05133b7b4154fa53a7dffb3877890a66145e76515 + languageName: node + linkType: hard + "@types/node@npm:^12.12.54, @types/node@npm:^12.7.1": version: 12.20.55 resolution: "@types/node@npm:12.20.55" @@ -6398,6 +6416,13 @@ __metadata: languageName: node linkType: hard +"@types/uuid@npm:^9.0.2": + version: 9.0.2 + resolution: "@types/uuid@npm:9.0.2" + checksum: 1754bcf3444e1e3aeadd6e774fc328eb53bc956665e2e8fb6ec127aa8e1f43d9a224c3d22a9a6233dca8dd81a12dc7fed4d84b8876dd5ec82d40f574f7ff8b68 + languageName: node + linkType: hard + "@types/ws@npm:^7.4.4": version: 7.4.7 resolution: "@types/ws@npm:7.4.7" @@ -6737,70 +6762,91 @@ __metadata: languageName: node linkType: hard -"@vitejs/plugin-react@npm:^4.0.2": - version: 4.0.2 - resolution: "@vitejs/plugin-react@npm:4.0.2" +"@vitejs/plugin-react@npm:^4.0.4": + version: 4.0.4 + resolution: "@vitejs/plugin-react@npm:4.0.4" dependencies: - "@babel/core": ^7.22.5 + "@babel/core": ^7.22.9 "@babel/plugin-transform-react-jsx-self": ^7.22.5 "@babel/plugin-transform-react-jsx-source": ^7.22.5 react-refresh: ^0.14.0 peerDependencies: vite: ^4.2.0 - checksum: 07eeb28ad0f60dc6289367e64a58cd6d0b0d856a227ef6ea17e7af3f548d7940707ec56ad296f2c34cb7aab2fc4d76473d1a05c148277136ce0bfe294ce13bf4 + checksum: ec25400dc7c5fce914122d1f57de0fbaff9216addb8cd6187308ad2c7a3d3b73ea3a6f2dd0a8c7ec5e90e56b37046fe90d3e0ec285a9446e73695cb174377f84 + languageName: node + linkType: hard + +"@vitest/coverage-v8@npm:^0.34.1": + version: 0.34.1 + resolution: "@vitest/coverage-v8@npm:0.34.1" + dependencies: + "@ampproject/remapping": ^2.2.1 + "@bcoe/v8-coverage": ^0.2.3 + istanbul-lib-coverage: ^3.2.0 + istanbul-lib-report: ^3.0.1 + istanbul-lib-source-maps: ^4.0.1 + istanbul-reports: ^3.1.5 + magic-string: ^0.30.1 + picocolors: ^1.0.0 + std-env: ^3.3.3 + test-exclude: ^6.0.0 + v8-to-istanbul: ^9.1.0 + peerDependencies: + vitest: ">=0.32.0 <1" + checksum: 0ba13c758c095991d533de5340a60242c074f456be1abfa3f9c583106de9fd1af1e0abe66aa473c1df42b0cf7332778d67ed60130f8aa93f9dc9ea62638cf19a languageName: node linkType: hard -"@vitest/expect@npm:0.33.0": - version: 0.33.0 - resolution: "@vitest/expect@npm:0.33.0" +"@vitest/expect@npm:0.34.1": + version: 0.34.1 + resolution: "@vitest/expect@npm:0.34.1" dependencies: - "@vitest/spy": 0.33.0 - "@vitest/utils": 0.33.0 + "@vitest/spy": 0.34.1 + "@vitest/utils": 0.34.1 chai: ^4.3.7 - checksum: da6bf8e4a4f23218088b4e7dcdf6eb9f8d92e82a98a674edf8be2f333625179da6802936a948e7a60e0918da53e7ec548183d1d9d42f0e1c4e2d3f66fd63e11f + checksum: a2bc76f9242a05987983c6c6ad24091fb34282b0704b844e31d94d4ee2564fbd5e566a1ea8344240770dc8ae619a532e316155785d0ff6bee5e57be6c3e3d028 languageName: node linkType: hard -"@vitest/runner@npm:0.33.0": - version: 0.33.0 - resolution: "@vitest/runner@npm:0.33.0" +"@vitest/runner@npm:0.34.1": + version: 0.34.1 + resolution: "@vitest/runner@npm:0.34.1" dependencies: - "@vitest/utils": 0.33.0 + "@vitest/utils": 0.34.1 p-limit: ^4.0.0 pathe: ^1.1.1 - checksum: de731aa0687cf15f141e81fb11027ff52860292f6d8957678c9fcd307502e4f9fd679bcaff93b53d29eeeb694d403d6aa52d49d341f998ec2b794e7abe061572 + checksum: c8108c8f8eb75c9995422689b0c7da6a4793425a673d32d6ce7df99f84be8c2037f0acc46c6f8b55d9bd90a864ff7c5dce2ddc3656b41888b125b9311ae20559 languageName: node linkType: hard -"@vitest/snapshot@npm:0.33.0": - version: 0.33.0 - resolution: "@vitest/snapshot@npm:0.33.0" +"@vitest/snapshot@npm:0.34.1": + version: 0.34.1 + resolution: "@vitest/snapshot@npm:0.34.1" dependencies: magic-string: ^0.30.1 pathe: ^1.1.1 pretty-format: ^29.5.0 - checksum: ff2604d5bf09342eab45109df06f4e2e9e78698bf26b0eed1f4871d7757312e43de90ead938698be3e03e9873d4081ebeb69c94928b8065c53d1e9f28742185e + checksum: 5f98d38ecdefd899628d253e3283f42f035fd013dcb2084e8060ebfc73884ab6071f5510ff8c75e8af726e3a41901f2a04bafa72786626f9be31f999f7e14a4f languageName: node linkType: hard -"@vitest/spy@npm:0.33.0": - version: 0.33.0 - resolution: "@vitest/spy@npm:0.33.0" +"@vitest/spy@npm:0.34.1": + version: 0.34.1 + resolution: "@vitest/spy@npm:0.34.1" dependencies: tinyspy: ^2.1.1 - checksum: 501a704a10b411f407fbcedeaf1f469e6fcac4894af11fa89c74e6f64bf3eebbcd006cf86377ae379708c0b8c860243db504f5d4e90d382419aa666458b76800 + checksum: 7a3f676096fdf201cb057588cfe3ea1199beb29b50581593c2a9c37be0a7d8b11b0986eeec4f67e358a1b8144b1675154ec5f29b339791f97bc5656fc39d8791 languageName: node linkType: hard -"@vitest/utils@npm:0.33.0": - version: 0.33.0 - resolution: "@vitest/utils@npm:0.33.0" +"@vitest/utils@npm:0.34.1": + version: 0.34.1 + resolution: "@vitest/utils@npm:0.34.1" dependencies: diff-sequences: ^29.4.3 loupe: ^2.3.6 pretty-format: ^29.5.0 - checksum: 8c5b381f5599ca517bedd0e46805e91b1150564473d37b2b80ef45aa9c16cb59d296513dd34bc2171904beb28be73b89e5333056539d49a0ba9d513ae7672a0a + checksum: 0015504f3af725ef84f9759f08bc051071d29b0024d6bbd27276450cdb9dccde367bb86cfede2ccfef803965f29f0ffb76104e92bf569169f87e0e74e5a720f1 languageName: node linkType: hard @@ -7356,6 +7402,54 @@ __metadata: languageName: node linkType: hard +"@xmtp/content-type-reaction@npm:^1.0.1": + version: 1.0.1 + resolution: "@xmtp/content-type-reaction@npm:1.0.1" + dependencies: + "@xmtp/xmtp-js": ^9.1.7 + peerDependencies: + "@xmtp/xmtp-js": ^9.1.7 + checksum: 2318f79a7906a89fb75fb7e0db4588d78e604acda634cc219119afa566fb2fadc182ee1fcfa6b63f3c42b90dc07991fe3fde37b97758246bfeb934bf00fc9f72 + languageName: node + linkType: hard + +"@xmtp/content-type-read-receipt@npm:^1.0.0": + version: 1.0.0 + resolution: "@xmtp/content-type-read-receipt@npm:1.0.0" + dependencies: + "@xmtp/proto": ^3.26.0 + "@xmtp/xmtp-js": ^9.2.0 + peerDependencies: + "@xmtp/xmtp-js": ^9.2.0 + checksum: 7c1935d6ca11f742ec9451cb9b3c621c8792fd45858bff27e9c4270adcb3eb62f049f3bba1eeb6ee9e17c36af6e65a0aea50f025d849412689f3902623758071 + languageName: node + linkType: hard + +"@xmtp/content-type-remote-attachment@npm:^1.0.7": + version: 1.0.7 + resolution: "@xmtp/content-type-remote-attachment@npm:1.0.7" + dependencies: + "@noble/secp256k1": ^1.7.1 + "@xmtp/proto": ^3.25.0 + "@xmtp/xmtp-js": ^9.1.6 + peerDependencies: + "@xmtp/xmtp-js": ^9.1.6 + checksum: ab7638f0634754f849be9162d16ae819f6a329418727a63b807d492f65a6c9c37c062e9c8cb0bd6aedf34960bd188a091f64bb1332c489f40f525e2dde627df2 + languageName: node + linkType: hard + +"@xmtp/content-type-reply@npm:^1.0.0": + version: 1.0.0 + resolution: "@xmtp/content-type-reply@npm:1.0.0" + dependencies: + "@xmtp/proto": ^3.26.0 + "@xmtp/xmtp-js": ^9.2.0 + peerDependencies: + "@xmtp/xmtp-js": ^9.2.0 + checksum: 7f204eab598d55a755d88328cd957aa713dad7281437ced0113a29801f67d759e8d2ac1852e69874b7ab72bc9f29e2a10a52cf105ae1a8aab5126ad0ce0a0758 + languageName: node + linkType: hard + "@xmtp/proto@npm:^3.24.0": version: 3.25.0 resolution: "@xmtp/proto@npm:3.25.0" @@ -7368,6 +7462,18 @@ __metadata: languageName: node linkType: hard +"@xmtp/proto@npm:^3.25.0, @xmtp/proto@npm:^3.26.0": + version: 3.26.0 + resolution: "@xmtp/proto@npm:3.26.0" + dependencies: + long: ^5.2.0 + protobufjs: ^7.0.0 + rxjs: ^7.8.0 + undici: ^5.8.1 + checksum: 2d29c5fdcaa3332577fac20a65a7ca837e937b8aab5c6bb2f20a198e5d07c9d76b534676fcc98b5375ef00f5f9f54f858d55524b3da40b8fe1db4225e1c4fdd7 + languageName: node + linkType: hard + "@xmtp/react-components@workspace:*, @xmtp/react-components@workspace:packages/react-components": version: 0.0.0-use.local resolution: "@xmtp/react-components@workspace:packages/react-components" @@ -7385,7 +7491,10 @@ __metadata: "@testing-library/react": ^14.0.0 "@types/react": ^18.2.14 "@types/react-dom": ^18.2.6 - "@vitejs/plugin-react": ^4.0.2 + "@vitejs/plugin-react": ^4.0.4 + "@xmtp/content-type-reaction": ^1.0.1 + "@xmtp/content-type-remote-attachment": ^1.0.7 + "@xmtp/content-type-reply": ^1.0.0 "@xmtp/react-sdk": "workspace:*" "@xmtp/tsconfig": "workspace:*" date-fns: ^2.30.0 @@ -7404,8 +7513,8 @@ __metadata: storybook: ^7.0.26 tsup: ^7.1.0 typescript: ^5.1.6 - vite: ^4.4.1 - vitest: ^0.33.0 + vite: ^4.4.9 + vitest: ^0.34.1 peerDependencies: react: ">=16.14" languageName: unknown @@ -7419,7 +7528,8 @@ __metadata: "@rainbow-me/rainbowkit": ^0.12.16 "@types/react": ^18.2.14 "@types/react-dom": ^18.2.6 - "@vitejs/plugin-react": ^4.0.2 + "@vitejs/plugin-react": ^4.0.4 + "@xmtp/content-type-remote-attachment": ^1.0.7 "@xmtp/react-components": "workspace:*" "@xmtp/react-sdk": "workspace:*" "@xmtp/tsconfig": "workspace:*" @@ -7432,7 +7542,7 @@ __metadata: react: ^18.2.0 react-dom: ^18.2.0 typescript: ^5.1.6 - vite: ^4.4.1 + vite: ^4.4.9 wagmi: ^0.12.18 languageName: unknown linkType: soft @@ -7441,26 +7551,39 @@ __metadata: version: 0.0.0-use.local resolution: "@xmtp/react-sdk@workspace:packages/react-sdk" dependencies: + "@testing-library/jest-dom": ^6.0.0 "@testing-library/react": ^14.0.0 "@types/react": ^18.2.14 "@types/react-dom": ^18.2.6 - "@vitejs/plugin-react": ^4.0.2 + "@types/uuid": ^9.0.2 + "@vitejs/plugin-react": ^4.0.4 + "@vitest/coverage-v8": ^0.34.1 + "@xmtp/content-type-reaction": ^1.0.1 + "@xmtp/content-type-read-receipt": ^1.0.0 + "@xmtp/content-type-remote-attachment": ^1.0.7 + "@xmtp/content-type-reply": ^1.0.0 "@xmtp/tsconfig": "workspace:*" - "@xmtp/xmtp-js": ^9.2.0 + "@xmtp/xmtp-js": ^10.2.0 + async-mutex: ^0.4.0 date-fns: ^2.30.0 dexie: ^3.2.4 + dexie-react-hooks: ^1.1.6 eslint: ^8.44.0 eslint-config-xmtp-web: "workspace:*" + ethers: ^6.7.0 + fake-indexeddb: ^4.0.2 jsdom: ^21.1.2 prettier: ^3.0.0 react: ^18.2.0 react-dom: ^18.2.0 tsup: ^7.1.0 typescript: ^5.1.6 - vite: ^4.4.1 - vitest: ^0.33.0 + uuid: ^9.0.0 + vite: ^4.4.9 + vite-tsconfig-paths: ^4.2.0 + vitest: ^0.34.1 peerDependencies: - "@xmtp/xmtp-js": ^9.1.6 + "@xmtp/xmtp-js": ^10.1.0 react: ">=16.14" languageName: unknown linkType: soft @@ -7471,9 +7594,9 @@ __metadata: languageName: unknown linkType: soft -"@xmtp/xmtp-js@npm:^9.2.0": - version: 9.2.0 - resolution: "@xmtp/xmtp-js@npm:9.2.0" +"@xmtp/xmtp-js@npm:^10.2.0": + version: 10.2.0 + resolution: "@xmtp/xmtp-js@npm:10.2.0" dependencies: "@noble/secp256k1": ^1.5.2 "@xmtp/proto": ^3.24.0 @@ -7481,7 +7604,7 @@ __metadata: elliptic: ^6.5.4 ethers: ^5.5.3 long: ^5.2.0 - checksum: a2f1c4061bd2c5325bd0ebcc59c0b72b30995f04f4838ea5caa3901f46f616862b26f61a1b9a21cb5933d0ad6a8ca56fde86df4256678aa7a6f1309caf04d42d + checksum: 116a34b4232c817494f292082d24c5ab84ec4536203b950e7cc079a4acbe5b43da594a6e52bffacab51cea3a4b00ac006dec9f32aba69919acf5098c5b22cf2a languageName: node linkType: hard @@ -7619,6 +7742,13 @@ __metadata: languageName: node linkType: hard +"aes-js@npm:4.0.0-beta.5": + version: 4.0.0-beta.5 + resolution: "aes-js@npm:4.0.0-beta.5" + checksum: cc2ea969d77df939c32057f7e361b6530aa6cb93cb10617a17a45cd164e6d761002f031ff6330af3e67e58b1f0a3a8fd0b63a720afd591a653b02f649470e15b + languageName: node + linkType: hard + "aes-js@npm:^3.1.2": version: 3.1.2 resolution: "aes-js@npm:3.1.2" @@ -8082,6 +8212,13 @@ __metadata: languageName: node linkType: hard +"base64-arraybuffer-es6@npm:^0.7.0": + version: 0.7.0 + resolution: "base64-arraybuffer-es6@npm:0.7.0" + checksum: 6d2fd114df49201b476cea5d470504e5d4e8c4cd42544152b312c9bdcb824313086fe83f1ffc34262e9e276b82d46aefc6e63bb85553f016932061137b355cdf + languageName: node + linkType: hard + "base64-js@npm:^1.3.1": version: 1.5.1 resolution: "base64-js@npm:1.5.1" @@ -8512,24 +8649,10 @@ __metadata: languageName: node linkType: hard -"caniuse-lite@npm:^1.0.30001449": - version: 1.0.30001458 - resolution: "caniuse-lite@npm:1.0.30001458" - checksum: 258cc5a25babbbfe483bf788c6f321a19400ff80b2bf156b360bac09a6f9f4da44516350d187a30395667cb142c682d9ea96577ea0df236d35f76234b07ccb41 - languageName: node - linkType: hard - -"caniuse-lite@npm:^1.0.30001464": - version: 1.0.30001465 - resolution: "caniuse-lite@npm:1.0.30001465" - checksum: c991ecdfff378a22b268f9b1eb732d003c8ad89db3241a4cdec3b3ec3354aa966a44171cb806c90abe2e3f0573d67dc29a7dce2478b1f070b23747c392244c5d - languageName: node - linkType: hard - -"caniuse-lite@npm:^1.0.30001503": - version: 1.0.30001506 - resolution: "caniuse-lite@npm:1.0.30001506" - checksum: 0a090745824622df146e2f6dde79c7f7920a899dec1b3a599d2ef9acf41cef5e179fd133bb59f2030286a4ea935f4230e05438d7394694c414e8ada345eb5268 +"caniuse-lite@npm:^1.0.30001449, caniuse-lite@npm:^1.0.30001464, caniuse-lite@npm:^1.0.30001503": + version: 1.0.30001521 + resolution: "caniuse-lite@npm:1.0.30001521" + checksum: be2a2b2cd3be03401887aaa31b89f3e7c6230289e6ef704e224268389cc136480fca502ac9e5001a65ff1e50459d3d95f8c4b2d39f878ab9843af3d6f372c8bb languageName: node linkType: hard @@ -8548,7 +8671,7 @@ __metadata: languageName: node linkType: hard -"chalk@npm:^2.0.0, chalk@npm:^2.1.0": +"chalk@npm:^2.0.0, chalk@npm:^2.1.0, chalk@npm:^2.4.2": version: 2.4.2 resolution: "chalk@npm:2.4.2" dependencies: @@ -8559,6 +8682,16 @@ __metadata: languageName: node linkType: hard +"chalk@npm:^3.0.0": + version: 3.0.0 + resolution: "chalk@npm:3.0.0" + dependencies: + ansi-styles: ^4.1.0 + supports-color: ^7.1.0 + checksum: 8e3ddf3981c4da405ddbd7d9c8d91944ddf6e33d6837756979f7840a29272a69a5189ecae0ff84006750d6d1e92368d413335eab4db5476db6e6703a1d1e0505 + languageName: node + linkType: hard + "chalk@npm:^4.0.0, chalk@npm:^4.0.2, chalk@npm:^4.1.0, chalk@npm:^4.1.1, chalk@npm:^4.1.2": version: 4.1.2 resolution: "chalk@npm:4.1.2" @@ -9013,6 +9146,13 @@ __metadata: languageName: node linkType: hard +"css.escape@npm:^1.5.1": + version: 1.5.1 + resolution: "css.escape@npm:1.5.1" + checksum: f6d38088d870a961794a2580b2b2af1027731bb43261cfdce14f19238a88664b351cc8978abc20f06cc6bbde725699dec8deb6fe9816b139fc3f2af28719e774 + languageName: node + linkType: hard + "cssdb@npm:^7.6.0": version: 7.6.0 resolution: "cssdb@npm:7.6.0" @@ -9379,6 +9519,17 @@ __metadata: languageName: node linkType: hard +"dexie-react-hooks@npm:^1.1.6": + version: 1.1.6 + resolution: "dexie-react-hooks@npm:1.1.6" + peerDependencies: + "@types/react": ">=16" + dexie: ^3.2 || ^4.0.1-alpha + react: ">=16" + checksum: 64beabf2929327b14963b92002a395237be081dfa2c1cf1d1884d196950fbee00457960ec3ec866de814433a7729000722d1355352810fb9b5f863a04236a8e3 + languageName: node + linkType: hard + "dexie@npm:^3.2.4": version: 3.2.4 resolution: "dexie@npm:3.2.4" @@ -9427,13 +9578,22 @@ __metadata: languageName: node linkType: hard -"dom-accessibility-api@npm:^0.5.9": +"dom-accessibility-api@npm:^0.5.6, dom-accessibility-api@npm:^0.5.9": version: 0.5.16 resolution: "dom-accessibility-api@npm:0.5.16" checksum: 005eb283caef57fc1adec4d5df4dd49189b628f2f575af45decb210e04d634459e3f1ee64f18b41e2dcf200c844bc1d9279d80807e686a30d69a4756151ad248 languageName: node linkType: hard +"domexception@npm:^1.0.1": + version: 1.0.1 + resolution: "domexception@npm:1.0.1" + dependencies: + webidl-conversions: ^4.0.2 + checksum: f564a9c0915dcb83ceefea49df14aaed106b1468fbe505119e8bcb0b77e242534f3aba861978537c0fc9dc6f35b176d0ffc77b3e342820fb27a8f215e7ae4d52 + languageName: node + linkType: hard + "domexception@npm:^4.0.0": version: 4.0.0 resolution: "domexception@npm:4.0.0" @@ -9766,83 +9926,6 @@ __metadata: languageName: node linkType: hard -"esbuild@npm:^0.16.14": - version: 0.16.17 - resolution: "esbuild@npm:0.16.17" - dependencies: - "@esbuild/android-arm": 0.16.17 - "@esbuild/android-arm64": 0.16.17 - "@esbuild/android-x64": 0.16.17 - "@esbuild/darwin-arm64": 0.16.17 - "@esbuild/darwin-x64": 0.16.17 - "@esbuild/freebsd-arm64": 0.16.17 - "@esbuild/freebsd-x64": 0.16.17 - "@esbuild/linux-arm": 0.16.17 - "@esbuild/linux-arm64": 0.16.17 - "@esbuild/linux-ia32": 0.16.17 - "@esbuild/linux-loong64": 0.16.17 - "@esbuild/linux-mips64el": 0.16.17 - "@esbuild/linux-ppc64": 0.16.17 - "@esbuild/linux-riscv64": 0.16.17 - "@esbuild/linux-s390x": 0.16.17 - "@esbuild/linux-x64": 0.16.17 - "@esbuild/netbsd-x64": 0.16.17 - "@esbuild/openbsd-x64": 0.16.17 - "@esbuild/sunos-x64": 0.16.17 - "@esbuild/win32-arm64": 0.16.17 - "@esbuild/win32-ia32": 0.16.17 - "@esbuild/win32-x64": 0.16.17 - dependenciesMeta: - "@esbuild/android-arm": - optional: true - "@esbuild/android-arm64": - optional: true - "@esbuild/android-x64": - optional: true - "@esbuild/darwin-arm64": - optional: true - "@esbuild/darwin-x64": - optional: true - "@esbuild/freebsd-arm64": - optional: true - "@esbuild/freebsd-x64": - optional: true - "@esbuild/linux-arm": - optional: true - "@esbuild/linux-arm64": - optional: true - "@esbuild/linux-ia32": - optional: true - "@esbuild/linux-loong64": - optional: true - "@esbuild/linux-mips64el": - optional: true - "@esbuild/linux-ppc64": - optional: true - "@esbuild/linux-riscv64": - optional: true - "@esbuild/linux-s390x": - optional: true - "@esbuild/linux-x64": - optional: true - "@esbuild/netbsd-x64": - optional: true - "@esbuild/openbsd-x64": - optional: true - "@esbuild/sunos-x64": - optional: true - "@esbuild/win32-arm64": - optional: true - "@esbuild/win32-ia32": - optional: true - "@esbuild/win32-x64": - optional: true - bin: - esbuild: bin/esbuild - checksum: 4c2cc609ecfb426554bc3f75beb92d89eb2d0c515cfceebaa36c7599d7dcaab7056b70f6d6b51e72b45951ddf9021ee28e356cf205f8e42cc055d522312ea30c - languageName: node - linkType: hard - "esbuild@npm:^0.17.0": version: 0.17.14 resolution: "esbuild@npm:0.17.14" @@ -10648,6 +10731,21 @@ __metadata: languageName: node linkType: hard +"ethers@npm:^6.7.0": + version: 6.7.0 + resolution: "ethers@npm:6.7.0" + dependencies: + "@adraffy/ens-normalize": 1.9.2 + "@noble/hashes": 1.1.2 + "@noble/secp256k1": 1.7.1 + "@types/node": 18.15.13 + aes-js: 4.0.0-beta.5 + tslib: 2.4.0 + ws: 8.5.0 + checksum: 6d2ea085010da1c34750ce4a00a94d2abbeb3ababb23aa754acc7772682f145e8c1b3862d3b5374e1c182aee569c80ea48191107d469a4de3b0dd6af3d9ce6db + languageName: node + linkType: hard + "eventemitter3@npm:^4.0.7": version: 4.0.7 resolution: "eventemitter3@npm:4.0.7" @@ -10764,6 +10862,15 @@ __metadata: languageName: node linkType: hard +"fake-indexeddb@npm:^4.0.2": + version: 4.0.2 + resolution: "fake-indexeddb@npm:4.0.2" + dependencies: + realistic-structured-clone: ^3.0.0 + checksum: ad257e5cc6273d9ea667b4129d8b0798830d56a68c7c77ddba752b3fd0088cb33e34b9569b218d16dfc8a78733d360d325f89c2ce352b70a0587c1f46f6f881d + languageName: node + linkType: hard + "fast-deep-equal@npm:^3.1.1, fast-deep-equal@npm:^3.1.3": version: 3.1.3 resolution: "fast-deep-equal@npm:3.1.3" @@ -11437,6 +11544,13 @@ __metadata: languageName: node linkType: hard +"globrex@npm:^0.1.2": + version: 0.1.2 + resolution: "globrex@npm:0.1.2" + checksum: adca162494a176ce9ecf4dd232f7b802956bb1966b37f60c15e49d2e7d961b66c60826366dc2649093cad5a0d69970cfa8875bd1695b5a1a2f33dcd2aa88da3c + languageName: node + linkType: hard + "gopd@npm:^1.0.1": version: 1.0.1 resolution: "gopd@npm:1.0.1" @@ -12314,6 +12428,28 @@ __metadata: languageName: node linkType: hard +"istanbul-lib-report@npm:^3.0.1": + version: 3.0.1 + resolution: "istanbul-lib-report@npm:3.0.1" + dependencies: + istanbul-lib-coverage: ^3.0.0 + make-dir: ^4.0.0 + supports-color: ^7.1.0 + checksum: fd17a1b879e7faf9bb1dc8f80b2a16e9f5b7b8498fe6ed580a618c34df0bfe53d2abd35bf8a0a00e628fb7405462576427c7df20bbe4148d19c14b431c974b21 + languageName: node + linkType: hard + +"istanbul-lib-source-maps@npm:^4.0.1": + version: 4.0.1 + resolution: "istanbul-lib-source-maps@npm:4.0.1" + dependencies: + debug: ^4.1.1 + istanbul-lib-coverage: ^3.0.0 + source-map: ^0.6.1 + checksum: 21ad3df45db4b81852b662b8d4161f6446cd250c1ddc70ef96a585e2e85c26ed7cd9c2a396a71533cfb981d1a645508bc9618cae431e55d01a0628e7dec62ef2 + languageName: node + linkType: hard + "istanbul-reports@npm:^3.1.4": version: 3.1.5 resolution: "istanbul-reports@npm:3.1.5" @@ -12324,6 +12460,16 @@ __metadata: languageName: node linkType: hard +"istanbul-reports@npm:^3.1.5": + version: 3.1.6 + resolution: "istanbul-reports@npm:3.1.6" + dependencies: + html-escaper: ^2.0.0 + istanbul-lib-report: ^3.0.0 + checksum: 44c4c0582f287f02341e9720997f9e82c071627e1e862895745d5f52ec72c9b9f38e1d12370015d2a71dcead794f34c7732aaef3fab80a24bc617a21c3d911d6 + languageName: node + linkType: hard + "jake@npm:^10.8.5": version: 10.8.5 resolution: "jake@npm:10.8.5" @@ -12915,7 +13061,7 @@ __metadata: languageName: node linkType: hard -"lodash@npm:^4.17.15, lodash@npm:^4.17.20, lodash@npm:^4.17.21": +"lodash@npm:^4.17.15, lodash@npm:^4.17.20, lodash@npm:^4.17.21, lodash@npm:^4.7.0": version: 4.17.21 resolution: "lodash@npm:4.17.21" checksum: eb835a2e51d381e561e508ce932ea50a8e5a68f4ebdd771ea240d3048244a8d13658acbd502cd4829768c56f2e16bdd4340b9ea141297d472517b83868e677f7 @@ -13040,6 +13186,15 @@ __metadata: languageName: node linkType: hard +"make-dir@npm:^4.0.0": + version: 4.0.0 + resolution: "make-dir@npm:4.0.0" + dependencies: + semver: ^7.5.3 + checksum: bf0731a2dd3aab4db6f3de1585cea0b746bb73eb5a02e3d8d72757e376e64e6ada190b1eddcde5b2f24a81b688a9897efd5018737d05e02e2a671dda9cff8a8a + languageName: node + linkType: hard + "make-fetch-happen@npm:^10.0.3": version: 10.2.1 resolution: "make-fetch-happen@npm:10.2.1" @@ -13501,7 +13656,7 @@ __metadata: languageName: node linkType: hard -"nanoid@npm:^3.3.1, nanoid@npm:^3.3.4": +"nanoid@npm:^3.3.1": version: 3.3.4 resolution: "nanoid@npm:3.3.4" bin: @@ -14761,36 +14916,25 @@ __metadata: languageName: node linkType: hard -"postcss@npm:^8.4.21": - version: 8.4.21 - resolution: "postcss@npm:8.4.21" - dependencies: - nanoid: ^3.3.4 - picocolors: ^1.0.0 - source-map-js: ^1.0.2 - checksum: e39ac60ccd1542d4f9d93d894048aac0d686b3bb38e927d8386005718e6793dbbb46930f0a523fe382f1bbd843c6d980aaea791252bf5e176180e5a4336d9679 - languageName: node - linkType: hard - -"postcss@npm:^8.4.24": - version: 8.4.24 - resolution: "postcss@npm:8.4.24" +"postcss@npm:^8.4.25": + version: 8.4.25 + resolution: "postcss@npm:8.4.25" dependencies: nanoid: ^3.3.6 picocolors: ^1.0.0 source-map-js: ^1.0.2 - checksum: 814e2126dacfea313588eda09cc99a9b4c26ec55c059188aa7a916d20d26d483483106dc5ff9e560731b59f45c5bb91b945dfadc670aed875cc90ddbbf4e787d + checksum: 9ed3ab8af43ad5210c28f56f916fd9b8c9f94fbeaebbf645dcf579bc28bdd8056c2a7ecc934668d399b81fedb6128f0c4b299f931e50454964bc911c25a3a0a2 languageName: node linkType: hard -"postcss@npm:^8.4.25": - version: 8.4.25 - resolution: "postcss@npm:8.4.25" +"postcss@npm:^8.4.27": + version: 8.4.27 + resolution: "postcss@npm:8.4.27" dependencies: nanoid: ^3.3.6 picocolors: ^1.0.0 source-map-js: ^1.0.2 - checksum: 9ed3ab8af43ad5210c28f56f916fd9b8c9f94fbeaebbf645dcf579bc28bdd8056c2a7ecc934668d399b81fedb6128f0c4b299f931e50454964bc911c25a3a0a2 + checksum: 1cdd0c298849df6cd65f7e646a3ba36870a37b65f55fd59d1a165539c263e9b4872a402bf4ed1ca1bc31f58b68b2835545e33ea1a23b161a1f8aa6d5ded81e78 languageName: node linkType: hard @@ -15484,6 +15628,17 @@ __metadata: languageName: node linkType: hard +"realistic-structured-clone@npm:^3.0.0": + version: 3.0.0 + resolution: "realistic-structured-clone@npm:3.0.0" + dependencies: + domexception: ^1.0.1 + typeson: ^6.1.0 + typeson-registry: ^1.0.0-alpha.20 + checksum: b4521b299c8dc320a5e3ef44678f80a92b0f1837901a5fbd1c7be06808110fb0b591b417114306ec55b44ef47fd17968aacca079afc9665afbe1c528026295ec + languageName: node + linkType: hard + "recast@npm:^0.21.0": version: 0.21.5 resolution: "recast@npm:0.21.5" @@ -15551,6 +15706,13 @@ __metadata: languageName: node linkType: hard +"regenerator-runtime@npm:^0.14.0": + version: 0.14.0 + resolution: "regenerator-runtime@npm:0.14.0" + checksum: 1c977ad82a82a4412e4f639d65d22be376d3ebdd30da2c003eeafdaaacd03fc00c2320f18120007ee700900979284fc78a9f00da7fb593f6e6eeebc673fba9a3 + languageName: node + linkType: hard + "regenerator-transform@npm:^0.15.1": version: 0.15.1 resolution: "regenerator-transform@npm:0.15.1" @@ -15811,23 +15973,9 @@ __metadata: languageName: node linkType: hard -"rollup@npm:^3.10.0": - version: 3.17.3 - resolution: "rollup@npm:3.17.3" - dependencies: - fsevents: ~2.3.2 - dependenciesMeta: - fsevents: - optional: true - bin: - rollup: dist/bin/rollup - checksum: afce20a6ef4a613e5803eff7fb17a3efe740e326257b43f48bdbe10783f3eae79587d7e455234bc68f3c3154a50f2c29c85d4d2a42cdebcc17a5abeaeb04e0ed - languageName: node - linkType: hard - -"rollup@npm:^3.25.2": - version: 3.26.2 - resolution: "rollup@npm:3.26.2" +"rollup@npm:^3.27.1": + version: 3.28.0 + resolution: "rollup@npm:3.28.0" dependencies: fsevents: ~2.3.2 dependenciesMeta: @@ -15835,7 +15983,7 @@ __metadata: optional: true bin: rollup: dist/bin/rollup - checksum: 7186beeba0e6fd33b37aa33ede7157e77af0a0be4b75d345a51dab4be5962a8e7a1c5ec8868e7c4cd361b456eb1b47bdb75e5db32ab37a546bed383d06b40384 + checksum: 6ded4a0d3ca531d68e82897d5eebaa9d085014a062620bc328f2859ccf78d6a148a51ed53f1275a5f89b55cc6d7b1440b7cee44e5a9e3a51442f809b4b26f727 languageName: node linkType: hard @@ -15988,6 +16136,15 @@ __metadata: languageName: node linkType: hard +"semver@npm:^6.3.1": + version: 6.3.1 + resolution: "semver@npm:6.3.1" + bin: + semver: bin/semver.js + checksum: ae47d06de28836adb9d3e25f22a92943477371292d9b665fb023fae278d345d508ca1958232af086d85e0155aee22e313e100971898bbb8d5d89b8b1d4054ca2 + languageName: node + linkType: hard + "semver@npm:^7.0.0, semver@npm:^7.3.5, semver@npm:^7.3.7, semver@npm:^7.3.8": version: 7.3.8 resolution: "semver@npm:7.3.8" @@ -16810,10 +16967,10 @@ __metadata: languageName: node linkType: hard -"tinypool@npm:^0.6.0": - version: 0.6.0 - resolution: "tinypool@npm:0.6.0" - checksum: 996bf3a922993cec568d6b6ddc72531700b2a8aea24623ed6946a8929557b0f17629955d20defda09cb3b12fc94087159f14cb8e06570adce7d1b7d2eef00a91 +"tinypool@npm:^0.7.0": + version: 0.7.0 + resolution: "tinypool@npm:0.7.0" + checksum: fdcccd5c750574fce51f8801a877f8284e145d12b79cd5f2d72bfbddfe20c895e915555bc848e122bb6aa968098e7ac4fe1e8e88104904d518dc01cccd18a510 languageName: node linkType: hard @@ -16891,6 +17048,15 @@ __metadata: languageName: node linkType: hard +"tr46@npm:^2.1.0": + version: 2.1.0 + resolution: "tr46@npm:2.1.0" + dependencies: + punycode: ^2.1.1 + checksum: ffe6049b9dca3ae329b059aada7f515b0f0064c611b39b51ff6b53897e954650f6f63d9319c6c008d36ead477c7b55e5f64c9dc60588ddc91ff720d64eb710b3 + languageName: node + linkType: hard + "tr46@npm:^4.1.1": version: 4.1.1 resolution: "tr46@npm:4.1.1" @@ -16937,6 +17103,20 @@ __metadata: languageName: node linkType: hard +"tsconfck@npm:^2.1.0": + version: 2.1.2 + resolution: "tsconfck@npm:2.1.2" + peerDependencies: + typescript: ^4.3.5 || ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + bin: + tsconfck: bin/tsconfck.js + checksum: 6fd2f7de012a724f6b4bf48ae76cc7dae2b59dd5cad2dc50bac58d224d4ed7d5c43c6b26e55d3e00636f426f8b5373c996523d73b7830d05f8479a9b83282192 + languageName: node + linkType: hard + "tsconfig-paths@npm:^3.14.1": version: 3.14.2 resolution: "tsconfig-paths@npm:3.14.2" @@ -16956,6 +17136,13 @@ __metadata: languageName: node linkType: hard +"tslib@npm:2.4.0": + version: 2.4.0 + resolution: "tslib@npm:2.4.0" + checksum: 8c4aa6a3c5a754bf76aefc38026134180c053b7bd2f81338cb5e5ebf96fefa0f417bff221592bf801077f5bf990562f6264fecbc42cd3309b33872cb6fc3b113 + languageName: node + linkType: hard + "tslib@npm:^2.0.0, tslib@npm:^2.0.1, tslib@npm:^2.1.0, tslib@npm:^2.3.1, tslib@npm:^2.4.0": version: 2.5.0 resolution: "tslib@npm:2.5.0" @@ -17222,6 +17409,24 @@ __metadata: languageName: node linkType: hard +"typeson-registry@npm:^1.0.0-alpha.20": + version: 1.0.0-alpha.39 + resolution: "typeson-registry@npm:1.0.0-alpha.39" + dependencies: + base64-arraybuffer-es6: ^0.7.0 + typeson: ^6.0.0 + whatwg-url: ^8.4.0 + checksum: c6b629697acf4652aecfff7be760356d764600afc9beca253278bbfc44fae0fe635b7619201b83e497cdc30645cbce7614d12a04b5726d9b8b505f73e6a3fc2a + languageName: node + linkType: hard + +"typeson@npm:^6.0.0, typeson@npm:^6.1.0": + version: 6.1.0 + resolution: "typeson@npm:6.1.0" + checksum: 00a77b03ac8f704acb103307bad9295fe47d6b304c386297f078ec3be63875c0b81e022a4815edb9dc2c7da0a72a431345411d35c755a8510af4a420e9e46cdc + languageName: node + linkType: hard + "ufo@npm:^1.1.1, ufo@npm:^1.1.2": version: 1.1.2 resolution: "ufo@npm:1.1.2" @@ -17562,7 +17767,7 @@ __metadata: languageName: node linkType: hard -"v8-to-istanbul@npm:^9.0.0": +"v8-to-istanbul@npm:^9.0.0, v8-to-istanbul@npm:^9.1.0": version: 9.1.0 resolution: "v8-to-istanbul@npm:9.1.0" dependencies: @@ -17605,9 +17810,9 @@ __metadata: languageName: node linkType: hard -"vite-node@npm:0.33.0": - version: 0.33.0 - resolution: "vite-node@npm:0.33.0" +"vite-node@npm:0.34.1": + version: 0.34.1 + resolution: "vite-node@npm:0.34.1" dependencies: cac: ^6.7.14 debug: ^4.3.4 @@ -17617,56 +17822,34 @@ __metadata: vite: ^3.0.0 || ^4.0.0 bin: vite-node: vite-node.mjs - checksum: 7c37911251d3e318fe4ad6b4093207498336ce190a58afb43a9ae701eee7f110ef80920b79061710cf6abcc6335ce58f6ca412ee6b268f25fe10f278c94cc264 + checksum: 0a95034377027aebd75ee1d1ca95105e6bdbb0896a7a4b52b553a66fafa2adacd38856a5782416cf8725e8f3e9e0a1e5c02a780225822cb5ea501161fefa1482 languageName: node linkType: hard -"vite@npm:^3.0.0 || ^4.0.0": - version: 4.1.4 - resolution: "vite@npm:4.1.4" +"vite-tsconfig-paths@npm:^4.2.0": + version: 4.2.0 + resolution: "vite-tsconfig-paths@npm:4.2.0" dependencies: - esbuild: ^0.16.14 - fsevents: ~2.3.2 - postcss: ^8.4.21 - resolve: ^1.22.1 - rollup: ^3.10.0 + debug: ^4.1.1 + globrex: ^0.1.2 + tsconfck: ^2.1.0 peerDependencies: - "@types/node": ">= 14" - less: "*" - sass: "*" - stylus: "*" - sugarss: "*" - terser: ^5.4.0 - dependenciesMeta: - fsevents: - optional: true + vite: "*" peerDependenciesMeta: - "@types/node": + vite: optional: true - less: - optional: true - sass: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - bin: - vite: bin/vite.js - checksum: 50a9a1f2e29e0ee8fefdec60314d38fb9b746df0bb6ae5a8114014b5bfd95e0fc9b29c0d5e73939361ba53af7eb66c7d20c5656bbe53a783e96540bd3b907c47 + checksum: 73a8467de72d7ac502328454fd00c19571cd4bad2dd5982643b24718bb95e449a3f4153cfc2d58a358bfc8f37e592fb442fc10884b59ae82138c1329160cd952 languageName: node linkType: hard -"vite@npm:^4.4.1": - version: 4.4.1 - resolution: "vite@npm:4.4.1" +"vite@npm:^4.4.9": + version: 4.4.9 + resolution: "vite@npm:4.4.9" dependencies: esbuild: ^0.18.10 fsevents: ~2.3.2 - postcss: ^8.4.24 - rollup: ^3.25.2 + postcss: ^8.4.27 + rollup: ^3.27.1 peerDependencies: "@types/node": ">= 14" less: "*" @@ -17695,22 +17878,22 @@ __metadata: optional: true bin: vite: bin/vite.js - checksum: c91d5228cd0b2410e95ea4b17279640a414f1a2d5290a01baec77351af2dda7d5901c240ed6a62de2b465567e328168d386da2aaa262d3a138fde827b289592d + checksum: c511024ceae39c68c7dbf2ac4381ee655cd7bb62cf43867a14798bc835d3320b8fa7867a336143c30825c191c1fb4e9aa3348fce831ab617e96203080d3d2908 languageName: node linkType: hard -"vitest@npm:^0.33.0": - version: 0.33.0 - resolution: "vitest@npm:0.33.0" +"vitest@npm:^0.34.1": + version: 0.34.1 + resolution: "vitest@npm:0.34.1" dependencies: "@types/chai": ^4.3.5 "@types/chai-subset": ^1.3.3 "@types/node": "*" - "@vitest/expect": 0.33.0 - "@vitest/runner": 0.33.0 - "@vitest/snapshot": 0.33.0 - "@vitest/spy": 0.33.0 - "@vitest/utils": 0.33.0 + "@vitest/expect": 0.34.1 + "@vitest/runner": 0.34.1 + "@vitest/snapshot": 0.34.1 + "@vitest/spy": 0.34.1 + "@vitest/utils": 0.34.1 acorn: ^8.9.0 acorn-walk: ^8.2.0 cac: ^6.7.14 @@ -17723,9 +17906,9 @@ __metadata: std-env: ^3.3.3 strip-literal: ^1.0.1 tinybench: ^2.5.0 - tinypool: ^0.6.0 + tinypool: ^0.7.0 vite: ^3.0.0 || ^4.0.0 - vite-node: 0.33.0 + vite-node: 0.34.1 why-is-node-running: ^2.2.2 peerDependencies: "@edge-runtime/vm": "*" @@ -17755,7 +17938,7 @@ __metadata: optional: true bin: vitest: vitest.mjs - checksum: c1884b2a1a41af81ee54c86a986a32b6a4c69ec3b3f7d2322f92c8fad5532d6a12160e7efb7927e4c53d95806ef4ede9549bdd82c66604e281c71056212f56e7 + checksum: 39d270e78be0ce06cb348c6c1e92517aa7269ad8c51f5432349849ca1615c18eeaeb635a49d16eedcb9b77a7a19186723f906d286d819368c15d086cecacfb0d languageName: node linkType: hard @@ -17831,6 +18014,13 @@ __metadata: languageName: node linkType: hard +"webidl-conversions@npm:^6.1.0": + version: 6.1.0 + resolution: "webidl-conversions@npm:6.1.0" + checksum: 1f526507aa491f972a0c1409d07f8444e1d28778dfa269a9971f2e157182f3d496dc33296e4ed45b157fdb3bf535bb90c90bf10c50dcf1dd6caacb2a34cc84fb + languageName: node + linkType: hard + "webidl-conversions@npm:^7.0.0": version: 7.0.0 resolution: "webidl-conversions@npm:7.0.0" @@ -17899,6 +18089,17 @@ __metadata: languageName: node linkType: hard +"whatwg-url@npm:^8.4.0": + version: 8.7.0 + resolution: "whatwg-url@npm:8.7.0" + dependencies: + lodash: ^4.7.0 + tr46: ^2.1.0 + webidl-conversions: ^6.1.0 + checksum: a87abcc6cefcece5311eb642858c8fdb234e51ec74196bfacf8def2edae1bfbffdf6acb251646ed6301f8cee44262642d8769c707256125a91387e33f405dd1e + languageName: node + linkType: hard + "which-boxed-primitive@npm:^1.0.2": version: 1.0.2 resolution: "which-boxed-primitive@npm:1.0.2" @@ -18077,6 +18278,21 @@ __metadata: languageName: node linkType: hard +"ws@npm:8.5.0": + version: 8.5.0 + resolution: "ws@npm:8.5.0" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 76f2f90e40344bf18fd544194e7067812fb1372b2a37865678d8f12afe4b478ff2ebc0c7c0aff82cd5e6b66fc43d889eec0f1865c2365d8f7a66d92da7744a77 + languageName: node + linkType: hard + "ws@npm:^6.1.0": version: 6.2.2 resolution: "ws@npm:6.2.2"