diff --git a/examples/react-vite-browser-sdk/README.md b/examples/react-vite-browser-sdk/README.md new file mode 100644 index 000000000..3795dcc31 --- /dev/null +++ b/examples/react-vite-browser-sdk/README.md @@ -0,0 +1,28 @@ +# React Vite example app + +Use this React Vite example app as a tool to start building an app with XMTP. This basic messaging app has an intentionally unopinionated UI to help make it easier for you to build with. + +The app is built using the [React XMTP client SDK](/packages/react-sdk/README.md), [React](https://react.dev/), [Vite](https://vitejs.dev/), and [RainbowKit](https://www.rainbowkit.com/). + +To keep up with the latest example app developments, see the [Issues tab](https://github.com/xmtp/xmtp-web/issues) in this repo. + +To learn more about XMTP and get answers to frequently asked questions, see the [XMTP documentation](https://xmtp.org/docs). + +## Limitations + +This example app isn't a complete solution. For example, the list of conversations doesn't update when new messages arrive in existing conversations. + +## Developing + +1. In `packages/react-sdk`, run `yarn build` to build the React SDK. +2. In `examples/react-vite`, run `yarn dev` to start the development server. + +## Useful commands + +- `yarn build`: Builds the example app +- `yarn clean`: Removes `node_modules`, `dist`, and `.turbo` folders +- `yarn dev`: Launches the example app and watches for changes, which will trigger a rebuild +- `yarn format`: Runs prettier format and write changes +- `yarn format:check`: Runs prettier format check +- `yarn lint`: Runs ESLint +- `yarn typecheck`: Runs `tsc` diff --git a/examples/react-vite-browser-sdk/index.html b/examples/react-vite-browser-sdk/index.html new file mode 100644 index 000000000..31cf5d729 --- /dev/null +++ b/examples/react-vite-browser-sdk/index.html @@ -0,0 +1,12 @@ + + + + + + XMTP V3 Browser SDK Example + + +
+ + + diff --git a/examples/react-vite-browser-sdk/package.json b/examples/react-vite-browser-sdk/package.json new file mode 100644 index 000000000..102500c6b --- /dev/null +++ b/examples/react-vite-browser-sdk/package.json @@ -0,0 +1,34 @@ +{ + "name": "@xmtp/react-vite-browser-sdk-example", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "clean": "rm -rf .turbo && rm -rf node_modules && yarn clean:dist", + "clean:dist": "rm -rf dist", + "dev": "vite", + "quickstart": "yarn dev", + "typecheck": "tsc" + }, + "dependencies": { + "@rainbow-me/rainbowkit": "^2.1.3", + "@tanstack/react-query": "^5.51.1", + "@wagmi/core": "^2.11.7", + "@xmtp/browser-sdk": "workspace:*", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "viem": "^2.17.4", + "wagmi": "^2.10.10" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.39", + "postcss-preset-env": "^9.6.0", + "tsconfig": "workspace:*", + "typescript": "^5.5.3", + "vite": "^5.4.9" + } +} diff --git a/examples/react-vite-browser-sdk/postcss.config.cjs b/examples/react-vite-browser-sdk/postcss.config.cjs new file mode 100644 index 000000000..eaccdea29 --- /dev/null +++ b/examples/react-vite-browser-sdk/postcss.config.cjs @@ -0,0 +1,5 @@ +module.exports = { + plugins: { + "postcss-preset-env": {}, + }, +}; diff --git a/examples/react-vite-browser-sdk/public/xmtp-icon.png b/examples/react-vite-browser-sdk/public/xmtp-icon.png new file mode 100644 index 000000000..6c610d7d8 Binary files /dev/null and b/examples/react-vite-browser-sdk/public/xmtp-icon.png differ diff --git a/examples/react-vite-browser-sdk/src/App.tsx b/examples/react-vite-browser-sdk/src/App.tsx new file mode 100644 index 000000000..ea6e39b27 --- /dev/null +++ b/examples/react-vite-browser-sdk/src/App.tsx @@ -0,0 +1,253 @@ +import { + Conversation, + type Client, + type DecodedMessage, +} from "@xmtp/browser-sdk"; +import { useState } from "react"; +import { createClient } from "./createClient"; + +export const App = () => { + const [client, setClient] = useState(undefined); + const [conversations, setConversations] = useState([]); + const [messages, setMessages] = useState>( + new Map(), + ); + + const handleCreateClient = async () => { + setClient(await createClient("key1")); + }; + + const handleResetClient = () => { + if (client) { + client.close(); + } + setClient(undefined); + setConversations([]); + setMessages(new Map()); + }; + + const handleListGroups = async () => { + if (client) { + const groups = await client.conversations.list(); + setConversations(groups); + } + }; + + const handleUpdateGroupName = async (groupId: string, elementId: string) => { + if (client) { + const conversation = new Conversation(client, groupId); + await conversation.sync(); + const element = document.getElementById(elementId) as HTMLInputElement; + const name = element.value; + await conversation.updateName(name); + element.value = ""; + await handleListGroups(); + } + }; + + const handleUpdateGroupDescription = async ( + groupId: string, + elementId: string, + ) => { + if (client) { + const conversation = new Conversation(client, groupId); + await conversation.sync(); + const element = document.getElementById(elementId) as HTMLInputElement; + const description = element.value; + await conversation.updateDescription(description); + element.value = ""; + await handleListGroups(); + } + }; + + const handleListGroupMessages = async (groupId: string) => { + if (client) { + const conversation = new Conversation(client, groupId); + await conversation.sync(); + const groupMessages = await conversation.messages(); + setMessages((prevMessages) => { + const newMessages = new Map(prevMessages); + newMessages.set(groupId, groupMessages); + return newMessages; + }); + } + }; + + const handleSendGroupMessage = async (groupId: string, elementId: string) => { + if (client) { + const conversation = new Conversation(client, groupId); + await conversation.sync(); + const element = document.getElementById(elementId) as HTMLInputElement; + const message = element.value; + await conversation.send(message); + element.value = ""; + } + }; + + const handleCreateGroup = async () => { + if (client) { + const element = document.getElementById( + "create-group-name", + ) as HTMLInputElement; + const name = element.value; + const group = await client.conversations.newGroup([]); + await group.sync(); + await group.updateName(name); + element.value = ""; + await handleListGroups(); + } + }; + + const handleSyncGroup = async (groupId: string) => { + if (client) { + const conversation = new Conversation(client, groupId); + await conversation.sync(); + await handleListGroupMessages(groupId); + } + }; + + return ( +
+

XMTP V3

+
+ {!client && ( + + )} + {client && ( + <> + + + + )} +
+ {client && ( + <> +
+

Client details

+
+
Address:
+
{client.address}
+
+
+
Inbox ID:
+
{client.inboxId}
+
+
+
Installation ID:
+
{client.installationId}
+
+
+
+
+ + +
+
+ + )} + {conversations.length > 0 && ( +
+

Conversations

+
+ {conversations.map((conversation) => ( +
+

{conversation.id}

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
Name:
+
{conversation.name}
+
+
+
Description:
+
{conversation.description}
+
+ {messages.get(conversation.id) && ( +
+

Messages

+ {messages.get(conversation.id)?.map((message) => ( +
+
{JSON.stringify(message.content, null, 2)}
+
+ ))} +
+ )} +
+ ))} +
+
+ )} +
+ ); +}; diff --git a/examples/react-vite-browser-sdk/src/createClient.ts b/examples/react-vite-browser-sdk/src/createClient.ts new file mode 100644 index 000000000..c9b362f5b --- /dev/null +++ b/examples/react-vite-browser-sdk/src/createClient.ts @@ -0,0 +1,35 @@ +import { Client, WasmSignatureRequestType } from "@xmtp/browser-sdk"; +import { toBytes } from "viem/utils"; +import { createWallet } from "./wallets"; + +type Wallet = ReturnType; + +export const getSignature = async (client: Client, wallet: Wallet) => { + const signatureText = await client.getCreateInboxSignatureText(); + if (signatureText) { + const signature = await wallet.signMessage({ + message: signatureText, + }); + return toBytes(signature); + } + return null; +}; + +export const createClient = async (walletKey: string) => { + const wallet = createWallet(walletKey); + const client = await Client.create(wallet.account.address, { + env: "local", + }); + const isRegistered = await client.isRegistered(); + if (!isRegistered) { + const signature = await getSignature(client, wallet); + if (signature) { + await client.addSignature( + WasmSignatureRequestType.CreateInbox, + signature, + ); + } + await client.registerIdentity(); + } + return client; +}; diff --git a/examples/react-vite-browser-sdk/src/globals.d.ts b/examples/react-vite-browser-sdk/src/globals.d.ts new file mode 100644 index 000000000..5b948c9b6 --- /dev/null +++ b/examples/react-vite-browser-sdk/src/globals.d.ts @@ -0,0 +1,15 @@ +interface ImportMeta { + env: { + VITE_PROJECT_ID: string; + }; +} + +declare module "*.module.css" { + const classes: { [key: string]: string }; + export default classes; +} + +declare module "*.png" { + const src: string; + export default src; +} diff --git a/examples/react-vite-browser-sdk/src/index.css b/examples/react-vite-browser-sdk/src/index.css new file mode 100644 index 000000000..cf988d17b --- /dev/null +++ b/examples/react-vite-browser-sdk/src/index.css @@ -0,0 +1,111 @@ +:root { + font-family: "SF Pro Rounded", Inter, system-ui, Avenir, Helvetica, Arial, + sans-serif; + line-height: 1.3; + font-weight: 400; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + -webkit-text-size-adjust: 100%; +} + +body { + margin: 0; + min-width: 320px; + height: 100vh; + display: flex; + flex-direction: column; + color: #213547; + background-color: #fff; +} + +#root { + flex-grow: 1; + display: flex; + flex-direction: column; +} + +#root > [data-rk] { + flex-grow: 1; + display: flex; + flex-direction: column; +} + +.App { + width: 800px; + margin: 0 auto; + padding: 20px; +} + +.App h1 { + text-align: center; + margin-top: 0; +} + +.Actions { + display: flex; + gap: 10px; + justify-content: center; + padding: 20px; +} + +.ClientDetail { + display: flex; + gap: 10px; + justify-content: space-between; +} + +.ConversationActions { + display: flex; + gap: 10px; + justify-content: center; + align-items: center; + padding: 10px; +} + +.ConversationAction { + padding: 6px; + border: 1px solid #213547; + border-radius: 6px; + background-color: #f0f0f0; + display: flex; + gap: 2px; + flex-direction: column; +} + +.ConversationWrapper { + display: flex; + flex-direction: column; + gap: 10px; +} + +.Conversation { + border: 1px solid #213547; + border-radius: 10px; + padding: 10px; +} + +.Conversation > h3 { + margin-top: 0; +} + +.ConversationDetail { + display: flex; + gap: 10px; + justify-content: space-between; +} + +.ConversationMessages { + display: flex; + flex-direction: column; + gap: 4px; +} + +.ConversationMessage { + border: 1px solid #213547; + border-radius: 6px; + padding: 4px; + background-color: #f0f0f0; + text-wrap: pretty; +} diff --git a/examples/react-vite-browser-sdk/src/main.tsx b/examples/react-vite-browser-sdk/src/main.tsx new file mode 100644 index 000000000..c4833794e --- /dev/null +++ b/examples/react-vite-browser-sdk/src/main.tsx @@ -0,0 +1,33 @@ +import "@rainbow-me/rainbowkit/styles.css"; +import { getDefaultConfig, RainbowKitProvider } from "@rainbow-me/rainbowkit"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { http } from "@wagmi/core"; +import { mainnet } from "@wagmi/core/chains"; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { WagmiProvider } from "wagmi"; +import { App } from "./App"; +import "./index.css"; + +export const config = getDefaultConfig({ + appName: "XMTP V3 Browser SDK Example", + projectId: import.meta.env.VITE_PROJECT_ID, + chains: [mainnet], + transports: { + [mainnet.id]: http(), + }, +}); + +const queryClient = new QueryClient(); + +createRoot(document.getElementById("root") as HTMLElement).render( + + + + + + + + + , +); diff --git a/examples/react-vite-browser-sdk/src/wallets.ts b/examples/react-vite-browser-sdk/src/wallets.ts new file mode 100644 index 000000000..a2b438a7c --- /dev/null +++ b/examples/react-vite-browser-sdk/src/wallets.ts @@ -0,0 +1,31 @@ +import { + createWalletClient, + http, + type PrivateKeyAccount, + type Transport, + type WalletClient, +} from "viem"; +import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; +import { mainnet } from "viem/chains"; + +const keys: Record = { + // address: 0xb879f1d8FD73EC057c02D681880169e5721a6d7F + key1: "0xf8ced372cdb9a67bed1843650a89a59859369bf9900c0bc75741f2740e93cb04", + // address: 0x7ad9d3892D5EEC0586920D3E0ac813aaeF881488 + key2: "0xb562d61bc9fe203a639dfc0c3f875b3411fe8ae211c5722ab9124a1009bda32a", + // address: 0x73655B77df59d378d396918C3426cc5219EfB3c8 + key3: "0x724028dcbf931ff1f2730ad76c0b7b8b07dbf7f0a56408be3e305be1b81edfe0", + // address: 0x5aB557A6b8FF7D7a9A42F223fAA376A4732Eb15a + key4: "0x4420cde3d475a038739d1d47cfd690799c0f2e1b84d871c24f221c2dee4e4121", + // address: 0x38F966794cf349f2c91116e94f587Fc3aafDC3F4 + key5: "0xd34cc37587785349013f3f10cadbe7bf8dfeb8a95c86724887e58816b734fcfb", +}; + +export const createWallet = ( + key: keyof typeof keys, +): WalletClient => + createWalletClient({ + account: privateKeyToAccount(keys[key] ?? generatePrivateKey()), + chain: mainnet, + transport: http(), + }); diff --git a/examples/react-vite-browser-sdk/tsconfig.json b/examples/react-vite-browser-sdk/tsconfig.json new file mode 100644 index 000000000..ce1c7e0fd --- /dev/null +++ b/examples/react-vite-browser-sdk/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "tsconfig/react-app.json", + "include": ["src", "vite.config.ts", "postcss.config.cjs"] +} diff --git a/examples/react-vite-browser-sdk/vite.config.ts b/examples/react-vite-browser-sdk/vite.config.ts new file mode 100644 index 000000000..3d7f96fe2 --- /dev/null +++ b/examples/react-vite-browser-sdk/vite.config.ts @@ -0,0 +1,16 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], + optimizeDeps: { + exclude: ["@xmtp/wasm-bindings"], + }, + server: { + headers: { + "Cross-Origin-Embedder-Policy": "require-corp", + "Cross-Origin-Opener-Policy": "same-origin", + }, + }, +});