├── src ├── vite-env.d.ts ├── polyfills.ts ├── components │ ├── README │ ├── Header.tsx │ └── Button.tsx ├── model │ ├── README │ ├── global.d.ts │ ├── attachments.ts │ ├── db.ts │ ├── conversations.ts │ ├── message-processor.ts │ ├── reactions.ts │ └── messages.ts ├── util │ └── shortAddress.ts ├── hooks │ ├── useAttachment.tsx │ ├── useReplies.tsx │ ├── useReactions.tsx │ ├── useLiveConversation.tsx │ ├── useMessages.tsx │ ├── useLatestMessages.tsx │ ├── useClient.tsx │ ├── useConversations.tsx │ └── useReadReceipts.tsx ├── views │ ├── ReadReceiptView.tsx │ ├── ConversationViewWithLoader.tsx │ ├── ConversationSettingsView.tsx │ ├── AttachmentPreviewView.tsx │ ├── ConversationCellView.tsx │ ├── MessageRepliesView.tsx │ ├── ConversationListView.tsx │ ├── HomeView.tsx │ ├── LoginView.tsx │ ├── ReplyComposer.tsx │ ├── ConversationView.tsx │ ├── NewConversationView.tsx │ ├── MessageCellView.tsx │ ├── MessageComposerView.tsx │ └── ReactionsView.tsx ├── App.tsx ├── index.css ├── main.tsx ├── contexts │ ├── ClientContext.tsx │ └── WalletContext.tsx └── assets │ └── react.svg ├── .github ├── CODEOWNERS └── workflows │ └── main.yml ├── postcss.config.js ├── tailwind.config.js ├── tsconfig.node.json ├── vite.config.ts ├── .gitignore ├── index.html ├── .eslintrc.cjs ├── tsconfig.json ├── LICENSE ├── README.md ├── public └── vite.svg └── package.json /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Global rule: 2 | * @xmtp/web 3 | *.md @jhaaaa 4 | -------------------------------------------------------------------------------- /src/polyfills.ts: -------------------------------------------------------------------------------- 1 | import { Buffer } from "buffer"; 2 | 3 | window.Buffer = window.Buffer ?? Buffer; 4 | -------------------------------------------------------------------------------- /src/components/README: -------------------------------------------------------------------------------- 1 | The files in this directory are general UI considerations and 2 | are not XMTP specific. 3 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /src/model/README: -------------------------------------------------------------------------------- 1 | The files in this directory handle all interactions with the XMTP network 2 | as well as keeping the local database up to date. 3 | -------------------------------------------------------------------------------- /src/util/shortAddress.ts: -------------------------------------------------------------------------------- 1 | export const shortAddress = (addr: string): string => 2 | addr.length > 10 && addr.startsWith("0x") 3 | ? `${addr.substring(0, 6)}...${addr.substring(addr.length - 4)}` 4 | : addr; 5 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [], 8 | }; 9 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | build: { 7 | target: "esnext", 8 | rollupOptions: {}, 9 | }, 10 | plugins: [react()], 11 | }); 12 | -------------------------------------------------------------------------------- /src/model/global.d.ts: -------------------------------------------------------------------------------- 1 | declare module "web3.storage" { 2 | declare class Web3Storage { 3 | constructor(opts: { token: string }); 4 | put(files: [Filelike]); 5 | } 6 | export interface Filelike { 7 | name: string; 8 | data: Uint8Array; 9 | stream(): ReadableArray; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren, ReactElement } from "react"; 2 | 3 | export default function Header({ 4 | children, 5 | }: PropsWithChildren): ReactElement { 6 | return ( 7 |
8 | {children} 9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /src/hooks/useAttachment.tsx: -------------------------------------------------------------------------------- 1 | import db, { Message, MessageAttachment } from "../model/db"; 2 | import { useLiveQuery } from "dexie-react-hooks"; 3 | 4 | export function useAttachment(message: Message): MessageAttachment | undefined { 5 | return useLiveQuery(async () => { 6 | return await db.attachments.where("messageID").equals(message.id!).first(); 7 | }); 8 | } 9 | -------------------------------------------------------------------------------- /src/views/ReadReceiptView.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement } from "react"; 2 | 3 | export default function ReadReceiptView({ 4 | readReceiptText, 5 | }: { 6 | readReceiptText: string | undefined; 7 | }): ReactElement { 8 | return readReceiptText ? ( 9 | {readReceiptText} 10 | ) : ( 11 | <> 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /src/hooks/useReplies.tsx: -------------------------------------------------------------------------------- 1 | import db, { Message } from "../model/db"; 2 | import { useLiveQuery } from "dexie-react-hooks"; 3 | 4 | export function useReplies(message: Message): Message[] { 5 | return ( 6 | useLiveQuery(async () => { 7 | return await db.messages 8 | .where("inReplyToID") 9 | .equals(message.xmtpID) 10 | .toArray(); 11 | }) || [] 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /src/hooks/useReactions.tsx: -------------------------------------------------------------------------------- 1 | import db, { Message, MessageReaction } from "../model/db"; 2 | import { useLiveQuery } from "dexie-react-hooks"; 3 | 4 | export function useReactions(message: Message): MessageReaction[] | undefined { 5 | return useLiveQuery(async () => { 6 | return await db.reactions 7 | .where("messageXMTPID") 8 | .equals(message.xmtpID) 9 | .toArray(); 10 | }); 11 | } 12 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | XMTP Quick Start React 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import LoginView from "./views/LoginView"; 2 | import { useClient } from "./hooks/useClient"; 3 | import HomeView from "./views/HomeView"; 4 | import TimeAgo from "javascript-time-ago"; 5 | import en from "javascript-time-ago/locale/en.json"; 6 | 7 | TimeAgo.addDefaultLocale(en); 8 | 9 | function App() { 10 | const client = useClient(); 11 | 12 | return client ? : ; 13 | } 14 | 15 | export default App; 16 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { browser: true, es2020: true }, 3 | extends: [ 4 | "eslint:recommended", 5 | "plugin:@typescript-eslint/recommended", 6 | "plugin:react-hooks/recommended", 7 | ], 8 | parser: "@typescript-eslint/parser", 9 | parserOptions: { ecmaVersion: "latest", sourceType: "module" }, 10 | plugins: ["react-refresh"], 11 | rules: { 12 | "react-refresh/only-export-components": "warn", 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /src/views/ConversationViewWithLoader.tsx: -------------------------------------------------------------------------------- 1 | import { useLoaderData } from "react-router-dom"; 2 | import ConversationView from "./ConversationView"; 3 | import { ReactElement } from "react"; 4 | import { Conversation } from "../model/db"; 5 | 6 | export default function ConversationViewWithLoader(): ReactElement { 7 | const { conversation } = useLoaderData() as { conversation: Conversation }; 8 | 9 | return ; 10 | } 11 | -------------------------------------------------------------------------------- /src/hooks/useLiveConversation.tsx: -------------------------------------------------------------------------------- 1 | import { Conversation } from "../model/db"; 2 | import { useLiveQuery } from "dexie-react-hooks"; 3 | import db from "../model/db"; 4 | 5 | // Keeps a conversation up to date with DB updates 6 | export function useLiveConversation(conversation: Conversation): Conversation { 7 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 8 | return useLiveQuery(async () => { 9 | return db.conversations.where("topic").equals(conversation.topic).first(); 10 | })!; 11 | } 12 | -------------------------------------------------------------------------------- /src/views/ConversationSettingsView.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement } from "react"; 2 | import { Conversation } from "../model/db"; 3 | import { XyzTransition } from "@animxyz/react"; 4 | 5 | export default function ConversationSettingsView({ 6 | conversation, 7 | }: { 8 | conversation: Conversation; 9 | dismiss: () => void; 10 | }): ReactElement { 11 | return ( 12 | 13 |
14 |

Conversation Info

15 |

This is a 1:1 conversation with {conversation.peerAddress}.

16 |
17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": false, 20 | "noUnusedParameters": false, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /src/hooks/useMessages.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { useClient } from "./useClient"; 3 | import { loadMessages } from "../model/messages"; 4 | import db, { Conversation, Message } from "../model/db"; 5 | import { useLiveQuery } from "dexie-react-hooks"; 6 | 7 | export function useMessages(conversation: Conversation): Message[] | undefined { 8 | const client = useClient(); 9 | 10 | useEffect(() => { 11 | if (!client) return; 12 | loadMessages(conversation, client); 13 | }, [client, conversation]); 14 | 15 | return useLiveQuery(async () => { 16 | return await db.messages 17 | .where({ 18 | conversationTopic: conversation.topic, 19 | inReplyToID: "", 20 | }) 21 | .sortBy("sentAt"); 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /src/hooks/useLatestMessages.tsx: -------------------------------------------------------------------------------- 1 | import db, { Conversation, Message } from "../model/db"; 2 | import { useLiveQuery } from "dexie-react-hooks"; 3 | 4 | export function useLatestMessages( 5 | conversations: Conversation[] 6 | ): (Message | undefined)[] { 7 | return ( 8 | useLiveQuery(async () => { 9 | return await Promise.all( 10 | conversations.map(async (conversation) => { 11 | return ( 12 | await db.messages 13 | .where("conversationTopic") 14 | .equals(conversation.topic) 15 | .reverse() 16 | .sortBy("sentAt") 17 | )[0]; 18 | }) 19 | ); 20 | }, [ 21 | conversations 22 | .map((conversation) => String(conversation.updatedAt)) 23 | .join(), 24 | ]) || [] 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | /* Override RainbowKit button to look more like ours */ 6 | .connect-button button { 7 | border-radius: 0.375rem !important; 8 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important; 9 | } 10 | 11 | @keyframes horizontal-shaking { 12 | 0% { 13 | transform: translateX(0) 14 | } 15 | 16 | 25% { 17 | transform: translateX(5px) 18 | } 19 | 20 | 50% { 21 | transform: translateX(-5px) 22 | } 23 | 24 | 75% { 25 | transform: translateX(5px) 26 | } 27 | 28 | 100% { 29 | transform: translateX(0) 30 | } 31 | } 32 | 33 | .horizontal-shake { 34 | animation: horizontal-shaking 0.25s; 35 | animation-iteration-count: 1; 36 | } 37 | -------------------------------------------------------------------------------- /src/components/Button.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement, ReactEventHandler } from "react"; 2 | 3 | export default function Button({ 4 | children, 5 | onClick, 6 | type, 7 | color, 8 | className, 9 | disabled, 10 | size = "md", 11 | }: { 12 | children: ReactElement | string; 13 | onClick?: ReactEventHandler | undefined; 14 | type: "submit" | "button"; 15 | color?: "primary" | "secondary"; 16 | className?: string | undefined; 17 | disabled?: boolean; 18 | size?: "sm" | "md"; 19 | }): ReactElement { 20 | const buttonColor = 21 | color == "secondary" || disabled ? "bg-gray-500" : "bg-blue-600"; 22 | 23 | const buttonSize = size === "sm" ? "text-xs px-2 py-1" : "text-sm px-3 py-2"; 24 | 25 | return ( 26 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/hooks/useClient.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { ClientContext } from "../contexts/ClientContext"; 3 | import { Client } from "@xmtp/xmtp-js"; 4 | import { 5 | AttachmentCodec, 6 | RemoteAttachmentCodec, 7 | } from "@xmtp/content-type-remote-attachment"; 8 | import { ReplyCodec } from "@xmtp/content-type-reply"; 9 | import { ReactionCodec } from "@xmtp/content-type-reaction"; 10 | import { ReadReceiptCodec } from "@xmtp/content-type-read-receipt"; 11 | 12 | export function useClient() { 13 | return useContext(ClientContext).client; 14 | } 15 | 16 | export function useSetClient() { 17 | const setClient = useContext(ClientContext).setClient; 18 | 19 | return (client: Client | null) => { 20 | if (client) { 21 | client.registerCodec(new AttachmentCodec()); 22 | client.registerCodec(new RemoteAttachmentCodec()); 23 | client.registerCodec(new ReplyCodec()); 24 | client.registerCodec(new ReactionCodec()); 25 | client.registerCodec(new ReadReceiptCodec()); 26 | } 27 | 28 | setClient(client); 29 | }; 30 | } 31 | -------------------------------------------------------------------------------- /src/views/AttachmentPreviewView.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement } from "react"; 2 | import { Attachment } from "@xmtp/content-type-remote-attachment"; 3 | 4 | export default function AttachmentPreviewView({ 5 | attachment, 6 | onDismiss, 7 | }: { 8 | attachment: Attachment; 9 | onDismiss: () => void; 10 | }): ReactElement { 11 | if (attachment.mimeType.startsWith("image/")) { 12 | const objectURL = URL.createObjectURL( 13 | new Blob([Buffer.from(attachment.data)], { 14 | type: attachment.mimeType, 15 | }) 16 | ); 17 | 18 | return ( 19 |
20 | 21 |
22 | 23 | {attachment.filename}{" "} 24 | 27 | 28 |
29 |
30 | ); 31 | } 32 | 33 | return
{attachment.filename}
; 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 XMTP 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/views/ConversationCellView.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement } from "react"; 2 | import { Conversation, Message } from "../model/db"; 3 | import { shortAddress } from "../util/shortAddress"; 4 | import ReactTimeAgo from "react-time-ago"; 5 | import { MessageContent } from "./MessageCellView"; 6 | 7 | export default function ConversationCellView({ 8 | conversation, 9 | latestMessage, 10 | }: { 11 | conversation: Conversation; 12 | latestMessage: Message | undefined; 13 | }): ReactElement { 14 | return ( 15 |
16 |
17 |
18 | 19 | {conversation.title || shortAddress(conversation.peerAddress)} 20 | {" "} 21 |
22 |
23 | 24 |
25 |
26 | {latestMessage ? ( 27 |
28 | 29 |
30 | ) : ( 31 |
No messages yet.
32 | )} 33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Status](https://img.shields.io/badge/Deprecated-brown) 2 | 3 | > [!CAUTION] 4 | > This repo is no longer maintained. 5 | 6 | The documentation below is provided for historical reference only. 7 | 8 | --- 9 | 10 | # XMTP React playground 11 | 12 | Use the XMTP React playground app as a tool to help you build your own app with XMTP. 13 | 14 | Built with React and the [XMTP JavaScript SDK](https://github.com/xmtp/xmtp-js), the playground provides the following functionality that you can use as a foundation for your app: 15 | 16 | - Performant [database architecture](https://xmtp.org/docs/tutorials/performance#use-a-local-cache) 17 | - Message reactions 18 | - Message replies 19 | - Read receipts 20 | - [Attachments](https://xmtp.org/docs/build/attachments) 21 | - Images 22 | - More formats to come 23 | 24 | The playground's visual design is intentionally limited to help make the code easier for you to use, customize, and deploy. 25 | 26 | The playground has not undergone a formal security audit. 27 | 28 | > **Note** 29 | > You might also be interested in our Vue playground: Coming soon 30 | 31 | ## Install the package 32 | 33 | ```bash 34 | npm install 35 | ``` 36 | 37 | ## Run the development server 38 | 39 | ```bash 40 | npm run dev 41 | ``` 42 | 43 | Open [http://localhost:5173](http://localhost:5173) in your browser to access the XMTP React playground app. 44 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import "./polyfills"; 2 | import "./index.css"; 3 | import "@animxyz/core"; 4 | import React from "react"; 5 | import ReactDOM from "react-dom/client"; 6 | import App from "./App.tsx"; 7 | import ClientProvider from "./contexts/ClientContext.tsx"; 8 | import { createHashRouter, RouterProvider } from "react-router-dom"; 9 | import { findConversation } from "./model/conversations"; 10 | import ConversationViewWithLoader from "./views/ConversationViewWithLoader.tsx"; 11 | import NewConversationView from "./views/NewConversationView.tsx"; 12 | import WalletContext from "./contexts/WalletContext.tsx"; 13 | 14 | async function conversationLoader({ params }: any) { 15 | const conversation = await findConversation(params.conversationTopic); 16 | return { conversation }; 17 | } 18 | 19 | const router = createHashRouter([ 20 | { 21 | path: "*", 22 | element: , 23 | }, 24 | { 25 | path: "c/:conversationTopic", 26 | element: , 27 | loader: conversationLoader, 28 | }, 29 | { 30 | path: "new", 31 | element: , 32 | }, 33 | ]); 34 | 35 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | ); 44 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # Simple workflow for deploying static content to GitHub Pages 2 | name: Deploy static content to Pages 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | branches: ['main'] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Sets the GITHUB_TOKEN permissions to allow deployment to GitHub Pages 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | # Allow one concurrent deployment 19 | concurrency: 20 | group: 'pages' 21 | cancel-in-progress: true 22 | 23 | jobs: 24 | # Single deploy job since we're just deploying 25 | deploy: 26 | environment: 27 | name: github-pages 28 | url: ${{ steps.deployment.outputs.page_url }} 29 | runs-on: ubuntu-latest 30 | steps: 31 | - name: Checkout 32 | uses: actions/checkout@v3 33 | - name: Set up Node 34 | uses: actions/setup-node@v3 35 | with: 36 | node-version: 18 37 | cache: 'npm' 38 | - name: Install dependencies 39 | run: npm install 40 | - name: Build 41 | run: npm run build 42 | - name: Setup Pages 43 | uses: actions/configure-pages@v3 44 | - name: Upload artifact 45 | uses: actions/upload-pages-artifact@v1 46 | with: 47 | # Upload dist repository 48 | path: './dist' 49 | - name: Deploy to GitHub Pages 50 | id: deployment 51 | uses: actions/deploy-pages@v1 52 | -------------------------------------------------------------------------------- /src/views/MessageRepliesView.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement, useState } from "react"; 2 | import { Message } from "../model/db"; 3 | import { useReplies } from "../hooks/useReplies"; 4 | import ReplyComposer from "./ReplyComposer"; 5 | import { MessageContent } from "./MessageCellView"; 6 | import { shortAddress } from "../util/shortAddress"; 7 | 8 | export default function MessageRepliesView({ 9 | message, 10 | }: { 11 | message: Message; 12 | }): ReactElement { 13 | const replies = useReplies(message); 14 | 15 | const [isShowingReplies, setIsShowingReplies] = useState(false); 16 | 17 | return isShowingReplies ? ( 18 |
19 | {replies.length > 0 && ( 20 |
21 | {replies.map((message) => ( 22 |
23 | {shortAddress(message.senderAddress)}: 24 | 25 |
26 | ))} 27 |
28 | )} 29 | 30 | setIsShowingReplies(false)} 33 | /> 34 |
35 | ) : ( 36 |
37 | 45 |
46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /src/hooks/useConversations.tsx: -------------------------------------------------------------------------------- 1 | import { Conversation } from "../model/db"; 2 | import { useLiveQuery } from "dexie-react-hooks"; 3 | import { useEffect } from "react"; 4 | import * as XMTP from "@xmtp/xmtp-js"; 5 | import { saveMessage } from "../model/messages"; 6 | import { saveConversation } from "../model/conversations"; 7 | import db from "../model/db"; 8 | 9 | export function useConversations(client: XMTP.Client | null): Conversation[] { 10 | useEffect(() => { 11 | (async () => { 12 | if (!client) return; 13 | 14 | for (const xmtpConversation of await client.conversations.list()) { 15 | const conversation = await saveConversation(xmtpConversation); 16 | 17 | // Load latest message from network for preview 18 | (async () => { 19 | const latestMessage = ( 20 | await xmtpConversation.messages({ 21 | direction: XMTP.SortDirection.SORT_DIRECTION_DESCENDING, 22 | limit: 1, 23 | }) 24 | )[0]; 25 | 26 | if (latestMessage) { 27 | await saveMessage(client, conversation, latestMessage); 28 | } 29 | })(); 30 | } 31 | })(); 32 | }, []); 33 | 34 | useEffect(() => { 35 | (async () => { 36 | if (!client) return; 37 | 38 | for await (const conversation of await client.conversations.stream()) { 39 | await saveConversation(conversation); 40 | } 41 | })(); 42 | }, []); 43 | 44 | return ( 45 | useLiveQuery(async () => { 46 | return await db.conversations.reverse().sortBy("updatedAt"); 47 | }) || [] 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /src/views/ConversationListView.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement, useEffect, useState } from "react"; 2 | import { useConversations } from "../hooks/useConversations"; 3 | import { useClient } from "../hooks/useClient"; 4 | import { Link } from "react-router-dom"; 5 | import { useLatestMessages } from "../hooks/useLatestMessages"; 6 | import ConversationCellView from "./ConversationCellView"; 7 | 8 | export default function ConversationListView(): ReactElement { 9 | const [readReceiptsEnabled, setReadReceiptsEnabled] = useState( 10 | window.localStorage.getItem("readReceiptsEnabled") === "true" 11 | ); 12 | 13 | const client = useClient(); 14 | const conversations = useConversations(client); 15 | const latestMessages = useLatestMessages(conversations); 16 | 17 | useEffect(() => { 18 | window.localStorage.setItem( 19 | "readReceiptsEnabled", 20 | String(readReceiptsEnabled) 21 | ); 22 | }, [readReceiptsEnabled]); 23 | 24 | return ( 25 |
26 | 33 | {conversations?.length == 0 &&

No conversations yet.

} 34 | {conversations 35 | ? conversations.map((conversation, i) => ( 36 | 37 | 41 | 42 | )) 43 | : "Could not load conversations"} 44 |
45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /src/views/HomeView.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement, useState } from "react"; 2 | import ConversationListView from "./ConversationListView"; 3 | import { useClient, useSetClient } from "../hooks/useClient"; 4 | import { shortAddress } from "../util/shortAddress"; 5 | import { Link } from "react-router-dom"; 6 | import Header from "../components/Header"; 7 | import { useDisconnect } from "wagmi"; 8 | 9 | export default function HomeView(): ReactElement { 10 | const client = useClient()!; 11 | const [copied, setCopied] = useState(false); 12 | 13 | function copy() { 14 | navigator.clipboard.writeText(client.address); 15 | setCopied(true); 16 | setTimeout(() => { 17 | setCopied(false); 18 | }, 2000); 19 | } 20 | 21 | const { disconnectAsync } = useDisconnect(); 22 | const setClient = useSetClient(); 23 | async function logout() { 24 | await disconnectAsync(); 25 | indexedDB.deleteDatabase("DB"); 26 | localStorage.removeItem("_insecurePrivateKey"); 27 | setClient(null); 28 | } 29 | 30 | return ( 31 |
32 |
33 |
34 |
35 | Hi {shortAddress(client.address)}{" "} 36 | 39 |
40 |
41 | 42 |
43 |
44 |
45 | 46 | Here are your conversations: 47 | 48 | Make a new one 49 | 50 | 51 | 52 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /src/hooks/useReadReceipts.tsx: -------------------------------------------------------------------------------- 1 | import db, { Conversation } from "../model/db"; 2 | import { useClient } from "./useClient"; 3 | import { sendMessage } from "../model/messages"; 4 | import { Client } from "@xmtp/xmtp-js"; 5 | import { ContentTypeReadReceipt } from "@xmtp/content-type-read-receipt"; 6 | import { useLiveQuery } from "dexie-react-hooks"; 7 | import { useMessages } from "./useMessages"; 8 | 9 | export function useReadReceipts(conversation: Conversation) { 10 | const client = useClient(); 11 | const messages = useMessages(conversation) || []; 12 | 13 | const isMostRecentMessageFromSelf = messages[messages.length - 1]?.sentByMe; 14 | const lastMessageTimestamp = messages[messages.length - 1]?.sentAt; 15 | 16 | const readReceiptsEnabled = 17 | window.localStorage.getItem("readReceiptsEnabled") === "true"; 18 | 19 | return useLiveQuery(async () => { 20 | try { 21 | const lastReadReceiptMessage = await db.readReceipts.get({ 22 | peerAddress: conversation.peerAddress, 23 | }); 24 | const isMostRecentMessageReadReceipt = 25 | lastMessageTimestamp < 26 | new Date(lastReadReceiptMessage?.timestamp as string); 27 | 28 | if (isMostRecentMessageFromSelf && isMostRecentMessageReadReceipt) { 29 | return true; 30 | } else if ( 31 | isMostRecentMessageFromSelf === false && 32 | isMostRecentMessageReadReceipt === false && 33 | readReceiptsEnabled 34 | ) { 35 | void sendMessage( 36 | client as Client, 37 | conversation, 38 | { 39 | timestamp: new Date().toISOString(), 40 | }, 41 | ContentTypeReadReceipt 42 | ); 43 | return false; 44 | } else { 45 | return false; 46 | } 47 | } catch { 48 | console.error("Error sending read receipt"); 49 | } 50 | }, [messages]); 51 | } 52 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "xmtp-playground", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build --base=/xmtp-react-playground/", 9 | "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview", 11 | "typecheck": "tsc --noEmit" 12 | }, 13 | "homepage": "https://xmtp.github.io/xmtp-react-playground", 14 | "dependencies": { 15 | "@animxyz/core": "^0.6.6", 16 | "@animxyz/react": "^0.6.7", 17 | "@heroicons/react": "^2.0.18", 18 | "@rainbow-me/rainbowkit": "^0.10.0", 19 | "@xmtp/content-type-reaction": "^1.0.1", 20 | "@xmtp/content-type-read-receipt": "^1.0.0", 21 | "@xmtp/content-type-remote-attachment": "^1.0.7", 22 | "@xmtp/content-type-reply": "^1.0.0", 23 | "@xmtp/xmtp-js": "^10.2.0", 24 | "async-mutex": "^0.4.0", 25 | "buffer": "^6.0.3", 26 | "classnames": "^2.3.2", 27 | "dexie": "^3.2.4", 28 | "dexie-react-hooks": "^1.1.6", 29 | "emojilib": "^3.0.10", 30 | "ethers": "^5.5.3", 31 | "javascript-time-ago": "^2.5.9", 32 | "react": "^18.2.0", 33 | "react-dom": "^18.2.0", 34 | "react-router-dom": "^6.13.0", 35 | "react-time-ago": "^7.2.1", 36 | "wagmi": "^0.10.0", 37 | "web3.storage": "^4.5.4" 38 | }, 39 | "devDependencies": { 40 | "@types/react": "^18.0.37", 41 | "@types/react-dom": "^18.0.11", 42 | "@typescript-eslint/eslint-plugin": "^5.59.0", 43 | "@typescript-eslint/parser": "^5.59.0", 44 | "@vitejs/plugin-react": "^4.0.0", 45 | "autoprefixer": "^10.4.14", 46 | "eslint": "^8.38.0", 47 | "eslint-plugin-react-hooks": "^4.6.0", 48 | "eslint-plugin-react-refresh": "^0.3.4", 49 | "postcss": "^8.4.24", 50 | "tailwindcss": "^3.3.2", 51 | "typescript": "^5.0.2", 52 | "vite": "^4.3.2" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/views/LoginView.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement, useEffect } from "react"; 2 | import Button from "../components/Button"; 3 | import { useClient, useSetClient } from "../hooks/useClient"; 4 | import { Wallet } from "ethers"; 5 | import { Client } from "@xmtp/xmtp-js"; 6 | import "@rainbow-me/rainbowkit/styles.css"; 7 | import { 8 | AttachmentCodec, 9 | RemoteAttachmentCodec, 10 | } from "@xmtp/content-type-remote-attachment"; 11 | import { ConnectButton } from "@rainbow-me/rainbowkit"; 12 | 13 | export default function LoginView(): ReactElement { 14 | const setClient = useSetClient(); 15 | 16 | async function generateWallet() { 17 | const wallet = Wallet.createRandom(); 18 | const client = await Client.create(wallet, { 19 | env: "dev", 20 | }); 21 | 22 | // Don't do this in real life. 23 | localStorage.setItem("_insecurePrivateKey", wallet.privateKey); 24 | 25 | setClient(client); 26 | } 27 | 28 | return ( 29 |
30 |
31 |
32 |
33 |

34 | Login 35 |

36 |
37 |

You can generate a wallet or connect your own.

38 |
39 |
40 | 47 |
48 | 49 |
50 |
51 |
52 |
53 |
54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /src/model/attachments.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Attachment, 3 | AttachmentCodec, 4 | RemoteAttachment, 5 | RemoteAttachmentCodec, 6 | } from "@xmtp/content-type-remote-attachment"; 7 | import { Web3Storage, Filelike } from "web3.storage"; 8 | 9 | export default class Upload implements Filelike { 10 | name: string; 11 | data: Uint8Array; 12 | 13 | constructor(name: string, data: Uint8Array) { 14 | this.name = name; 15 | this.data = data; 16 | } 17 | 18 | stream(): ReadableStream { 19 | // eslint-disable-next-line @typescript-eslint/no-this-alias 20 | const self = this; 21 | return new ReadableStream({ 22 | start(controller) { 23 | controller.enqueue(Buffer.from(self.data)); 24 | controller.close(); 25 | }, 26 | }); 27 | } 28 | } 29 | 30 | export async function upload( 31 | attachment: Attachment 32 | ): Promise { 33 | const encryptedEncoded = await RemoteAttachmentCodec.encodeEncrypted( 34 | attachment, 35 | new AttachmentCodec() 36 | ); 37 | 38 | let token: string | null = localStorage.getItem("web3storageToken"); 39 | 40 | if (!token) { 41 | token = prompt("Enter your web3.storage token"); 42 | 43 | if (token) { 44 | localStorage.setItem("web3storageToken", token); 45 | } 46 | } 47 | 48 | if (!token) { 49 | alert("No token, sorry."); 50 | throw new Error("no web3.storage token found"); 51 | } 52 | 53 | const web3Storage = new Web3Storage({ 54 | token: token, 55 | }); 56 | 57 | const upload = new Upload("XMTPEncryptedContent", encryptedEncoded.payload); 58 | const cid = await web3Storage.put([upload]); 59 | const url = `https://${cid}.ipfs.w3s.link/XMTPEncryptedContent`; 60 | 61 | return { 62 | url: url, 63 | contentDigest: encryptedEncoded.digest, 64 | salt: encryptedEncoded.salt, 65 | nonce: encryptedEncoded.nonce, 66 | secret: encryptedEncoded.secret, 67 | scheme: "https://", 68 | filename: attachment.filename, 69 | contentLength: attachment.data.byteLength, 70 | }; 71 | } 72 | -------------------------------------------------------------------------------- /src/contexts/ClientContext.tsx: -------------------------------------------------------------------------------- 1 | import { Client } from "@xmtp/xmtp-js"; 2 | import { Wallet } from "ethers"; 3 | import { createContext, useState, ReactElement, useEffect } from "react"; 4 | import { 5 | AttachmentCodec, 6 | RemoteAttachmentCodec, 7 | } from "@xmtp/content-type-remote-attachment"; 8 | import { ReplyCodec } from "@xmtp/content-type-reply"; 9 | import { ReactionCodec } from "@xmtp/content-type-reaction"; 10 | import { ReadReceiptCodec } from "@xmtp/content-type-read-receipt"; 11 | 12 | type ClientContextValue = { 13 | client: Client | null; 14 | setClient: (client: Client | null) => void; 15 | }; 16 | 17 | export const ClientContext = createContext({ 18 | client: null, 19 | setClient: () => { 20 | return; 21 | }, 22 | }); 23 | 24 | export default function ClientProvider({ 25 | children, 26 | }: { 27 | children: ReactElement; 28 | }): ReactElement { 29 | const [client, setClient] = useState(null); 30 | const [isLoading, setIsLoading] = useState(true); 31 | 32 | useEffect(() => { 33 | (async () => { 34 | const insecurePrivateKey = localStorage.getItem("_insecurePrivateKey"); 35 | 36 | if (!insecurePrivateKey) { 37 | setIsLoading(false); 38 | return; 39 | } 40 | 41 | const wallet = new Wallet(insecurePrivateKey); 42 | const client = await Client.create(wallet, { 43 | env: "dev", 44 | }); 45 | 46 | client.registerCodec(new AttachmentCodec()); 47 | client.registerCodec(new RemoteAttachmentCodec()); 48 | client.registerCodec(new ReplyCodec()); 49 | client.registerCodec(new ReactionCodec()); 50 | client.registerCodec(new ReadReceiptCodec()); 51 | 52 | setClient(client); 53 | setIsLoading(false); 54 | })(); 55 | }, []); 56 | 57 | const clientContextValue = { 58 | client, 59 | setClient, 60 | }; 61 | 62 | return ( 63 | 64 | {isLoading ? ( 65 |
Loading client....
66 | ) : ( 67 | children 68 | )} 69 |
70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /src/views/ReplyComposer.tsx: -------------------------------------------------------------------------------- 1 | import { FormEvent, ReactElement, createRef, useRef } from "react"; 2 | import { Message } from "../model/db"; 3 | import { shortAddress } from "../util/shortAddress"; 4 | import Button from "../components/Button"; 5 | import { sendMessage } from "../model/messages"; 6 | import { ContentTypeReply, Reply } from "@xmtp/content-type-reply"; 7 | import { ContentTypeText } from "@xmtp/xmtp-js"; 8 | import { useClient } from "../hooks/useClient"; 9 | import { findConversation } from "../model/conversations"; 10 | 11 | export default function ReplyComposer({ 12 | inReplyToMessage, 13 | dismiss, 14 | }: { 15 | inReplyToMessage: Message; 16 | dismiss: () => void; 17 | }): ReactElement { 18 | const client = useClient()!; 19 | 20 | // We're using an uncontrolled component here because we don't need to update 21 | // anything as the user is typing. 22 | // 23 | // See https://react.dev/learn/manipulating-the-dom-with-refs#best-practices-for-dom-manipulation-with-refs 24 | const textField = createRef(); 25 | 26 | async function reply(e: FormEvent) { 27 | e.preventDefault(); 28 | 29 | const replyText = textField.current?.value; 30 | 31 | if (!replyText) { 32 | return; 33 | } 34 | 35 | const reply: Reply = { 36 | reference: inReplyToMessage.xmtpID, 37 | content: textField.current.value, 38 | contentType: ContentTypeText, 39 | }; 40 | 41 | textField.current.value = ""; 42 | 43 | const conversation = await findConversation( 44 | inReplyToMessage.conversationTopic 45 | ); 46 | 47 | if (!conversation) { 48 | return; 49 | } 50 | 51 | await sendMessage(client, conversation, reply, ContentTypeReply); 52 | } 53 | 54 | return ( 55 |
56 |
57 | 66 | 67 | 70 | 73 |
74 |
75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /src/model/db.ts: -------------------------------------------------------------------------------- 1 | /* 2 | DB.ts 3 | 4 | This file defines the local database schema for our app. Any time we show any 5 | data in the UI, it should come from the database. 6 | */ 7 | 8 | import Dexie from "dexie"; 9 | 10 | export interface Conversation { 11 | id?: number; 12 | topic: string; 13 | title: string | undefined; 14 | createdAt: Date; 15 | updatedAt: Date; 16 | peerAddress: string; 17 | } 18 | 19 | export interface Message { 20 | id?: number; 21 | inReplyToID: string; 22 | conversationTopic: string; 23 | xmtpID: string; 24 | senderAddress: string; 25 | sentByMe: boolean; 26 | sentAt: Date; 27 | contentType: { 28 | authorityId: string; 29 | typeId: string; 30 | versionMajor: number; 31 | versionMinor: number; 32 | }; 33 | content: any; 34 | metadata?: { [key: string]: [value: string] }; 35 | isSending: boolean; 36 | } 37 | 38 | export interface MessageAttachment { 39 | id?: number; 40 | messageID: number; 41 | filename: string; 42 | mimeType: string; 43 | data: Uint8Array; 44 | } 45 | 46 | export interface MessageReaction { 47 | id?: number; 48 | reactor: string; 49 | messageXMTPID: string; 50 | name: string; 51 | } 52 | 53 | export interface ReadReceipt { 54 | peerAddress: string; 55 | timestamp: string; 56 | } 57 | 58 | class DB extends Dexie { 59 | conversations!: Dexie.Table; 60 | messages!: Dexie.Table; 61 | attachments!: Dexie.Table; 62 | reactions!: Dexie.Table; 63 | readReceipts!: Dexie.Table; 64 | 65 | constructor() { 66 | super("DB"); 67 | this.version(2).stores({ 68 | conversations: ` 69 | ++id, 70 | topic, 71 | title, 72 | createdAt, 73 | updatedAt, 74 | peerAddress 75 | `, 76 | messages: ` 77 | ++id, 78 | [conversationTopic+inReplyToID], 79 | inReplyToID, 80 | conversationTopic, 81 | xmtpID, 82 | senderAddress, 83 | sentByMe, 84 | sentAt, 85 | contentType, 86 | content 87 | `, 88 | attachments: ` 89 | ++id, 90 | messageID, 91 | filename, 92 | mimeType, 93 | data 94 | `, 95 | reactions: ` 96 | ++id, 97 | [messageXMTPID+reactor+name], 98 | messageXMTPID, 99 | reactor, 100 | name 101 | `, 102 | readReceipts: ` 103 | ++peerAddress, 104 | timestamp 105 | `, 106 | }); 107 | } 108 | } 109 | 110 | const db = new DB(); 111 | export default db; 112 | -------------------------------------------------------------------------------- /src/model/conversations.ts: -------------------------------------------------------------------------------- 1 | import { Conversation } from "./db"; 2 | import { useLiveQuery } from "dexie-react-hooks"; 3 | import { useEffect } from "react"; 4 | import * as XMTP from "@xmtp/xmtp-js"; 5 | import db from "./db"; 6 | import { Mutex } from "async-mutex"; 7 | import { saveMessage } from "./messages"; 8 | 9 | // Prevent races when updating the local database 10 | const conversationMutex = new Mutex(); 11 | 12 | // TODO: figure out better way to turn db Conversation -> XMTP.Conversation 13 | export async function getXMTPConversation( 14 | client: XMTP.Client, 15 | conversation: Conversation 16 | ): Promise { 17 | const conversations = await client.conversations.list(); 18 | const xmtpConversation = conversations.find( 19 | (xmtpConversation) => 20 | stripTopicName(xmtpConversation.topic) == conversation.topic 21 | ); 22 | 23 | if (!xmtpConversation) 24 | throw new Error("could not convert db conversation to XMTP conversation"); 25 | 26 | return xmtpConversation; 27 | } 28 | 29 | export async function findConversation( 30 | topic: string 31 | ): Promise { 32 | return await db.conversations 33 | .where("topic") 34 | .equals(stripTopicName(topic)) 35 | .first(); 36 | } 37 | 38 | export async function updateConversationTimestamp( 39 | topic: string, 40 | updatedAt: Date 41 | ) { 42 | const conversation = await db.conversations 43 | .where("topic") 44 | .equals(topic) 45 | .first(); 46 | 47 | if (conversation && conversation.updatedAt < updatedAt) { 48 | await conversationMutex.runExclusive(async () => { 49 | await db.conversations.update(conversation, { updatedAt }); 50 | }); 51 | } 52 | } 53 | 54 | export function stripTopicName(conversationTopic: string): string { 55 | return conversationTopic.replace("/xmtp/0/", "").replace("/proto", ""); 56 | } 57 | 58 | export async function startConversation( 59 | client: XMTP.Client, 60 | address: string 61 | ): Promise { 62 | const xmtpConversation = await client.conversations.newConversation(address); 63 | return await saveConversation(xmtpConversation); 64 | } 65 | 66 | export async function saveConversation( 67 | xmtpConversation: XMTP.Conversation 68 | ): Promise { 69 | return await conversationMutex.runExclusive(async () => { 70 | const existing = await db.conversations 71 | .where("topic") 72 | .equals(stripTopicName(xmtpConversation.topic)) 73 | .first(); 74 | 75 | if (existing) { 76 | return existing; 77 | } 78 | 79 | const conversation: Conversation = { 80 | topic: stripTopicName(xmtpConversation.topic), 81 | title: undefined, 82 | createdAt: xmtpConversation.createdAt, 83 | updatedAt: xmtpConversation.createdAt, 84 | peerAddress: xmtpConversation.peerAddress, 85 | }; 86 | 87 | conversation.id = await db.conversations.add(conversation); 88 | 89 | return conversation; 90 | }); 91 | } 92 | -------------------------------------------------------------------------------- /src/contexts/WalletContext.tsx: -------------------------------------------------------------------------------- 1 | import { getDefaultWallets, RainbowKitProvider } from "@rainbow-me/rainbowkit"; 2 | import { PropsWithChildren, ReactElement, useEffect, useState } from "react"; 3 | import { 4 | configureChains, 5 | createClient, 6 | WagmiConfig, 7 | useSigner, 8 | useAccount, 9 | useDisconnect, 10 | } from "wagmi"; 11 | import { mainnet, polygon, optimism, arbitrum } from "wagmi/chains"; 12 | import { publicProvider } from "wagmi/providers/public"; 13 | import { useSetClient } from "../hooks/useClient"; 14 | import { Client } from "@xmtp/xmtp-js"; 15 | 16 | const { chains, provider, webSocketProvider } = configureChains( 17 | [mainnet, polygon, optimism, arbitrum], 18 | [publicProvider()] 19 | ); 20 | 21 | const { connectors } = getDefaultWallets({ 22 | appName: "XMTP Inbox", 23 | chains, 24 | }); 25 | 26 | const wagmiClient = createClient({ 27 | autoConnect: true, 28 | connectors, 29 | provider, 30 | webSocketProvider, 31 | }); 32 | 33 | function WalletSetter({ 34 | setWaitingForSignatures, 35 | children, 36 | }: PropsWithChildren<{ 37 | setWaitingForSignatures: (state: boolean) => void; 38 | }>): ReactElement { 39 | const { disconnect } = useDisconnect(); 40 | const { data: signer } = useSigner({ 41 | onError: () => { 42 | setWaitingForSignatures(false); 43 | disconnect(); 44 | }, 45 | }); 46 | const setClient = useSetClient(); 47 | 48 | useEffect(() => { 49 | if (signer) { 50 | setWaitingForSignatures(true); 51 | (async () => { 52 | try { 53 | const client = await Client.create(signer, { 54 | env: "dev", 55 | }); 56 | 57 | setClient(client); 58 | setWaitingForSignatures(false); 59 | } catch { 60 | disconnect(); 61 | setWaitingForSignatures(false); 62 | } 63 | })(); 64 | } 65 | }, [!!signer]); 66 | 67 | return <>{children}; 68 | } 69 | 70 | export default function WalletContext({ 71 | children, 72 | }: PropsWithChildren): ReactElement { 73 | const [waitingForSignatures, setWaitingForSignatures] = useState(false); 74 | 75 | return ( 76 | 77 | 78 | 79 | {waitingForSignatures ? ( 80 |
81 |
82 |
83 |
84 |

85 | Waiting for signatures… 86 |

87 |

88 | Sign the messages you've been prompted with in your wallet 89 | app to sign in to XMTP. 90 |

91 |
92 |
93 |
94 | ) : ( 95 | children 96 | )} 97 |
98 |
99 |
100 | ); 101 | } 102 | -------------------------------------------------------------------------------- /src/model/message-processor.ts: -------------------------------------------------------------------------------- 1 | import db, { Conversation, Message, MessageAttachment } from "./db"; 2 | import * as XMTP from "@xmtp/xmtp-js"; 3 | import { 4 | Attachment, 5 | ContentTypeAttachment, 6 | ContentTypeRemoteAttachment, 7 | RemoteAttachment, 8 | RemoteAttachmentCodec, 9 | } from "@xmtp/content-type-remote-attachment"; 10 | import { ContentTypeReply, Reply } from "@xmtp/content-type-reply"; 11 | import { deleteReaction, persistReaction } from "./reactions"; 12 | import { ContentTypeReaction, Reaction } from "@xmtp/content-type-reaction"; 13 | import { ContentTypeReadReceipt } from "@xmtp/content-type-read-receipt"; 14 | import { ContentTypeId } from "@xmtp/xmtp-js"; 15 | 16 | export async function process( 17 | client: XMTP.Client, 18 | conversation: Conversation, 19 | { 20 | content, 21 | contentType, 22 | message, 23 | }: { content: any; contentType: ContentTypeId; message: Message } 24 | ) { 25 | if (ContentTypeReadReceipt.sameAs(contentType)) { 26 | // Get items from the read receipts table based on peerAddress within conversation 27 | await db.readReceipts 28 | .get({ peerAddress: conversation.peerAddress }) 29 | .then(async (existingEntry) => { 30 | // If the entry doesn't exist, add it with content timestamp 31 | if (!existingEntry) { 32 | await db.readReceipts.add({ 33 | peerAddress: conversation.peerAddress, 34 | timestamp: message.content.timestamp, 35 | }); 36 | } 37 | // If the entry does exist, update it with content timestamp 38 | else { 39 | await db.readReceipts.update(conversation.peerAddress, { 40 | timestamp: message.content.timestamp, 41 | }); 42 | } 43 | }); 44 | } else { 45 | message.id = await db.messages.add(message); 46 | 47 | if (ContentTypeReply.sameAs(contentType)) { 48 | const reply = content as Reply; 49 | await db.messages.update(message.id, { 50 | inReplyToID: reply.reference, 51 | }); 52 | } 53 | 54 | if (ContentTypeAttachment.sameAs(contentType)) { 55 | const attachment = content as Attachment; 56 | const messageAttachment: MessageAttachment = { 57 | messageID: message.id, 58 | ...attachment, 59 | }; 60 | 61 | await db.attachments.add(messageAttachment); 62 | } 63 | 64 | if (ContentTypeRemoteAttachment.sameAs(contentType)) { 65 | const remoteAttachment = content as RemoteAttachment; 66 | const attachment: Attachment = await RemoteAttachmentCodec.load( 67 | remoteAttachment, 68 | client 69 | ); 70 | 71 | const messageAttachment: MessageAttachment = { 72 | messageID: message.id, 73 | ...attachment, 74 | }; 75 | 76 | await db.attachments.add(messageAttachment); 77 | } 78 | 79 | if (ContentTypeReaction.sameAs(message.contentType as XMTP.ContentTypeId)) { 80 | const reaction: Reaction = message.content; 81 | 82 | if (reaction.action == "removed") { 83 | await deleteReaction({ 84 | messageXMTPID: reaction.reference, 85 | reactor: message.senderAddress, 86 | name: reaction.content, 87 | }); 88 | } else { 89 | await persistReaction({ 90 | reactor: message.senderAddress, 91 | name: reaction.content, 92 | messageXMTPID: reaction.reference, 93 | }); 94 | } 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/views/ConversationView.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement, useEffect, useState } from "react"; 2 | import { Conversation, Message } from "../model/db"; 3 | import { useMessages } from "../hooks/useMessages"; 4 | import MessageComposerView from "./MessageComposerView"; 5 | import MessageCellView from "./MessageCellView"; 6 | import { Link } from "react-router-dom"; 7 | import Header from "../components/Header"; 8 | import { Cog6ToothIcon } from "@heroicons/react/24/solid"; 9 | import { useLiveConversation } from "../hooks/useLiveConversation"; 10 | import ConversationSettingsView from "./ConversationSettingsView"; 11 | import { ContentTypeId } from "@xmtp/xmtp-js"; 12 | import { ContentTypeReaction } from "@xmtp/content-type-reaction"; 13 | import { useReadReceipts } from "../hooks/useReadReceipts"; 14 | 15 | const appearsInMessageList = (message: Message): boolean => { 16 | if (ContentTypeReaction.sameAs(message.contentType as ContentTypeId)) { 17 | return false; 18 | } 19 | 20 | return true; 21 | }; 22 | 23 | export default function ConversationView({ 24 | conversation, 25 | }: { 26 | conversation: Conversation; 27 | }): ReactElement { 28 | const liveConversation = useLiveConversation(conversation); 29 | 30 | const messages = useMessages(conversation); 31 | 32 | const showReadReceipt = useReadReceipts(conversation); 33 | 34 | const [isShowingSettings, setIsShowingSettings] = useState(false); 35 | 36 | useEffect(() => { 37 | window.scrollTo({ top: 100000, behavior: "smooth" }); 38 | }, [messages?.length]); 39 | 40 | return ( 41 |
42 |
43 |
44 | 45 | {liveConversation?.title || conversation.peerAddress} 46 | 47 |
48 | 57 | 58 | Go Back 59 | 60 |
61 |
62 | {isShowingSettings && ( 63 | setIsShowingSettings(false)} 66 | /> 67 | )} 68 |
69 |
70 | {messages?.length == 0 &&

No messages yet.

} 71 | {messages ? ( 72 | messages.reduce((acc: ReactElement[], message: Message, index) => { 73 | const showRead = showReadReceipt && index === messages.length - 1; 74 | if (appearsInMessageList(message)) { 75 | acc.push( 76 | 81 | ); 82 | } 83 | 84 | return acc; 85 | }, [] as ReactElement[]) 86 | ) : ( 87 | Could not load messages 88 | )} 89 |
90 | 91 |
92 | ); 93 | } 94 | -------------------------------------------------------------------------------- /src/views/NewConversationView.tsx: -------------------------------------------------------------------------------- 1 | import { FormEvent, ReactElement, createRef, useState } from "react"; 2 | import { Link, redirect, useNavigate } from "react-router-dom"; 3 | import Header from "../components/Header"; 4 | import Button from "../components/Button"; 5 | import { startConversation } from "../model/conversations"; 6 | import { useClient } from "../hooks/useClient"; 7 | 8 | export default function NewConversationView(): ReactElement { 9 | const client = useClient()!; 10 | 11 | // We're using an uncontrolled component here because we don't need to update 12 | // anything as the user is typing. 13 | // 14 | // See https://react.dev/learn/manipulating-the-dom-with-refs#best-practices-for-dom-manipulation-with-refs 15 | const addressInputRef = createRef(); 16 | 17 | const [error, setError] = useState(); 18 | const [addresses, setAddresses] = useState([]); 19 | 20 | const navigate = useNavigate(); 21 | 22 | function validateAddress(): string | undefined { 23 | const address = addressInputRef.current?.value || ""; 24 | 25 | if (address.trim().length == 0) { 26 | addressInputRef.current?.classList.add("horizontal-shake"); 27 | setTimeout(() => { 28 | addressInputRef.current?.classList.remove("horizontal-shake"); 29 | }, 1000); 30 | 31 | addressInputRef.current?.focus(); 32 | 33 | return; 34 | } 35 | 36 | return address; 37 | } 38 | 39 | async function onSubmit(e: FormEvent) { 40 | e.preventDefault(); 41 | 42 | const address = validateAddress(); 43 | if (!address) return; 44 | 45 | try { 46 | const conversation = await startConversation(client, address); 47 | navigate(`/c/${conversation.topic}`); 48 | } catch (e) { 49 | setError(String(e)); 50 | } 51 | } 52 | 53 | function onAdd() { 54 | const address = validateAddress(); 55 | if (!address) { 56 | return; 57 | } 58 | 59 | setAddresses((addresses) => [address, ...addresses]); 60 | 61 | addressInputRef.current!.value = ""; 62 | addressInputRef.current?.focus(); 63 | } 64 | 65 | return ( 66 |
67 |
68 |
69 |

Make a new conversation

70 | 71 | Go Back 72 | 73 |
74 |
75 |
76 |
77 | {error && ( 78 |
79 | {error} 80 |
81 | )} 82 | 83 | 96 | 99 |
100 |
101 |
102 | ); 103 | } 104 | -------------------------------------------------------------------------------- /src/views/MessageCellView.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement } from "react"; 2 | import { Message, MessageAttachment } from "../model/db"; 3 | import { useAttachment } from "../hooks/useAttachment"; 4 | import { shortAddress } from "../util/shortAddress"; 5 | import { ContentTypeId, ContentTypeText } from "@xmtp/xmtp-js"; 6 | import { 7 | ContentTypeAttachment, 8 | ContentTypeRemoteAttachment, 9 | } from "@xmtp/content-type-remote-attachment"; 10 | import { ContentTypeReply, Reply } from "@xmtp/content-type-reply"; 11 | import MessageRepliesView from "./MessageRepliesView"; 12 | import ReactionsView from "./ReactionsView"; 13 | import ReadReceiptView from "./ReadReceiptView"; 14 | 15 | function ImageAttachmentContent({ 16 | attachment, 17 | }: { 18 | attachment: MessageAttachment; 19 | }): ReactElement { 20 | const objectURL = URL.createObjectURL( 21 | new Blob([Buffer.from(attachment.data)], { 22 | type: attachment.mimeType, 23 | }) 24 | ); 25 | 26 | return ( 27 | { 29 | window.scroll({ top: 10000, behavior: "smooth" }); 30 | }} 31 | className="rounded w-48" 32 | src={objectURL} 33 | title={attachment.filename} 34 | /> 35 | ); 36 | } 37 | 38 | function AttachmentContent({ message }: { message: Message }): ReactElement { 39 | const attachment = useAttachment(message); 40 | 41 | if (!attachment) { 42 | return Loading attachment…; 43 | } 44 | 45 | if (attachment.mimeType.startsWith("image/")) { 46 | return ; 47 | } 48 | 49 | return ( 50 | 51 | {attachment.mimeType} {attachment.filename || "no filename?"} 52 | 53 | ); 54 | } 55 | 56 | export function Content({ 57 | content, 58 | contentType, 59 | }: { 60 | content: any; 61 | contentType: ContentTypeId; 62 | }): ReactElement { 63 | if (ContentTypeText.sameAs(contentType)) { 64 | return {content}; 65 | } 66 | 67 | if (ContentTypeReply.sameAs(contentType)) { 68 | const reply: Reply = content; 69 | return ; 70 | } 71 | 72 | return ( 73 | 74 | Unknown content: {JSON.stringify(content)} 75 | 76 | ); 77 | } 78 | 79 | export function MessageContent({ 80 | message, 81 | }: { 82 | message: Message; 83 | }): ReactElement { 84 | if ( 85 | ContentTypeAttachment.sameAs(message.contentType as ContentTypeId) || 86 | ContentTypeRemoteAttachment.sameAs(message.contentType as ContentTypeId) 87 | ) { 88 | return ; 89 | } 90 | 91 | return ( 92 | 96 | ); 97 | } 98 | 99 | export default function MessageCellView({ 100 | message, 101 | readReceiptText, 102 | }: { 103 | message: Message; 104 | readReceiptText: string | undefined; 105 | }): ReactElement { 106 | return ( 107 |
108 | 112 | {shortAddress(message.senderAddress)}: 113 | 114 |
115 | 116 | 117 | 118 | 119 |
120 |
121 | ); 122 | } 123 | -------------------------------------------------------------------------------- /src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/views/MessageComposerView.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ChangeEvent, 3 | FormEvent, 4 | ReactElement, 5 | createRef, 6 | useCallback, 7 | useContext, 8 | useState, 9 | } from "react"; 10 | import Button from "../components/Button"; 11 | import { useClient } from "../hooks/useClient"; 12 | import { Conversation } from "../model/db"; 13 | import { sendMessage } from "../model/messages"; 14 | import { ContentTypeText } from "@xmtp/xmtp-js"; 15 | import { 16 | Attachment, 17 | ContentTypeAttachment, 18 | } from "@xmtp/content-type-remote-attachment"; 19 | import AttachmentPreviewView from "./AttachmentPreviewView"; 20 | import { MessageContent } from "./MessageCellView"; 21 | import { shortAddress } from "../util/shortAddress"; 22 | import { ContentTypeReply, Reply } from "@xmtp/content-type-reply"; 23 | 24 | export default function MessageComposerView({ 25 | conversation, 26 | }: { 27 | conversation: Conversation; 28 | }): ReactElement { 29 | const [loading, setLoading] = useState(false); 30 | const [attachment, setAttachment] = useState(); 31 | const [textInput, setTextInput] = useState(""); 32 | 33 | const fileField = createRef(); 34 | 35 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 36 | const client = useClient()!; 37 | 38 | function onSubmit(e: FormEvent) { 39 | e.preventDefault(); 40 | 41 | (async () => { 42 | setLoading(true); 43 | 44 | // check for input 45 | if (textInput || attachment) { 46 | const finalContent = textInput || attachment; 47 | const finalContentType = textInput 48 | ? ContentTypeText 49 | : ContentTypeAttachment; 50 | // send regular message 51 | await sendMessage(client, conversation, finalContent, finalContentType); 52 | } 53 | 54 | // clear inputs 55 | setAttachment(undefined); 56 | setTextInput(""); 57 | setLoading(false); 58 | })(); 59 | } 60 | 61 | async function onChange(e: ChangeEvent) { 62 | const file = e.target.files && e.target.files[0]; 63 | 64 | if (!file) { 65 | return; 66 | } 67 | 68 | const arrayBuffer = await file.arrayBuffer(); 69 | 70 | setAttachment({ 71 | filename: file.name, 72 | mimeType: file.type, 73 | data: new Uint8Array(arrayBuffer), 74 | }); 75 | 76 | window.scroll({ top: 10000, behavior: "smooth" }); 77 | } 78 | 79 | return ( 80 |
81 | 87 |
88 |
89 | {attachment && ( 90 | { 93 | setAttachment(undefined); 94 | }} 95 | /> 96 | )} 97 |
98 | 105 | setTextInput(e.target.value)} 116 | /> 117 |
118 |
119 | 120 | 123 |
124 |
125 | ); 126 | } 127 | -------------------------------------------------------------------------------- /src/model/reactions.ts: -------------------------------------------------------------------------------- 1 | import { Client, ContentTypeId, ContentTypeText } from "@xmtp/xmtp-js"; 2 | import db, { Message, MessageReaction } from "./db"; 3 | import { findConversation, getXMTPConversation } from "./conversations"; 4 | import { Mutex } from "async-mutex"; 5 | import { ContentTypeReaction, Reaction } from "@xmtp/content-type-reaction"; 6 | import { shortAddress } from "../util/shortAddress"; 7 | import { 8 | ContentTypeAttachment, 9 | ContentTypeRemoteAttachment, 10 | } from "@xmtp/content-type-remote-attachment"; 11 | import { ContentTypeReply } from "@xmtp/content-type-reply"; 12 | 13 | const reactionMutex = new Mutex(); 14 | 15 | const getReactionTo = (message: Message) => { 16 | let reactionTo = ""; 17 | 18 | if ( 19 | ContentTypeAttachment.sameAs(message.contentType as ContentTypeId) || 20 | ContentTypeRemoteAttachment.sameAs(message.contentType as ContentTypeId) 21 | ) { 22 | reactionTo = "to an attachment "; 23 | } 24 | 25 | if (ContentTypeReply.sameAs(message.contentType as ContentTypeId)) { 26 | reactionTo = "to a reply "; 27 | } 28 | 29 | if (ContentTypeText.sameAs(message.contentType as ContentTypeId)) { 30 | reactionTo = `to "${message.content}" `; 31 | } 32 | 33 | return reactionTo; 34 | }; 35 | 36 | export async function addReaction( 37 | reactionName: string, 38 | message: Message, 39 | client: Client | null 40 | ) { 41 | if (!client) { 42 | return; 43 | } 44 | 45 | const conversation = await findConversation(message.conversationTopic); 46 | if (!conversation) { 47 | return; 48 | } 49 | 50 | await persistReaction({ 51 | reactor: client.address, 52 | name: reactionName, 53 | messageXMTPID: message.xmtpID, 54 | }); 55 | 56 | const reaction: Reaction = { 57 | action: "added", 58 | reference: message.xmtpID, 59 | content: reactionName, 60 | schema: "shortcode", 61 | }; 62 | 63 | const xmtpConversation = await getXMTPConversation(client, conversation); 64 | await xmtpConversation.send(reaction, { 65 | contentType: ContentTypeReaction, 66 | contentFallback: `${shortAddress(client.address)} reacted ${getReactionTo( 67 | message 68 | )}with ${reaction.content}`, 69 | }); 70 | } 71 | 72 | export async function removeReaction( 73 | reactionName: string, 74 | message: Message, 75 | client: Client | null 76 | ) { 77 | if (!client) { 78 | return; 79 | } 80 | 81 | const conversation = await findConversation(message.conversationTopic); 82 | 83 | if (!conversation) { 84 | return; 85 | } 86 | 87 | const existing = await db.reactions 88 | .where({ 89 | messageXMTPID: message.xmtpID, 90 | reactor: client.address, 91 | name: reactionName, 92 | }) 93 | .first(); 94 | 95 | if (existing && existing.id) { 96 | db.reactions.delete(existing.id); 97 | } 98 | 99 | const reaction: Reaction = { 100 | action: "removed", 101 | reference: message.xmtpID, 102 | content: reactionName, 103 | schema: "shortcode", 104 | }; 105 | 106 | const xmtpConversation = await getXMTPConversation(client, conversation); 107 | await xmtpConversation.send(reaction, { 108 | contentType: ContentTypeReaction, 109 | contentFallback: `${shortAddress(client.address)} unreacted ${getReactionTo( 110 | message 111 | )}with ${reaction.content}`, 112 | }); 113 | } 114 | 115 | export async function deleteReaction(reaction: MessageReaction) { 116 | await reactionMutex.runExclusive(async () => { 117 | await db.reactions 118 | .where({ 119 | messageXMTPID: reaction.messageXMTPID, 120 | reactor: reaction.reactor, 121 | name: reaction.name, 122 | }) 123 | .delete(); 124 | }); 125 | } 126 | 127 | export async function persistReaction(reaction: MessageReaction) { 128 | await reactionMutex.runExclusive(async () => { 129 | const existing = await db.reactions 130 | .where({ 131 | messageXMTPID: reaction.messageXMTPID, 132 | reactor: reaction.reactor, 133 | name: reaction.name, 134 | }) 135 | .first(); 136 | 137 | if (existing) { 138 | return; 139 | } 140 | 141 | await db.reactions.add(reaction); 142 | }); 143 | } 144 | -------------------------------------------------------------------------------- /src/views/ReactionsView.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement, useState } from "react"; 2 | import { Message, MessageReaction } from "../model/db"; 3 | import { useReactions } from "../hooks/useReactions"; 4 | import { addReaction, removeReaction } from "../model/reactions"; 5 | import { useClient } from "../hooks/useClient"; 6 | import classNames from "classnames"; 7 | 8 | const defaultReactions = { 9 | thumbsup: "👍", 10 | thumbsdown: "👎", 11 | tada: "🎉", 12 | } as const; 13 | 14 | type ReactionString = keyof typeof defaultReactions; 15 | type ReactionEmoji = (typeof defaultReactions)[ReactionString]; 16 | type ReactionEntries = [ReactionString, ReactionEmoji][]; 17 | 18 | function nameToEmoji(name: ReactionString): string { 19 | return defaultReactions[name]; 20 | } 21 | 22 | function ReactionView({ 23 | clientAddress, 24 | name, 25 | reactions, 26 | onAdd, 27 | onRemove, 28 | }: { 29 | clientAddress: string; 30 | name: ReactionString; 31 | reactions: MessageReaction[]; 32 | onAdd: () => void; 33 | onRemove: () => void; 34 | }): ReactElement { 35 | const currentUserReacted = reactions 36 | .map((reaction) => reaction.reactor) 37 | .includes(clientAddress); 38 | 39 | function onClick() { 40 | if (currentUserReacted) { 41 | onRemove(); 42 | } else { 43 | onAdd(); 44 | } 45 | } 46 | 47 | return ( 48 | 59 | ); 60 | } 61 | 62 | export default function ReactionsView({ 63 | message, 64 | }: { 65 | message: Message; 66 | }): ReactElement { 67 | const client = useClient(); 68 | const reactions = useReactions(message) || []; 69 | const [isReacting, setIsReacting] = useState(false); 70 | 71 | const reactionsByName = reactions.reduce((acc, curr) => { 72 | const name = curr.name as ReactionString; 73 | if (acc[name]) { 74 | acc[name]?.push(curr); 75 | } else { 76 | acc[name] = [curr]; 77 | } 78 | return acc; 79 | }, {} as { [key in ReactionString]?: MessageReaction[] }); 80 | 81 | const isFullyReacted = 82 | reactions.length === Object.keys(defaultReactions).length; 83 | 84 | return ( 85 |
86 | {(Object.keys(reactionsByName) as ReactionString[]).map( 87 | (name) => { 88 | return ( 89 | { 95 | addReaction(name, message, client); 96 | setIsReacting(false); 97 | }} 98 | onRemove={() => { 99 | removeReaction(name, message, client); 100 | setIsReacting(false); 101 | }} 102 | /> 103 | ); 104 | } 105 | )} 106 | 107 | {!isFullyReacted ? ( 108 | isReacting ? ( 109 |
110 | {(Object.entries(defaultReactions) as ReactionEntries) 111 | .map(([name, emoji]) => { 112 | if (reactionsByName[name]) { 113 | return null; 114 | } 115 | return ( 116 | 123 | ); 124 | }) 125 | .filter(Boolean)} 126 | 127 | 133 |
134 | ) : ( 135 | 141 | ) 142 | ) : null} 143 |
144 | ); 145 | } 146 | -------------------------------------------------------------------------------- /src/model/messages.ts: -------------------------------------------------------------------------------- 1 | import db, { Conversation, Message } from "./db"; 2 | import { 3 | getXMTPConversation, 4 | stripTopicName, 5 | updateConversationTimestamp, 6 | } from "./conversations"; 7 | import * as XMTP from "@xmtp/xmtp-js"; 8 | import { 9 | ContentTypeAttachment, 10 | ContentTypeRemoteAttachment, 11 | } from "@xmtp/content-type-remote-attachment"; 12 | import { Mutex } from "async-mutex"; 13 | import { upload } from "./attachments"; 14 | import { process } from "./message-processor"; 15 | import { ContentTypeReply, Reply } from "@xmtp/content-type-reply"; 16 | 17 | const messageMutex = new Mutex(); 18 | 19 | export async function sendMessage( 20 | client: XMTP.Client, 21 | conversation: Conversation, 22 | content: any, 23 | contentType: XMTP.ContentTypeId 24 | ): Promise { 25 | const message: Message = { 26 | conversationTopic: stripTopicName(conversation.topic), 27 | inReplyToID: "", 28 | xmtpID: "PENDING-" + new Date().toString(), 29 | senderAddress: client.address, 30 | sentByMe: true, 31 | sentAt: new Date(), 32 | contentType: { ...contentType }, 33 | content: content, 34 | isSending: true, 35 | }; 36 | 37 | await process(client, conversation, { 38 | content, 39 | contentType, 40 | message, 41 | }); 42 | 43 | // process reply content as message 44 | if (contentType.sameAs(ContentTypeReply)) { 45 | const replyContent = content as Reply; 46 | await process(client, conversation, { 47 | content: replyContent.content, 48 | contentType: replyContent.contentType, 49 | message, 50 | }); 51 | } 52 | 53 | // Do the actual sending async 54 | (async () => { 55 | // check if message is a reply that contains an attachment 56 | if ( 57 | contentType.sameAs(ContentTypeReply) && 58 | (content as Reply).contentType.sameAs(ContentTypeAttachment) 59 | ) { 60 | (content as Reply).content = await upload(content.content); 61 | (content as Reply).contentType = ContentTypeRemoteAttachment; 62 | } else { 63 | // Always treat Attachments as remote attachments so we don't send 64 | // huge messages to the network 65 | if (contentType.sameAs(ContentTypeAttachment)) { 66 | content = await upload(content); 67 | contentType = ContentTypeRemoteAttachment; 68 | } 69 | } 70 | 71 | const xmtpConversation = await getXMTPConversation(client, conversation); 72 | const decodedMessage = await xmtpConversation.send(content, { 73 | contentType, 74 | }); 75 | 76 | if (message.contentType.typeId !== "readReceipt") { 77 | await db.messages.update(message.id!, { 78 | xmtpID: decodedMessage.id, 79 | sentAt: decodedMessage.sent, 80 | isSending: false, 81 | }); 82 | } 83 | })(); 84 | 85 | return message; 86 | } 87 | 88 | async function nonMutex(fn: () => Promise) { 89 | return await fn(); 90 | } 91 | 92 | export async function saveMessage( 93 | client: XMTP.Client, 94 | conversation: Conversation, 95 | decodedMessage: XMTP.DecodedMessage, 96 | useMutex = true 97 | ): Promise { 98 | const runner = useMutex 99 | ? messageMutex.runExclusive.bind(messageMutex) 100 | : nonMutex; 101 | 102 | return await runner(async () => { 103 | const existing = await db.messages 104 | .where("xmtpID") 105 | .equals(decodedMessage.id) 106 | .first(); 107 | 108 | if (existing) { 109 | return existing; 110 | } 111 | 112 | const message: Message = { 113 | conversationTopic: stripTopicName(decodedMessage.contentTopic), 114 | inReplyToID: "", 115 | xmtpID: decodedMessage.id, 116 | senderAddress: decodedMessage.senderAddress, 117 | sentByMe: decodedMessage.senderAddress == client.address, 118 | sentAt: decodedMessage.sent, 119 | contentType: { ...decodedMessage.contentType }, 120 | content: decodedMessage.content, 121 | isSending: false, 122 | }; 123 | 124 | await process(client, conversation, { 125 | content: decodedMessage.content, 126 | contentType: decodedMessage.contentType, 127 | message, 128 | }); 129 | 130 | // process reply content as message 131 | if (decodedMessage.contentType.sameAs(ContentTypeReply)) { 132 | const replyContent = decodedMessage.content as Reply; 133 | await process(client, conversation, { 134 | content: replyContent.content, 135 | contentType: replyContent.contentType, 136 | message, 137 | }); 138 | } 139 | 140 | await updateConversationTimestamp( 141 | message.conversationTopic, 142 | message.sentAt 143 | ); 144 | 145 | return message; 146 | }); 147 | } 148 | 149 | export async function loadMessages( 150 | conversation: Conversation, 151 | client: XMTP.Client 152 | ) { 153 | const xmtpConversation = await getXMTPConversation(client, conversation); 154 | for (const message of await xmtpConversation.messages()) { 155 | saveMessage(client, conversation, message, true); 156 | } 157 | 158 | for await (const message of await xmtpConversation.streamMessages()) { 159 | await saveMessage(client, conversation, message); 160 | } 161 | } 162 | --------------------------------------------------------------------------------