├── .babelrc ├── .eslintrc.json ├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ ├── build.yml │ ├── lint.yml │ └── test.yml ├── .gitignore ├── .nvmrc ├── .prettierrc.js ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── components ├── Address.test.tsx ├── Address.tsx ├── AddressInput.test.tsx ├── AddressInput.tsx ├── AddressPill.tsx ├── App.tsx ├── Avatar.tsx ├── BackArrow.tsx ├── Conversation │ ├── Conversation.tsx │ ├── MessageComposer.tsx │ ├── MessagesList.tsx │ ├── RecipientControl.tsx │ └── index.ts ├── ConversationsList.tsx ├── Layout.tsx ├── Loader.tsx ├── NavigationPanel.tsx ├── NewMessageButton.tsx ├── Tooltip │ └── Tooltip.tsx ├── UserMenu.tsx ├── Views │ ├── ConversationView.tsx │ ├── NavigationView.tsx │ └── index.ts └── XmtpInfoPanel.tsx ├── helpers ├── appVersion.ts ├── classNames.ts ├── constants.ts ├── env.ts ├── getUniqueMessages.ts ├── index.ts ├── keys.ts └── string.ts ├── hooks ├── useEns.ts ├── useGetMessages.ts ├── useInitXmtpClient.ts ├── useListConversations.tsx ├── useSendMessage.ts ├── useWalletProvider.tsx └── useWindowSize.ts ├── jest.config.js ├── jest.setup.ts ├── next-env.d.ts ├── next.config.js ├── package-lock.json ├── package.json ├── pages ├── 404.tsx ├── _app.tsx ├── _document.tsx ├── dm │ ├── [...recipientWalletAddr].tsx │ └── index.tsx └── index.tsx ├── postcss.config.js ├── public ├── favicon.ico ├── up-arrow-green.svg ├── up-arrow-grey.svg ├── xmtp-icon.png └── xmtp-logo.png ├── store └── app.tsx ├── styles ├── Home.module.css ├── Loader.module.css ├── MessageComposer.module.css └── globals.css ├── tailwind.config.js ├── tsconfig.json └── types └── global.d.ts /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["next/babel"] 3 | } 4 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["@typescript-eslint"], 3 | "extends": [ 4 | "next/core-web-vitals", 5 | "plugin:@typescript-eslint/recommended", 6 | "prettier" // Add "prettier" last. This will turn off eslint rules conflicting with prettier. This is not what formats the code. 7 | ], 8 | "rules": { 9 | "@typescript-eslint/no-unused-vars": "error", 10 | "@typescript-eslint/no-explicit-any": "error", 11 | "@next/next/no-img-element": "off", 12 | "curly": "error" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a bug report 4 | title: 'Bug: ' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Describe the bug 11 | 12 | A clear and concise description of what the bug is. 13 | 14 | Include **1 bug per issue**. Please do not include multiple bugs or pieces of feedback in 1 issue. Creating 1 issue per bug will make it easier for us to assign and track progress on bugs. 15 | 16 | ## Steps to Reproduce 17 | 18 | Steps to reproduce the behavior: 19 | 20 | 1. Go to '...' (include a link, if helpful) 21 | 2. Click on '....' 22 | 3. Scroll down to '....' 23 | 4. See error 24 | 25 | ## Expected behavior 26 | 27 | A clear and concise description of what you expected to happen. 28 | 29 | ## Screenshots 30 | 31 | If applicable, add screenshots to help explain your problem. 32 | 33 | ## Additional context 34 | 35 | Add any other context about the problem here. 36 | 37 | ### Desktop 38 | 39 | Please complete the following information if you think it is relevant to the bug: 40 | 41 | - OS & Version: [e.g. macOS 12.3] 42 | - Browser & Version: [e.g. Chrome 105.0.5195.102, Safari 15.4] 43 | 44 | ### Smartphone 45 | 46 | Please complete the following information if you think it is relevant to the bug: 47 | 48 | - Device: [e.g. iPhone 14] 49 | - OS: [e.g. iOS 16.1] 50 | - Browser: [e.g. Safari, Chrome] 51 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | jobs: 8 | build: 9 | name: Build 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions/setup-node@v2 14 | with: 15 | node-version-file: ".nvmrc" 16 | - name: Authenticate with NPM for private package 17 | run: echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc 18 | - run: npm install 19 | - run: npm run build 20 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | jobs: 8 | lint: 9 | name: Lint 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions/setup-node@v2 14 | with: 15 | node-version-file: ".nvmrc" 16 | - name: Authenticate with NPM for private package 17 | run: echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc 18 | - run: npm install 19 | - run: npm run lint 20 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | jobs: 8 | test: 9 | name: Test 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions/setup-node@v2 14 | with: 15 | node-version-file: ".nvmrc" 16 | - name: Authenticate with NPM for private package 17 | run: echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc 18 | - run: npm install 19 | - run: npm run test 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | 36 | # typescript 37 | *.tsbuildinfo 38 | 39 | scratch 40 | 41 | .npmrc 42 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 16.16.0 2 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: false, 3 | singleQuote: true, 4 | trailingComma: 'es5', 5 | arrowParens: 'always', 6 | printWidth: 80, 7 | endOfLine: 'auto', 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.fixAll.eslint": true 4 | }, 5 | "editor.formatOnSave": true, 6 | "editor.defaultFormatter": "esbenp.prettier-vscode", 7 | "editor.tabSize": 2, 8 | "editor.detectIndentation": false, 9 | "files.insertFinalNewline": true 10 | } 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 XMTP Labs (xmtp.com) 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Chat Example 2 | 3 | ![Status](https://img.shields.io/badge/Project_Status-Archived-red) 4 | 5 | ![x-red-sm](https://user-images.githubusercontent.com/510695/163488403-1fb37e86-c673-4b48-954e-8460ae4d4b05.png) 6 | 7 | The XMTP React Chat example app has been archived and this repo is no longer being maintained. 8 | 9 | Check out these other supported app repos you can use for guidance and inspiration when building with XMTP: 10 | 11 | - The [XMTP Quickstart React chat app](https://github.com/xmtp/xmtp-quickstart-react) demonstrates the core capabilities of the XMTP client SDK, providing code you can use to learn to build a basic messaging app. 12 | - The [XMTP Inbox chat app](https://github.com/xmtp-labs/xmtp-inbox-web) demonstrates core and advanced capabilities of the XMTP client SDK, showcasing innovative ways of building with XMTP. This app is an evolution of the XMTP React Chat example app previously maintained in this repo. 13 | 14 | For even more examples of apps built with XMTP, see: 15 | 16 | - [Awesome-xtmp](https://github.com/xmtp/awesome-xmtp) 17 | - [Built with XMTP](https://xmtp.org/built-with-xmtp) 18 | 19 | You can still fork this repo and existing forks continue to work. 20 | 21 | --- 22 | 23 | **Example chat application demonstrating the core concepts and capabilities of the XMTP client SDK** 24 | 25 | This application is built with React, [Next.js](https://nextjs.org/), and the [`xmtp-js` client SDK](https://github.com/xmtp/xmtp-js). 26 | 27 | Use the application to send and receive messages using the XMTP `dev` network environment, with some [important considerations](#considerations). You are also free to customize and deploy the application. 28 | 29 | This application is distributed under [MIT License](./LICENSE) for learning about and developing applications built with XMTP, a messaging protocol and decentralized communication network for blockchain wallets. The application has not undergone a formal security audit. 30 | 31 | ## Getting Started 32 | 33 | ### Configure Infura 34 | 35 | Add your Infura ID to `.env.local` in the project's root. 36 | 37 | ``` 38 | NEXT_PUBLIC_INFURA_ID={YOUR_INFURA_ID} 39 | ``` 40 | 41 | If you do not have an Infura ID, you can follow [these instructions](https://blog.infura.io/getting-started-with-infura-28e41844cc89/) to get one. 42 | 43 | ### Install the package 44 | 45 | ```bash 46 | npm install 47 | ``` 48 | 49 | ### Run the development server 50 | 51 | ```bash 52 | npm run dev 53 | ``` 54 | 55 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the application. 56 | 57 | ## Functionality 58 | 59 | ### Wallet Connections 60 | 61 | [`Web3Modal`](https://github.com/Web3Modal/web3modal) is used to inject a Metamask, Coinbase Wallet, or WalletConnect provider through [`ethers`](https://docs.ethers.io/v5/). Methods for connecting and disconnecting are included in `WalletProvider` alongside the provider, signer, wallet address, and ENS utilities. 62 | 63 | To use the application's chat functionality, the connected wallet must provide two signatures: 64 | 65 | 1. A one-time signature that is used to generate the wallet's private XMTP identity 66 | 2. A signature that is used on application start-up to initialize the XMTP client with that identity 67 | 68 | ### Chat Conversations 69 | 70 | The application uses the `xmtp-js` [Conversations](https://github.com/xmtp/xmtp-js#conversations) abstraction to list the available conversations for a connected wallet and to listen for or create new conversations. For each conversation, the application gets existing messages and listens for or creates new messages. Conversations and messages are kept in a lightweight store and made available through `XmtpProvider`. 71 | 72 | ### Considerations 73 | 74 | Here are some important considerations when working with the example chat application: 75 | 76 | - The application sends and receives messages using the XMTP `dev` network environment. To connect to the `production` network instead, set the following environment variable `NEXT_PUBLIC_XMTP_ENVIRONMENT=production`. 77 | - XMTP may occasionally delete messages and keys from the `dev` network, and will provide advance notice in the XMTP Discord community ([request access](https://xmtp.typeform.com/to/yojTJarb?utm_source=docs_home)). The `production` network is configured to store messages indefinitely. 78 | - You can't yet send a message to a wallet address that hasn't used XMTP. The client displays an error when it looks up an address that doesn't have an identity broadcast on the XMTP network. 79 | -------------------------------------------------------------------------------- /components/Address.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render, act } from '@testing-library/react' 3 | import { waitFor } from '@testing-library/dom' 4 | import Address from './Address' 5 | import * as nextRouter from 'next/router' 6 | 7 | // @ts-expect-error mocked next router 8 | nextRouter.useRouter = jest.fn() 9 | 10 | // @ts-expect-error mocked next router 11 | nextRouter.useRouter.mockImplementation(() => ({ route: '/' })) 12 | 13 | describe('Address', () => { 14 | it('renders value', () => { 15 | const { container } = render(
) 16 | expect(container.textContent).toBe('0xfoo') 17 | expect(container.querySelector('div > span')).toHaveAttribute( 18 | 'title', 19 | '0xfoo' 20 | ) 21 | }) 22 | 23 | it('renders lookup', async () => { 24 | let text: string | null 25 | let span: Element | null 26 | act(() => { 27 | const { container } = render(
) 28 | text = container.textContent 29 | span = container.querySelector('div > span') 30 | }) 31 | waitFor(() => expect(text).toBe('foo.eth')) 32 | waitFor(() => expect(span).toHaveAttribute('title', '0xfoo')) 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /components/Address.tsx: -------------------------------------------------------------------------------- 1 | import { classNames, shortAddress } from '../helpers' 2 | import useEns from '../hooks/useEns' 3 | 4 | type AddressProps = { 5 | address: string 6 | className?: string 7 | } 8 | 9 | const Address = ({ address, className }: AddressProps): JSX.Element => { 10 | const { name, loading } = useEns(address) 11 | 12 | return ( 13 | 21 | {name || shortAddress(address)} 22 | 23 | ) 24 | } 25 | 26 | export default Address 27 | -------------------------------------------------------------------------------- /components/AddressInput.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render, fireEvent, act } from '@testing-library/react' 3 | import { waitFor } from '@testing-library/dom' 4 | import AddressInput from './AddressInput' 5 | import * as nextRouter from 'next/router' 6 | 7 | // @ts-expect-error mocked next router 8 | nextRouter.useRouter = jest.fn() 9 | 10 | // @ts-expect-error mocked next router 11 | nextRouter.useRouter.mockImplementation(() => ({ route: '/' })) 12 | 13 | describe('AddressInput', () => { 14 | it('renders no initial value', () => { 15 | let input: HTMLInputElement | null 16 | act(() => { 17 | const { container } = render() 18 | input = container.querySelector('input') 19 | }) 20 | waitFor(() => expect(input).toHaveAttribute('value', '')) 21 | }) 22 | 23 | it('renders initial value', () => { 24 | let input: HTMLInputElement | null 25 | act(() => { 26 | const { container } = render( 27 | 28 | ) 29 | input = container.querySelector('input') 30 | }) 31 | waitFor(() => expect(input).toHaveAttribute('value', '0xfoo.eth')) 32 | }) 33 | 34 | it('renders lookup for initial value', async () => { 35 | let input: HTMLInputElement | null 36 | act(() => { 37 | const { container } = render( 38 | 39 | ) 40 | input = container.querySelector('input') 41 | }) 42 | waitFor(() => expect(input).toHaveAttribute('value', 'foo.eth')) 43 | }) 44 | 45 | it('renders lookup for changed value', async () => { 46 | let input: HTMLInputElement | null 47 | act(() => { 48 | const rerenderWithInputValue = (value: string) => 49 | rerender() 50 | const { container, rerender } = render( 51 | { 54 | const data = event.target as typeof event.target & { 55 | value: string 56 | } 57 | rerenderWithInputValue(data.value) 58 | }} 59 | /> 60 | ) 61 | input = container.querySelector('input') 62 | input && fireEvent.change(input, { target: { value: '0xfoo' } }) 63 | }) 64 | waitFor(() => expect(input).toHaveAttribute('value', 'foo.eth')) 65 | }) 66 | }) 67 | -------------------------------------------------------------------------------- /components/AddressInput.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState, useRef, useCallback } from 'react' 2 | import { isEns, classNames, is0xAddress } from '../helpers' 3 | import useWalletProvider from '../hooks/useWalletProvider' 4 | import { useAppStore } from '../store/app' 5 | 6 | type AddressInputProps = { 7 | recipientWalletAddress?: string 8 | id?: string 9 | name?: string 10 | className?: string 11 | placeholder?: string 12 | onInputChange?: (e: React.SyntheticEvent) => Promise 13 | } 14 | 15 | const AddressInput = ({ 16 | recipientWalletAddress, 17 | id, 18 | name, 19 | className, 20 | placeholder, 21 | onInputChange, 22 | }: AddressInputProps): JSX.Element => { 23 | const { lookupAddress } = useWalletProvider() 24 | const walletAddress = useAppStore((state) => state.address) 25 | const inputElement = useRef(null) 26 | const [value, setValue] = useState(recipientWalletAddress || '') 27 | const conversations = useAppStore((state) => state.conversations) 28 | const setConversations = useAppStore((state) => state.setConversations) 29 | const client = useAppStore((state) => state.client) 30 | 31 | const focusInputElementRef = () => { 32 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 33 | ;(inputElement.current as any)?.focus() 34 | } 35 | 36 | useEffect(() => { 37 | if (!recipientWalletAddress) { 38 | focusInputElementRef() 39 | setValue('') 40 | } 41 | }, [recipientWalletAddress]) 42 | 43 | useEffect(() => { 44 | const setLookupValue = async () => { 45 | if (!lookupAddress) { 46 | return 47 | } 48 | if (recipientWalletAddress && !isEns(recipientWalletAddress)) { 49 | const name = await lookupAddress(recipientWalletAddress) 50 | const conversation = await client?.conversations.newConversation( 51 | recipientWalletAddress 52 | ) 53 | if (conversation) { 54 | conversations.set(recipientWalletAddress, conversation) 55 | setConversations(new Map(conversations)) 56 | } 57 | if (name) { 58 | setValue(name) 59 | } else if (recipientWalletAddress) { 60 | setValue(recipientWalletAddress) 61 | } 62 | } else if (is0xAddress(value)) { 63 | const conversation = await client?.conversations.newConversation(value) 64 | if (conversation) { 65 | conversations.set(value, conversation) 66 | setConversations(new Map(conversations)) 67 | } 68 | const name = await lookupAddress(value) 69 | if (name) { 70 | setValue(name) 71 | } 72 | } 73 | } 74 | setLookupValue() 75 | }, [value, recipientWalletAddress, lookupAddress]) 76 | 77 | const userIsSender = recipientWalletAddress === walletAddress 78 | 79 | const recipientPillInputStyle = classNames( 80 | 'absolute', 81 | 'top-[4px] md:top-[2px]', 82 | 'left-[26px] md:left-[23px]', 83 | 'rounded-2xl', 84 | 'px-[5px] md:px-2', 85 | 'border', 86 | 'text-md', 87 | 'focus:outline-none', 88 | 'focus:ring-0', 89 | 'font-bold', 90 | 'font-mono', 91 | 'overflow-visible', 92 | 'text-center', 93 | 'text-transparent', 94 | 'select-none', 95 | userIsSender ? 'bg-bt-100' : 'bg-zinc-50', 96 | userIsSender ? 'border-bt-300' : 'border-gray-300' 97 | ) 98 | 99 | const onAddressChange = useCallback( 100 | async (event: React.SyntheticEvent) => { 101 | const data = event.target as typeof event.target & { 102 | value: string 103 | } 104 | setValue(data.value.trim()) 105 | onInputChange && onInputChange(event) 106 | }, 107 | [onInputChange] 108 | ) 109 | 110 | return ( 111 |
112 | {recipientWalletAddress && value && ( 113 | {value} 114 | )} 115 | 133 |
134 | ) 135 | } 136 | 137 | export default AddressInput 138 | -------------------------------------------------------------------------------- /components/AddressPill.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { classNames } from '../helpers' 3 | import { useAppStore } from '../store/app' 4 | import Address from './Address' 5 | 6 | type addressPillProps = { 7 | address: string 8 | } 9 | 10 | const AddressPill = ({ address }: addressPillProps): JSX.Element => { 11 | const walletAddress = useAppStore((state) => state.address) 12 | const userIsSender = address === walletAddress 13 | return ( 14 |
28 | ) 29 | } 30 | 31 | export default AddressPill 32 | -------------------------------------------------------------------------------- /components/App.tsx: -------------------------------------------------------------------------------- 1 | import Layout from '../components/Layout' 2 | 3 | type AppProps = { 4 | children?: React.ReactNode 5 | } 6 | 7 | function App({ children }: AppProps) { 8 | return {children} 9 | } 10 | 11 | export default App 12 | -------------------------------------------------------------------------------- /components/Avatar.tsx: -------------------------------------------------------------------------------- 1 | import Blockies from 'react-blockies' 2 | import useEns from '../hooks/useEns' 3 | 4 | type AvatarProps = { 5 | peerAddress: string 6 | } 7 | 8 | const Avatar = ({ peerAddress }: AvatarProps) => { 9 | const { avatarUrl, loading } = useEns(peerAddress) 10 | if (loading) { 11 | return ( 12 |
13 |
14 |
15 | ) 16 | } 17 | if (avatarUrl) { 18 | return ( 19 |
20 |
21 | {peerAddress} 26 |
27 | ) 28 | } 29 | return 30 | } 31 | 32 | export default Avatar 33 | -------------------------------------------------------------------------------- /components/BackArrow.tsx: -------------------------------------------------------------------------------- 1 | import { ChevronLeftIcon } from '@heroicons/react/outline' 2 | 3 | type BackArrowProps = { 4 | onClick: () => void 5 | } 6 | 7 | const BackArrow = ({ onClick }: BackArrowProps): JSX.Element => ( 8 | 16 | ) 17 | 18 | export default BackArrow 19 | -------------------------------------------------------------------------------- /components/Conversation/Conversation.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useState } from 'react' 2 | import { MessagesList, MessageComposer } from './' 3 | import Loader from '../../components/Loader' 4 | import { useAppStore } from '../../store/app' 5 | import useGetMessages from '../../hooks/useGetMessages' 6 | import useSendMessage from '../../hooks/useSendMessage' 7 | import { getConversationKey } from '../../helpers' 8 | 9 | type ConversationProps = { 10 | recipientWalletAddr: string 11 | } 12 | 13 | const Conversation = ({ 14 | recipientWalletAddr, 15 | }: ConversationProps): JSX.Element => { 16 | const conversations = useAppStore((state) => state.conversations) 17 | const selectedConversation = conversations.get(recipientWalletAddr) 18 | const conversationKey = getConversationKey(selectedConversation) 19 | 20 | const { sendMessage } = useSendMessage(selectedConversation) 21 | 22 | const [endTime, setEndTime] = useState>(new Map()) 23 | 24 | const { convoMessages: messages, hasMore } = useGetMessages( 25 | conversationKey, 26 | endTime.get(conversationKey) 27 | ) 28 | 29 | const loadingConversations = useAppStore( 30 | (state) => state.loadingConversations 31 | ) 32 | 33 | const fetchNextMessages = useCallback(() => { 34 | if ( 35 | hasMore && 36 | Array.isArray(messages) && 37 | messages.length > 0 && 38 | conversationKey 39 | ) { 40 | const lastMsgDate = messages[messages.length - 1].sent 41 | const currentEndTime = endTime.get(conversationKey) 42 | if (!currentEndTime || lastMsgDate <= currentEndTime) { 43 | endTime.set(conversationKey, lastMsgDate) 44 | setEndTime(new Map(endTime)) 45 | } 46 | } 47 | }, [conversationKey, hasMore, messages, endTime]) 48 | 49 | const hasMessages = Number(messages?.length ?? 0) > 0 50 | 51 | if (loadingConversations && !hasMessages) { 52 | return ( 53 | 58 | ) 59 | } 60 | 61 | return ( 62 | <> 63 |
64 |
65 | 70 |
71 |
72 | 73 | 74 | ) 75 | } 76 | 77 | export default React.memo(Conversation) 78 | -------------------------------------------------------------------------------- /components/Conversation/MessageComposer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import { classNames } from '../../helpers' 3 | import messageComposerStyles from '../../styles/MessageComposer.module.css' 4 | import upArrowGreen from '../../public/up-arrow-green.svg' 5 | import upArrowGrey from '../../public/up-arrow-grey.svg' 6 | import { useRouter } from 'next/router' 7 | import Image from 'next/image' 8 | 9 | type MessageComposerProps = { 10 | onSend: (msg: string) => Promise 11 | } 12 | 13 | const MessageComposer = ({ onSend }: MessageComposerProps): JSX.Element => { 14 | const [message, setMessage] = useState('') 15 | const router = useRouter() 16 | 17 | useEffect(() => setMessage(''), [router.query.recipientWalletAddr]) 18 | 19 | const onMessageChange = (e: React.FormEvent) => 20 | setMessage(e.currentTarget.value) 21 | 22 | const onSubmit = async (e: React.FormEvent) => { 23 | e.preventDefault() 24 | if (!message) { 25 | return 26 | } 27 | setMessage('') 28 | await onSend(message) 29 | } 30 | 31 | return ( 32 |
33 |
49 | 65 | 72 |
73 |
74 | ) 75 | } 76 | 77 | export default MessageComposer 78 | -------------------------------------------------------------------------------- /components/Conversation/MessagesList.tsx: -------------------------------------------------------------------------------- 1 | import { DecodedMessage } from '@xmtp/xmtp-js' 2 | import React, { FC } from 'react' 3 | import Emoji from 'react-emoji-render' 4 | import Avatar from '../Avatar' 5 | import { formatTime } from '../../helpers' 6 | import AddressPill from '../AddressPill' 7 | import InfiniteScroll from 'react-infinite-scroll-component' 8 | import useWindowSize from '../../hooks/useWindowSize' 9 | 10 | export type MessageListProps = { 11 | messages: DecodedMessage[] 12 | fetchNextMessages: () => void 13 | hasMore: boolean 14 | } 15 | 16 | type MessageTileProps = { 17 | message: DecodedMessage 18 | } 19 | 20 | const isOnSameDay = (d1?: Date, d2?: Date): boolean => { 21 | return d1?.toDateString() === d2?.toDateString() 22 | } 23 | 24 | const formatDate = (d?: Date) => 25 | d?.toLocaleString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) 26 | 27 | const MessageTile = ({ message }: MessageTileProps): JSX.Element => ( 28 |
29 | 30 |
31 |
32 | 33 | 34 | {formatTime(message.sent)} 35 | 36 |
37 | 38 | {message.error ? ( 39 | `Error: ${message.error?.message}` 40 | ) : ( 41 | 42 | )} 43 | 44 |
45 |
46 | ) 47 | 48 | const DateDividerBorder: React.FC<{ children?: React.ReactNode }> = ({ 49 | children, 50 | }) => ( 51 | <> 52 |
53 | {children} 54 |
55 | 56 | ) 57 | 58 | const DateDivider = ({ date }: { date?: Date }): JSX.Element => ( 59 |
60 | 61 | 62 | {formatDate(date)} 63 | 64 | 65 |
66 | ) 67 | 68 | const ConversationBeginningNotice = (): JSX.Element => ( 69 |
70 | 71 | This is the beginning of the conversation 72 | 73 |
74 | ) 75 | 76 | const LoadingMore: FC = () => ( 77 |
78 | Loading Messages... 79 |
80 | ) 81 | 82 | const MessagesList = ({ 83 | messages, 84 | fetchNextMessages, 85 | hasMore, 86 | }: MessageListProps): JSX.Element => { 87 | let lastMessageDate: Date | undefined 88 | const size = useWindowSize() 89 | 90 | return ( 91 | 700 ? '87vh' : '83vh'} 96 | inverse 97 | endMessage={} 98 | hasMore={hasMore} 99 | loader={} 100 | > 101 | {messages?.map((msg: DecodedMessage, index: number) => { 102 | const dateHasChanged = lastMessageDate 103 | ? !isOnSameDay(lastMessageDate, msg.sent) 104 | : false 105 | const messageDiv = ( 106 |
107 | 108 | {dateHasChanged ? : null} 109 |
110 | ) 111 | lastMessageDate = msg.sent 112 | return messageDiv 113 | })} 114 |
115 | ) 116 | } 117 | 118 | export default React.memo(MessagesList) 119 | -------------------------------------------------------------------------------- /components/Conversation/RecipientControl.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useCallback } from 'react' 2 | import { useRouter } from 'next/router' 3 | import AddressInput from '../AddressInput' 4 | import { isEns, getAddressFromPath, is0xAddress } from '../../helpers' 5 | import { useAppStore } from '../../store/app' 6 | import useWalletProvider from '../../hooks/useWalletProvider' 7 | import BackArrow from '../BackArrow' 8 | 9 | const RecipientInputMode = { 10 | InvalidEntry: 0, 11 | ValidEntry: 1, 12 | FindingEntry: 2, 13 | Submitted: 3, 14 | NotOnNetwork: 4, 15 | } 16 | 17 | const RecipientControl = (): JSX.Element => { 18 | const { resolveName, lookupAddress } = useWalletProvider() 19 | const client = useAppStore((state) => state.client) 20 | const router = useRouter() 21 | const recipientWalletAddress = getAddressFromPath(router) 22 | const [recipientInputMode, setRecipientInputMode] = useState( 23 | RecipientInputMode.InvalidEntry 24 | ) 25 | const [hasName, setHasName] = useState(false) 26 | 27 | const checkIfOnNetwork = useCallback( 28 | async (address: string): Promise => { 29 | return client?.canMessage(address) || false 30 | }, 31 | [client] 32 | ) 33 | 34 | const onSubmit = async (address: string) => { 35 | router.push(address ? `/dm/${address}` : '/dm/') 36 | } 37 | 38 | const handleBackArrowClick = useCallback(() => { 39 | router.push('/') 40 | }, [router]) 41 | 42 | const completeSubmit = async (address: string, input: HTMLInputElement) => { 43 | if (await checkIfOnNetwork(address)) { 44 | onSubmit(address) 45 | input.blur() 46 | setRecipientInputMode(RecipientInputMode.Submitted) 47 | } else { 48 | setRecipientInputMode(RecipientInputMode.NotOnNetwork) 49 | } 50 | } 51 | 52 | useEffect(() => { 53 | const handleAddressLookup = async (address: string) => { 54 | const name = await lookupAddress(address) 55 | setHasName(!!name) 56 | } 57 | if (recipientWalletAddress && !isEns(recipientWalletAddress)) { 58 | setRecipientInputMode(RecipientInputMode.Submitted) 59 | handleAddressLookup(recipientWalletAddress) 60 | } else { 61 | setRecipientInputMode(RecipientInputMode.InvalidEntry) 62 | } 63 | }, [lookupAddress, recipientWalletAddress]) 64 | 65 | const handleSubmit = useCallback( 66 | async (e: React.SyntheticEvent, value?: string) => { 67 | e.preventDefault() 68 | const data = e.target as typeof e.target & { 69 | recipient: { value: string } 70 | } 71 | const input = e.target as HTMLInputElement 72 | const recipientValue = value || data.recipient.value 73 | if (isEns(recipientValue)) { 74 | setRecipientInputMode(RecipientInputMode.FindingEntry) 75 | const address = await resolveName(recipientValue) 76 | if (address) { 77 | await completeSubmit(address, input) 78 | } else { 79 | setRecipientInputMode(RecipientInputMode.InvalidEntry) 80 | } 81 | } else if (is0xAddress(recipientValue)) { 82 | await completeSubmit(recipientValue, input) 83 | } 84 | }, 85 | [resolveName] 86 | ) 87 | 88 | const handleInputChange = useCallback( 89 | async (e: React.SyntheticEvent) => { 90 | const data = e.target as typeof e.target & { 91 | value: string 92 | } 93 | if (router.pathname !== '/dm') { 94 | router.push('/dm') 95 | } 96 | if (isEns(data.value) || is0xAddress(data.value)) { 97 | handleSubmit(e, data.value) 98 | } else { 99 | setRecipientInputMode(RecipientInputMode.InvalidEntry) 100 | } 101 | }, 102 | [handleSubmit, router] 103 | ) 104 | 105 | return ( 106 | <> 107 |
108 | 109 |
110 |
111 |
117 | 120 |
121 |
122 | To: 123 |
124 | 131 |
133 |
134 | 135 | {recipientInputMode === RecipientInputMode.Submitted ? ( 136 |
137 | {hasName ? recipientWalletAddress :
} 138 |
139 | ) : ( 140 |
141 | {recipientInputMode === RecipientInputMode.NotOnNetwork && 142 | 'Recipient is not on the XMTP network'} 143 | {recipientInputMode === RecipientInputMode.FindingEntry && 144 | 'Finding ENS domain...'} 145 | {recipientInputMode === RecipientInputMode.InvalidEntry && 146 | 'Please enter a valid wallet address'} 147 | {recipientInputMode === RecipientInputMode.ValidEntry &&
} 148 |
149 | )} 150 |
151 | 152 | ) 153 | } 154 | 155 | export default RecipientControl 156 | -------------------------------------------------------------------------------- /components/Conversation/index.ts: -------------------------------------------------------------------------------- 1 | export { default as RecipientControl } from './RecipientControl' 2 | export { default as MessagesList } from './MessagesList' 3 | export { default as MessageComposer } from './MessageComposer' 4 | export { default as Conversation } from './Conversation' 5 | -------------------------------------------------------------------------------- /components/ConversationsList.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import { ChatIcon } from '@heroicons/react/outline' 3 | import Address from './Address' 4 | import { useRouter } from 'next/router' 5 | import { Conversation } from '@xmtp/xmtp-js' 6 | import { classNames, formatDate, getConversationKey } from '../helpers' 7 | import Avatar from './Avatar' 8 | import { useAppStore } from '../store/app' 9 | 10 | type ConversationTileProps = { 11 | conversation: Conversation 12 | } 13 | 14 | const ConversationTile = ({ 15 | conversation, 16 | }: ConversationTileProps): JSX.Element | null => { 17 | const router = useRouter() 18 | const address = useAppStore((state) => state.address) 19 | const previewMessages = useAppStore((state) => state.previewMessages) 20 | const loadingConversations = useAppStore( 21 | (state) => state.loadingConversations 22 | ) 23 | const [recipentAddress, setRecipentAddress] = useState() 24 | 25 | useEffect(() => { 26 | const routeAddress = 27 | (Array.isArray(router.query.recipientWalletAddr) 28 | ? router.query.recipientWalletAddr.join('/') 29 | : router.query.recipientWalletAddr) ?? '' 30 | setRecipentAddress(routeAddress) 31 | }, [router.query.recipientWalletAddr]) 32 | 33 | useEffect(() => { 34 | if (!recipentAddress && window.location.pathname.includes('/dm')) { 35 | router.push(window.location.pathname) 36 | setRecipentAddress(window.location.pathname.replace('/dm/', '')) 37 | } 38 | }, [recipentAddress, window.location.pathname]) 39 | 40 | if (!previewMessages.get(getConversationKey(conversation))) { 41 | return null 42 | } 43 | 44 | const latestMessage = previewMessages.get(getConversationKey(conversation)) 45 | 46 | const conversationDomain = 47 | conversation.context?.conversationId.split('/')[0] ?? '' 48 | 49 | const isSelected = recipentAddress === getConversationKey(conversation) 50 | 51 | if (!latestMessage) { 52 | return null 53 | } 54 | 55 | const onClick = (path: string) => { 56 | router.push(path) 57 | } 58 | 59 | return ( 60 |
onClick(`/dm/${getConversationKey(conversation)}`)} 62 | className={classNames( 63 | 'h-20', 64 | 'py-2', 65 | 'px-4', 66 | 'md:max-w-sm', 67 | 'mx-auto', 68 | 'bg-white', 69 | 'space-y-2', 70 | 'py-2', 71 | 'flex', 72 | 'items-center', 73 | 'space-y-0', 74 | 'space-x-4', 75 | 'border-b-2', 76 | 'border-gray-100', 77 | 'hover:bg-bt-100', 78 | 'cursor-pointer', 79 | loadingConversations ? 'opacity-80' : 'opacity-100', 80 | isSelected ? 'bg-bt-200' : null 81 | )} 82 | > 83 | 84 |
85 | {conversationDomain && ( 86 |
87 | {conversationDomain.toLocaleUpperCase()} 88 |
89 | )} 90 |
91 |
95 | 102 | {formatDate(latestMessage?.sent)} 103 | 104 |
105 | 106 | {address === latestMessage?.senderAddress && 'You: '}{' '} 107 | {latestMessage?.content} 108 | 109 |
110 |
111 | ) 112 | } 113 | 114 | const ConversationsList = (): JSX.Element => { 115 | const conversations = useAppStore((state) => state.conversations) 116 | const previewMessages = useAppStore((state) => state.previewMessages) 117 | 118 | const orderByLatestMessage = ( 119 | convoA: Conversation, 120 | convoB: Conversation 121 | ): number => { 122 | const convoALastMessageDate = 123 | previewMessages.get(getConversationKey(convoA))?.sent || new Date() 124 | const convoBLastMessageDate = 125 | previewMessages.get(getConversationKey(convoB))?.sent || new Date() 126 | return convoALastMessageDate < convoBLastMessageDate ? 1 : -1 127 | } 128 | 129 | if (!conversations || conversations.size == 0) { 130 | return 131 | } 132 | 133 | return ( 134 | <> 135 | {conversations && 136 | conversations.size > 0 && 137 | Array.from(conversations.values()) 138 | .sort(orderByLatestMessage) 139 | .map((convo) => { 140 | return ( 141 | 145 | ) 146 | })} 147 | 148 | ) 149 | } 150 | 151 | const NoConversationsMessage = (): JSX.Element => { 152 | return ( 153 |
154 |
155 |
166 |
167 | ) 168 | } 169 | 170 | export default React.memo(ConversationsList) 171 | -------------------------------------------------------------------------------- /components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head' 2 | import Link from 'next/link' 3 | import { NavigationView, ConversationView } from './Views' 4 | import { RecipientControl } from './Conversation' 5 | import NewMessageButton from './NewMessageButton' 6 | import NavigationPanel from './NavigationPanel' 7 | import XmtpInfoPanel from './XmtpInfoPanel' 8 | import UserMenu from './UserMenu' 9 | import React, { useCallback } from 'react' 10 | import { useAppStore } from '../store/app' 11 | import useInitXmtpClient from '../hooks/useInitXmtpClient' 12 | import useListConversations from '../hooks/useListConversations' 13 | import useWalletProvider from '../hooks/useWalletProvider' 14 | 15 | const Layout: React.FC<{ children?: React.ReactNode }> = ({ children }) => { 16 | const client = useAppStore((state) => state.client) 17 | const { initClient } = useInitXmtpClient() 18 | useListConversations() 19 | const walletAddress = useAppStore((state) => state.address) 20 | const signer = useAppStore((state) => state.signer) 21 | 22 | const { connect: connectWallet, disconnect: disconnectWallet } = 23 | useWalletProvider() 24 | 25 | const handleDisconnect = useCallback(async () => { 26 | await disconnectWallet() 27 | }, [disconnectWallet]) 28 | 29 | const handleConnect = useCallback(async () => { 30 | await connectWallet() 31 | signer && (await initClient(signer)) 32 | }, [connectWallet, initClient, signer]) 33 | 34 | return ( 35 | <> 36 | 37 | Chat via XMTP 38 | 42 | 43 |
44 | 45 | 60 | 61 | 62 | {walletAddress && client ? ( 63 | <> 64 |
65 | 66 |
67 | {children} 68 | 69 | ) : ( 70 | 71 | )} 72 |
73 |
74 | 75 | ) 76 | } 77 | 78 | export default Layout 79 | -------------------------------------------------------------------------------- /components/Loader.tsx: -------------------------------------------------------------------------------- 1 | import loaderStyles from '../styles/Loader.module.css' 2 | 3 | type LoaderProps = { 4 | isLoading: boolean 5 | } 6 | 7 | type StyledLoaderProps = { 8 | headingText?: string 9 | subHeadingText: string 10 | isLoading: boolean 11 | } 12 | 13 | export const Spinner = ({ isLoading }: LoaderProps): JSX.Element | null => { 14 | if (!isLoading) { 15 | return null 16 | } 17 | 18 | // This feels janky af, but I'm gonna run with it rather than import some bloated library just to make a spinner. 19 | return ( 20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 | ) 31 | } 32 | 33 | export const Loader = ({ 34 | headingText, 35 | subHeadingText, 36 | isLoading, 37 | }: StyledLoaderProps): JSX.Element => ( 38 |
39 |
40 | 41 | {headingText && ( 42 |
43 | {headingText} 44 |
45 | )} 46 |
47 | {subHeadingText} 48 |
49 |
50 |
51 | ) 52 | 53 | export default Loader 54 | -------------------------------------------------------------------------------- /components/NavigationPanel.tsx: -------------------------------------------------------------------------------- 1 | import { LinkIcon } from '@heroicons/react/outline' 2 | import { ArrowSmRightIcon } from '@heroicons/react/solid' 3 | import { useAppStore } from '../store/app' 4 | import ConversationsList from './ConversationsList' 5 | import Loader from './Loader' 6 | 7 | type NavigationPanelProps = { 8 | onConnect: () => Promise 9 | } 10 | 11 | type ConnectButtonProps = { 12 | onConnect: () => Promise 13 | } 14 | 15 | const NavigationPanel = ({ onConnect }: NavigationPanelProps): JSX.Element => { 16 | const walletAddress = useAppStore((state) => state.address) 17 | const client = useAppStore((state) => state.client) 18 | 19 | return ( 20 |
21 | {walletAddress && client !== null ? ( 22 | 23 | ) : ( 24 | 25 | 26 | 27 | )} 28 |
29 | ) 30 | } 31 | 32 | const NoWalletConnectedMessage: React.FC<{ children?: React.ReactNode }> = ({ 33 | children, 34 | }) => { 35 | return ( 36 |
37 |
38 |
49 | {children} 50 |
51 | ) 52 | } 53 | 54 | const ConnectButton = ({ onConnect }: ConnectButtonProps): JSX.Element => { 55 | return ( 56 | 65 | ) 66 | } 67 | 68 | const ConversationsPanel = (): JSX.Element => { 69 | const client = useAppStore((state) => state.client) 70 | const loadingConversations = useAppStore( 71 | (state) => state.loadingConversations 72 | ) 73 | 74 | if (client === undefined) { 75 | return ( 76 | 81 | ) 82 | } 83 | 84 | if (loadingConversations) { 85 | return ( 86 | 91 | ) 92 | } 93 | 94 | return ( 95 | 98 | ) 99 | } 100 | 101 | export default NavigationPanel 102 | -------------------------------------------------------------------------------- /components/NewMessageButton.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router' 2 | 3 | const NewMessageButton = (): JSX.Element => { 4 | const router = useRouter() 5 | 6 | const onNewMessageButtonClick = () => { 7 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 8 | router.push('/dm/') 9 | } 10 | 11 | return ( 12 | 18 | ) 19 | } 20 | 21 | export default NewMessageButton 22 | -------------------------------------------------------------------------------- /components/Tooltip/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react' 2 | 3 | export const Tooltip = ({ 4 | message, 5 | children, 6 | }: { 7 | message: string 8 | children: ReactNode 9 | }) => { 10 | return ( 11 |
12 | {children} 13 |
14 | 15 | {message} 16 | 17 |
18 |
19 |
20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /components/UserMenu.tsx: -------------------------------------------------------------------------------- 1 | import { Menu, Transition } from '@headlessui/react' 2 | import { CogIcon } from '@heroicons/react/solid' 3 | import { Fragment } from 'react' 4 | import { classNames, tagStr } from '../helpers' 5 | import Blockies from 'react-blockies' 6 | import Address from './Address' 7 | import useEns from '../hooks/useEns' 8 | import { Tooltip } from './Tooltip/Tooltip' 9 | import packageJson from '../package.json' 10 | import { useAppStore } from '../store/app' 11 | 12 | type UserMenuProps = { 13 | onConnect?: () => Promise 14 | onDisconnect?: () => Promise 15 | } 16 | 17 | type AvatarBlockProps = { 18 | walletAddress: string 19 | avatarUrl?: string 20 | } 21 | 22 | const AvatarBlock = ({ walletAddress }: AvatarBlockProps) => { 23 | const { avatarUrl, loading } = useEns(walletAddress) 24 | if (loading) { 25 | return ( 26 |
27 |
28 |
29 | ) 30 | } 31 | return avatarUrl ? ( 32 |
33 |
34 | {walletAddress} 39 |
40 | ) : ( 41 | 42 | ) 43 | } 44 | 45 | const NotConnected = ({ onConnect }: UserMenuProps): JSX.Element => { 46 | return ( 47 | <> 48 |
49 |
50 |
51 |

You are not connected.

52 |
53 | 54 | 55 |

56 | Sign in with your wallet 57 |

58 |
59 |
60 | 70 | 71 | ) 72 | } 73 | 74 | const UserMenu = ({ onConnect, onDisconnect }: UserMenuProps): JSX.Element => { 75 | const walletAddress = useAppStore((state) => state.address) 76 | 77 | const onClickCopy = () => { 78 | if (walletAddress) { 79 | navigator.clipboard.writeText(walletAddress) 80 | } 81 | } 82 | 83 | return ( 84 |
89 | {walletAddress ? ( 90 | 91 | {({ open }) => ( 92 | <> 93 |
99 | {walletAddress ? ( 100 | <> 101 | 102 |
103 |
104 |
105 |

106 | Connected as: 107 |

108 |
109 |
113 |
114 | 115 | ) : ( 116 |
117 |
118 |
119 |

120 | Connecting... 121 |

122 |
123 |

124 | Verifying your wallet 125 |

126 |
127 | )} 128 |
129 |
130 | {tagStr() && ( 131 | 132 |
133 | {tagStr()} 134 |
135 |
136 | )} 137 |
138 | 139 | Open user menu 140 | 148 |
149 | 158 | 159 |
160 | 161 | 162 | xmtp-js v 163 | {packageJson.dependencies['@xmtp/xmtp-js'].substring( 164 | 1 165 | )} 166 | 167 | 168 |
169 |
170 | 171 | {({ active }) => ( 172 | 179 | Copy wallet address 180 | 181 | )} 182 | 183 |
184 |
185 | 186 | {({ active }) => ( 187 | 194 | Disconnect wallet 195 | 196 | )} 197 | 198 |
199 |
200 |
201 |
202 | 203 | )} 204 |
205 | ) : ( 206 | 207 | )} 208 |
209 | ) 210 | } 211 | 212 | export default UserMenu 213 | -------------------------------------------------------------------------------- /components/Views/ConversationView.tsx: -------------------------------------------------------------------------------- 1 | import { Transition } from '@headlessui/react' 2 | import { Fragment } from 'react' 3 | import { useRouter } from 'next/router' 4 | 5 | type ConversationViewProps = { 6 | children?: React.ReactNode 7 | } 8 | 9 | const ConversationView = ({ children }: ConversationViewProps): JSX.Element => { 10 | const router = useRouter() 11 | const show = router.pathname !== '/' 12 | 13 | return ( 14 | <> 15 | 16 |
17 | 26 |
27 | {children} 28 |
29 |
30 |
31 |
32 | 33 | {/* Always show in desktop layout */} 34 |
35 | {children} 36 |
37 | 38 | ) 39 | } 40 | 41 | export default ConversationView 42 | -------------------------------------------------------------------------------- /components/Views/NavigationView.tsx: -------------------------------------------------------------------------------- 1 | import { Transition } from '@headlessui/react' 2 | import { Fragment } from 'react' 3 | import { useRouter } from 'next/router' 4 | 5 | type NavigationViewProps = { 6 | children?: React.ReactNode 7 | } 8 | 9 | const NavigationView = ({ children }: NavigationViewProps): JSX.Element => { 10 | const router = useRouter() 11 | const show = router.pathname === '/' 12 | 13 | return ( 14 | <> 15 | 16 |
17 | 26 |
27 | {children} 28 |
29 |
30 |
31 |
32 | 33 | {/* Always show in desktop layout */} 34 |
{children}
35 | 36 | ) 37 | } 38 | 39 | export default NavigationView 40 | -------------------------------------------------------------------------------- /components/Views/index.ts: -------------------------------------------------------------------------------- 1 | export { default as NavigationView } from './NavigationView' 2 | export { default as ConversationView } from './ConversationView' 3 | -------------------------------------------------------------------------------- /components/XmtpInfoPanel.tsx: -------------------------------------------------------------------------------- 1 | import packageJson from '../package.json' 2 | import { classNames } from '../helpers' 3 | import { 4 | LinkIcon, 5 | BookOpenIcon, 6 | UserGroupIcon, 7 | ChevronRightIcon, 8 | ArrowSmRightIcon, 9 | } from '@heroicons/react/solid' 10 | import { useAppStore } from '../store/app' 11 | 12 | type XmtpInfoRowProps = { 13 | icon: JSX.Element 14 | headingText: string 15 | subHeadingText: string 16 | onClick?: (() => void) | (() => Promise) 17 | disabled?: boolean 18 | } 19 | 20 | type XmtpInfoPanelProps = { 21 | onConnect?: () => Promise 22 | } 23 | 24 | const InfoRow = ({ 25 | icon, 26 | headingText, 27 | subHeadingText, 28 | onClick, 29 | disabled, 30 | }: XmtpInfoRowProps): JSX.Element => ( 31 | 35 |
41 |
{icon}
42 |
43 |
{headingText}
44 |
{subHeadingText}
45 |
46 |
47 | 48 |
49 |
50 |
51 | ) 52 | 53 | const XmtpInfoPanel = ({ onConnect }: XmtpInfoPanelProps): JSX.Element => { 54 | const walletAddress = useAppStore((state) => state.address) 55 | const InfoRows = [ 56 | { 57 | icon: , 58 | headingText: 'Connect your wallet', 59 | subHeadingText: 'Verify your wallet to start using the XMTP protocol', 60 | onClick: onConnect, 61 | disabled: !!walletAddress, 62 | }, 63 | { 64 | icon: , 65 | headingText: 'Read the docs', 66 | subHeadingText: 67 | 'Check out the documentation for our protocol and find out how to get up and running quickly', 68 | onClick: () => window.open('https://docs.xmtp.org', '_blank'), 69 | }, 70 | { 71 | icon: , 72 | headingText: 'Join our community', 73 | subHeadingText: 74 | 'Talk about what you’re building or find out other projects that are building upon XMTP', 75 | onClick: () => window.open('https://community.xmtp.org', '_blank'), 76 | }, 77 | ] 78 | 79 | return ( 80 | // The info panel is only shown in desktop layouts. 81 |
82 |
83 |
84 | Welcome to the web3 communication protocol 85 |
86 |
87 | Get started by reading the docs or joining the community 88 |
89 |
90 |
91 | {InfoRows.map((info, index) => { 92 | return ( 93 | 101 | ) 102 | })} 103 |
104 |
105 |
106 | xmtp-js v{packageJson.dependencies['@xmtp/xmtp-js'].substring(1)} 107 |
108 | 114 | I need help 115 | 116 |
117 |
118 | ) 119 | } 120 | 121 | export default XmtpInfoPanel 122 | -------------------------------------------------------------------------------- /helpers/appVersion.ts: -------------------------------------------------------------------------------- 1 | import packageJson from '../package.json' 2 | 3 | export const getAppVersion = () => { 4 | const { name, version } = packageJson 5 | return name + '/' + version 6 | } 7 | -------------------------------------------------------------------------------- /helpers/classNames.ts: -------------------------------------------------------------------------------- 1 | export default function classNames(...classes: (string | null)[]) { 2 | return classes.filter(Boolean).join(' ') 3 | } 4 | -------------------------------------------------------------------------------- /helpers/constants.ts: -------------------------------------------------------------------------------- 1 | export const MESSAGE_LIMIT = 20 2 | -------------------------------------------------------------------------------- /helpers/env.ts: -------------------------------------------------------------------------------- 1 | export const getEnv = (): 'dev' | 'production' | 'local' => { 2 | const envVar = process.env.NEXT_PUBLIC_XMTP_ENVIRONMENT 3 | if (envVar === 'production') { 4 | return envVar 5 | } 6 | if (envVar === 'local') { 7 | return envVar 8 | } 9 | return 'dev' 10 | } 11 | 12 | export const tagStr = (): string | null => { 13 | return getEnv() === 'production' ? null : getEnv().toLocaleUpperCase() 14 | } 15 | -------------------------------------------------------------------------------- /helpers/getUniqueMessages.ts: -------------------------------------------------------------------------------- 1 | import type { DecodedMessage } from '@xmtp/xmtp-js' 2 | 3 | const getUniqueMessages = (msgObj: DecodedMessage[]): DecodedMessage[] => { 4 | const uniqueMessages = [ 5 | ...Array.from(new Map(msgObj.map((item) => [item['id'], item])).values()), 6 | ] 7 | uniqueMessages.sort((a, b) => { 8 | return (b.sent?.getTime() ?? 0) - (a.sent?.getTime() ?? 0) 9 | }) 10 | 11 | return uniqueMessages ?? [] 12 | } 13 | 14 | export default getUniqueMessages 15 | -------------------------------------------------------------------------------- /helpers/index.ts: -------------------------------------------------------------------------------- 1 | export { default as classNames } from './classNames' 2 | export * from './string' 3 | export * from './env' 4 | export * from './appVersion' 5 | export * from './keys' 6 | export * from './constants' 7 | -------------------------------------------------------------------------------- /helpers/keys.ts: -------------------------------------------------------------------------------- 1 | import { getEnv } from './env' 2 | 3 | const ENCODING = 'binary' 4 | 5 | export const buildLocalStorageKey = (walletAddress: string) => 6 | `xmtp:${getEnv()}:keys:${walletAddress}` 7 | 8 | export const loadKeys = (walletAddress: string): Uint8Array | null => { 9 | const val = localStorage.getItem(buildLocalStorageKey(walletAddress)) 10 | return val ? Buffer.from(val, ENCODING) : null 11 | } 12 | 13 | export const storeKeys = (walletAddress: string, keys: Uint8Array) => { 14 | localStorage.setItem( 15 | buildLocalStorageKey(walletAddress), 16 | Buffer.from(keys).toString(ENCODING) 17 | ) 18 | } 19 | 20 | export const wipeKeys = (walletAddress: string) => { 21 | localStorage.removeItem(buildLocalStorageKey(walletAddress)) 22 | } 23 | -------------------------------------------------------------------------------- /helpers/string.ts: -------------------------------------------------------------------------------- 1 | import { Conversation } from '@xmtp/xmtp-js' 2 | import { NextRouter } from 'next/router' 3 | 4 | export const truncate = ( 5 | str: string | undefined, 6 | length: number 7 | ): string | undefined => { 8 | if (!str) { 9 | return str 10 | } 11 | if (str.length > length) { 12 | return `${str.substring(0, length - 3)}...` 13 | } 14 | return str 15 | } 16 | 17 | export const formatDate = (d: Date | undefined): string => 18 | d ? d.toLocaleDateString('en-US') : '' 19 | 20 | export const formatTime = (d: Date | undefined): string => 21 | d 22 | ? d.toLocaleTimeString(undefined, { 23 | hour12: true, 24 | hour: 'numeric', 25 | minute: '2-digit', 26 | }) 27 | : '' 28 | 29 | export const checkPath = () => { 30 | return window.location.pathname !== '/' && window.location.pathname !== '/dm' 31 | } 32 | 33 | export const isEns = (address: string): boolean => { 34 | return address.endsWith('eth') || address.endsWith('.cb.id') 35 | } 36 | 37 | export const is0xAddress = (address: string): boolean => 38 | address.startsWith('0x') && address.length === 42 39 | 40 | export const shortAddress = (addr: string): string => 41 | addr.length > 10 && addr.startsWith('0x') 42 | ? `${addr.substring(0, 6)}...${addr.substring(addr.length - 4)}` 43 | : addr 44 | 45 | export const getConversationKey = (conversation?: Conversation): string => { 46 | return conversation?.context?.conversationId 47 | ? `${conversation?.peerAddress}/${conversation?.context?.conversationId}` 48 | : conversation?.peerAddress ?? '' 49 | } 50 | 51 | export const getAddressFromPath = (router: NextRouter): string => { 52 | return Array.isArray(router.query.recipientWalletAddr) 53 | ? router.query.recipientWalletAddr[0] 54 | : (router.query.recipientWalletAddr as string) 55 | } 56 | -------------------------------------------------------------------------------- /hooks/useEns.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | import { is0xAddress, isEns } from '../helpers/string' 3 | import useWalletProvider from './useWalletProvider' 4 | 5 | const useEns = (addressOrName: string | undefined) => { 6 | const { resolveName, lookupAddress, getAvatarUrl } = useWalletProvider() 7 | const [address, setAddress] = useState('') 8 | const [name, setName] = useState('') 9 | const [avatarUrl, setAvatarUrl] = useState('') 10 | const [loading, setLoading] = useState(true) 11 | const probableAddress = 12 | addressOrName && is0xAddress(addressOrName) ? addressOrName : undefined 13 | const probableName = 14 | addressOrName && isEns(addressOrName) ? addressOrName : undefined 15 | 16 | useEffect(() => { 17 | if (!resolveName || !lookupAddress || !getAvatarUrl) { 18 | return 19 | } 20 | const initAvatarUrl = async (name: string) => { 21 | setAvatarUrl((await getAvatarUrl(name)) as string) 22 | } 23 | const initName = async (probableAddress: string) => { 24 | setLoading(true) 25 | setName((await lookupAddress(probableAddress)) as string) 26 | if (name) { 27 | await initAvatarUrl(name) 28 | } 29 | setLoading(false) 30 | } 31 | const initAddress = async (probableName: string) => { 32 | setLoading(true) 33 | setAddress((await resolveName(probableName)) as string) 34 | await initAvatarUrl(probableName) 35 | setLoading(false) 36 | } 37 | if (probableAddress) { 38 | initName(probableAddress) 39 | } 40 | if (probableName) { 41 | initAddress(probableName) 42 | } 43 | }, [ 44 | probableName, 45 | probableAddress, 46 | name, 47 | resolveName, 48 | lookupAddress, 49 | getAvatarUrl, 50 | ]) 51 | 52 | return { 53 | address: probableAddress || (address as string | undefined), 54 | name: probableName || (name as string | undefined), 55 | avatarUrl: avatarUrl as string | undefined, 56 | loading, 57 | } 58 | } 59 | 60 | export default useEns 61 | -------------------------------------------------------------------------------- /hooks/useGetMessages.ts: -------------------------------------------------------------------------------- 1 | import { SortDirection } from '@xmtp/xmtp-js' 2 | import { useEffect, useState } from 'react' 3 | import { MESSAGE_LIMIT } from '../helpers' 4 | import { useAppStore } from '../store/app' 5 | 6 | const useGetMessages = (conversationKey: string, endTime?: Date) => { 7 | const convoMessages = useAppStore((state) => 8 | state.convoMessages.get(conversationKey) 9 | ) 10 | const conversation = useAppStore((state) => 11 | state.conversations.get(conversationKey) 12 | ) 13 | const addMessages = useAppStore((state) => state.addMessages) 14 | const [hasMore, setHasMore] = useState>(new Map()) 15 | 16 | useEffect(() => { 17 | if (!conversation) { 18 | return 19 | } 20 | 21 | const loadMessages = async () => { 22 | const newMessages = await conversation.messages({ 23 | direction: SortDirection.SORT_DIRECTION_DESCENDING, 24 | limit: MESSAGE_LIMIT, 25 | endTime: endTime, 26 | }) 27 | if (newMessages.length > 0) { 28 | addMessages(conversationKey, newMessages) 29 | if (newMessages.length < MESSAGE_LIMIT) { 30 | hasMore.set(conversationKey, false) 31 | setHasMore(new Map(hasMore)) 32 | } else { 33 | hasMore.set(conversationKey, true) 34 | setHasMore(new Map(hasMore)) 35 | } 36 | } else { 37 | hasMore.set(conversationKey, false) 38 | setHasMore(new Map(hasMore)) 39 | } 40 | } 41 | loadMessages() 42 | // eslint-disable-next-line react-hooks/exhaustive-deps 43 | }, [conversation, conversationKey, endTime]) 44 | 45 | return { 46 | convoMessages, 47 | hasMore: hasMore.get(conversationKey) ?? false, 48 | } 49 | } 50 | 51 | export default useGetMessages 52 | -------------------------------------------------------------------------------- /hooks/useInitXmtpClient.ts: -------------------------------------------------------------------------------- 1 | import { Client } from '@xmtp/xmtp-js' 2 | import { Signer } from 'ethers' 3 | import { useCallback, useEffect, useState } from 'react' 4 | import { 5 | getAppVersion, 6 | getEnv, 7 | loadKeys, 8 | storeKeys, 9 | wipeKeys, 10 | } from '../helpers' 11 | import { useAppStore } from '../store/app' 12 | 13 | const useInitXmtpClient = (cacheOnly = false) => { 14 | const signer = useAppStore((state) => state.signer) 15 | const address = useAppStore((state) => state.address) ?? '' 16 | const client = useAppStore((state) => state.client) 17 | const setClient = useAppStore((state) => state.setClient) 18 | const reset = useAppStore((state) => state.reset) 19 | const [isRequestPending, setIsRequestPending] = useState(false) 20 | 21 | const disconnect = () => { 22 | reset() 23 | if (signer) { 24 | wipeKeys(address) 25 | } 26 | } 27 | 28 | const initClient = useCallback( 29 | async (wallet: Signer) => { 30 | if (wallet && !client) { 31 | try { 32 | setIsRequestPending(true) 33 | let keys = loadKeys(address) 34 | if (!keys) { 35 | if (cacheOnly) { 36 | return 37 | } 38 | keys = await Client.getKeys(wallet, { 39 | env: getEnv(), 40 | appVersion: getAppVersion(), 41 | }) 42 | storeKeys(address, keys) 43 | } 44 | const xmtp = await Client.create(null, { 45 | env: getEnv(), 46 | appVersion: getAppVersion(), 47 | privateKeyOverride: keys, 48 | }) 49 | setClient(xmtp) 50 | setIsRequestPending(false) 51 | } catch (e) { 52 | console.error(e) 53 | setClient(null) 54 | setIsRequestPending(false) 55 | } 56 | } 57 | }, 58 | [client] 59 | ) 60 | 61 | useEffect(() => { 62 | if (!isRequestPending) { 63 | signer ? initClient(signer) : disconnect() 64 | } 65 | }, [signer, initClient]) 66 | 67 | return { 68 | initClient, 69 | } 70 | } 71 | 72 | export default useInitXmtpClient 73 | -------------------------------------------------------------------------------- /hooks/useListConversations.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Conversation, 3 | DecodedMessage, 4 | SortDirection, 5 | Stream, 6 | } from '@xmtp/xmtp-js' 7 | import { useEffect, useState } from 'react' 8 | import { getConversationKey, shortAddress, truncate } from '../helpers' 9 | import { useAppStore } from '../store/app' 10 | import useWalletProvider from './useWalletProvider' 11 | 12 | let latestMsgId: string 13 | 14 | export const useListConversations = () => { 15 | const walletAddress = useAppStore((state) => state.address) 16 | const { lookupAddress } = useWalletProvider() 17 | const convoMessages = useAppStore((state) => state.convoMessages) 18 | const client = useAppStore((state) => state.client) 19 | const conversations = useAppStore((state) => state.conversations) 20 | const setConversations = useAppStore((state) => state.setConversations) 21 | const addMessages = useAppStore((state) => state.addMessages) 22 | const previewMessages = useAppStore((state) => state.previewMessages) 23 | const setPreviewMessages = useAppStore((state) => state.setPreviewMessages) 24 | const setPreviewMessage = useAppStore((state) => state.setPreviewMessage) 25 | const setLoadingConversations = useAppStore( 26 | (state) => state.setLoadingConversations 27 | ) 28 | const [browserVisible, setBrowserVisible] = useState(true) 29 | 30 | useEffect(() => { 31 | window.addEventListener('focus', () => setBrowserVisible(true)) 32 | window.addEventListener('blur', () => setBrowserVisible(false)) 33 | }, []) 34 | 35 | const fetchMostRecentMessage = async ( 36 | convo: Conversation 37 | ): Promise<{ key: string; message?: DecodedMessage }> => { 38 | const key = getConversationKey(convo) 39 | const newMessages = await convo.messages({ 40 | limit: 1, 41 | direction: SortDirection.SORT_DIRECTION_DESCENDING, 42 | }) 43 | if (newMessages.length <= 0) { 44 | return { key } 45 | } 46 | return { key, message: newMessages[0] } 47 | } 48 | 49 | useEffect(() => { 50 | if (!client) { 51 | return 52 | } 53 | 54 | let messageStream: AsyncGenerator 55 | let conversationStream: Stream 56 | 57 | const streamAllMessages = async () => { 58 | messageStream = await client.conversations.streamAllMessages() 59 | 60 | for await (const message of messageStream) { 61 | const key = getConversationKey(message.conversation) 62 | setPreviewMessage(key, message) 63 | 64 | const numAdded = addMessages(key, [message]) 65 | if (numAdded > 0) { 66 | const newMessages = convoMessages.get(key) ?? [] 67 | newMessages.push(message) 68 | const uniqueMessages = [ 69 | ...Array.from( 70 | new Map(newMessages.map((item) => [item['id'], item])).values() 71 | ), 72 | ] 73 | convoMessages.set(key, uniqueMessages) 74 | if ( 75 | latestMsgId !== message.id && 76 | Notification.permission === 'granted' && 77 | message.senderAddress !== walletAddress && 78 | !browserVisible 79 | ) { 80 | const name = await lookupAddress(message.senderAddress ?? '') 81 | new Notification('XMTP', { 82 | body: `${ 83 | name || shortAddress(message.senderAddress ?? '') 84 | }\n${truncate(message.content, 75)}`, 85 | }) 86 | 87 | latestMsgId = message.id 88 | } 89 | } 90 | } 91 | } 92 | 93 | const listConversations = async () => { 94 | console.log('Listing conversations') 95 | setLoadingConversations(true) 96 | const newPreviewMessages = new Map(previewMessages) 97 | const convos = await client.conversations.list() 98 | 99 | const previews = await Promise.all(convos.map(fetchMostRecentMessage)) 100 | 101 | for (const preview of previews) { 102 | if (preview.message) { 103 | newPreviewMessages.set(preview.key, preview.message) 104 | } 105 | } 106 | setPreviewMessages(newPreviewMessages) 107 | 108 | Promise.all( 109 | convos.map(async (convo) => { 110 | if (convo.peerAddress !== walletAddress) { 111 | conversations.set(getConversationKey(convo), convo) 112 | setConversations(new Map(conversations)) 113 | } 114 | }) 115 | ).then(() => { 116 | setLoadingConversations(false) 117 | if (Notification.permission === 'default') { 118 | Notification.requestPermission() 119 | } 120 | }) 121 | } 122 | const streamConversations = async () => { 123 | conversationStream = await client.conversations.stream() 124 | for await (const convo of conversationStream) { 125 | if (convo.peerAddress !== walletAddress) { 126 | conversations.set(getConversationKey(convo), convo) 127 | setConversations(new Map(conversations)) 128 | const preview = await fetchMostRecentMessage(convo) 129 | if (preview.message) { 130 | setPreviewMessage(preview.key, preview.message) 131 | } 132 | closeMessageStream() 133 | streamAllMessages() 134 | } 135 | } 136 | } 137 | 138 | const closeConversationStream = async () => { 139 | if (!conversationStream) { 140 | return 141 | } 142 | await conversationStream.return() 143 | } 144 | 145 | const closeMessageStream = async () => { 146 | if (messageStream) { 147 | await messageStream.return(undefined) 148 | } 149 | } 150 | 151 | listConversations() 152 | streamConversations() 153 | streamAllMessages() 154 | 155 | return () => { 156 | closeConversationStream() 157 | closeMessageStream() 158 | } 159 | }, [client, walletAddress]) 160 | } 161 | 162 | export default useListConversations 163 | -------------------------------------------------------------------------------- /hooks/useSendMessage.ts: -------------------------------------------------------------------------------- 1 | import { Conversation } from '@xmtp/xmtp-js' 2 | import { useCallback } from 'react' 3 | 4 | const useSendMessage = (selectedConversation?: Conversation) => { 5 | const sendMessage = useCallback( 6 | async (message: string) => { 7 | await selectedConversation?.send(message) 8 | }, 9 | [selectedConversation] 10 | ) 11 | 12 | return { 13 | sendMessage, 14 | } 15 | } 16 | 17 | export default useSendMessage 18 | -------------------------------------------------------------------------------- /hooks/useWalletProvider.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect } from 'react' 2 | import { ethers } from 'ethers' 3 | import Web3Modal, { IProviderOptions, providers } from 'web3modal' 4 | import WalletConnectProvider from '@walletconnect/web3-provider' 5 | import WalletLink from 'walletlink' 6 | import { useRouter } from 'next/router' 7 | import { useAppStore } from '../store/app' 8 | 9 | // Ethereum mainnet 10 | const ETH_CHAIN_ID = 1 11 | 12 | const cachedLookupAddress = new Map() 13 | const cachedResolveName = new Map() 14 | const cachedGetAvatarUrl = new Map() 15 | 16 | // This variables are not added in state on purpose. 17 | // It saves few re-renders which then trigger the children to re-render 18 | // Consider the above while moving it to state variables 19 | let provider: ethers.providers.Web3Provider 20 | 21 | const useWalletProvider = () => { 22 | const web3Modal = useAppStore((state) => state.web3Modal) 23 | const setWeb3Modal = useAppStore((state) => state.setWeb3Modal) 24 | const setAddress = useAppStore((state) => state.setAddress) 25 | const setSigner = useAppStore((state) => state.setSigner) 26 | const reset = useAppStore((state) => state.reset) 27 | const router = useRouter() 28 | 29 | const resolveName = useCallback(async (name: string) => { 30 | if (cachedResolveName.has(name)) { 31 | return cachedResolveName.get(name) 32 | } 33 | 34 | const { chainId } = (await provider?.getNetwork()) || {} 35 | 36 | if (Number(chainId) !== ETH_CHAIN_ID) { 37 | return undefined 38 | } 39 | const address = (await provider?.resolveName(name)) || undefined 40 | cachedResolveName.set(name, address) 41 | return address 42 | }, []) 43 | 44 | const lookupAddress = useCallback(async (address: string) => { 45 | if (cachedLookupAddress.has(address)) { 46 | return cachedLookupAddress.get(address) 47 | } 48 | const { chainId } = (await provider?.getNetwork()) || {} 49 | 50 | if (Number(chainId) !== ETH_CHAIN_ID) { 51 | return undefined 52 | } 53 | 54 | const name = (await provider?.lookupAddress(address)) || undefined 55 | cachedLookupAddress.set(address, name) 56 | return name 57 | }, []) 58 | 59 | const getAvatarUrl = useCallback(async (name: string) => { 60 | if (cachedGetAvatarUrl.has(name)) { 61 | return cachedGetAvatarUrl.get(name) 62 | } 63 | const avatarUrl = (await provider?.getAvatar(name)) || undefined 64 | cachedGetAvatarUrl.set(name, avatarUrl) 65 | return avatarUrl 66 | }, []) 67 | 68 | // Note, this triggers a re-render on acccount change and on diconnect. 69 | const disconnect = useCallback(() => { 70 | Object.keys(localStorage).forEach((key) => { 71 | if (key.startsWith('xmtp')) { 72 | localStorage.removeItem(key) 73 | } 74 | }) 75 | reset() 76 | router.push('/') 77 | }, [router, web3Modal]) 78 | 79 | const handleAccountsChanged = useCallback(() => { 80 | disconnect() 81 | }, [disconnect]) 82 | 83 | const connect = useCallback(async () => { 84 | if (!web3Modal) { 85 | throw new Error('web3Modal not initialized') 86 | } 87 | try { 88 | const instance = await web3Modal.connect() 89 | if (!instance) { 90 | return 91 | } 92 | instance.on('accountsChanged', handleAccountsChanged) 93 | provider = new ethers.providers.Web3Provider(instance, 'any') 94 | const newSigner = provider.getSigner() 95 | setSigner(newSigner) 96 | setAddress(await newSigner.getAddress()) 97 | return newSigner 98 | } catch (e) { 99 | // TODO: better error handling/surfacing here. 100 | // Note that web3Modal.connect throws an error when the user closes the 101 | // modal, as "User closed modal" 102 | console.log('error', e) 103 | } 104 | }, [handleAccountsChanged, web3Modal]) 105 | 106 | useEffect(() => { 107 | const infuraId = process.env.NEXT_PUBLIC_INFURA_ID 108 | const providerOptions: IProviderOptions = { 109 | walletconnect: { 110 | package: WalletConnectProvider, 111 | options: { 112 | infuraId, 113 | }, 114 | }, 115 | } 116 | if ( 117 | !window.ethereum || 118 | (window.ethereum && !window.ethereum.isCoinbaseWallet) 119 | ) { 120 | providerOptions.walletlink = { 121 | package: WalletLink, 122 | options: { 123 | appName: 'Chat via XMTP', 124 | infuraId, 125 | // darkMode: false, 126 | }, 127 | } 128 | } 129 | if (!window.ethereum || !window.ethereum.isMetaMask) { 130 | providerOptions['custom-metamask'] = { 131 | display: { 132 | logo: providers.METAMASK.logo, 133 | name: 'Install MetaMask', 134 | description: 'Connect using browser wallet', 135 | }, 136 | package: {}, 137 | connector: async () => { 138 | window.open('https://metamask.io') 139 | // throw new Error("MetaMask not installed"); 140 | }, 141 | } 142 | } 143 | !web3Modal && 144 | setWeb3Modal(new Web3Modal({ cacheProvider: true, providerOptions })) 145 | }, []) 146 | 147 | useEffect(() => { 148 | if (!web3Modal) { 149 | return 150 | } 151 | const initCached = async () => { 152 | try { 153 | const cachedProviderJson = localStorage.getItem( 154 | 'WEB3_CONNECT_CACHED_PROVIDER' 155 | ) 156 | if (!cachedProviderJson) { 157 | return 158 | } 159 | const cachedProviderName = JSON.parse(cachedProviderJson) 160 | const instance = await web3Modal.connectTo(cachedProviderName) 161 | if (!instance) { 162 | return 163 | } 164 | instance.on('accountsChanged', handleAccountsChanged) 165 | provider = new ethers.providers.Web3Provider(instance, 'any') 166 | const newSigner = provider.getSigner() 167 | setSigner(newSigner) 168 | setAddress(await newSigner.getAddress()) 169 | } catch (e) { 170 | console.error(e) 171 | } 172 | } 173 | initCached() 174 | }, [web3Modal]) 175 | 176 | return { 177 | resolveName, 178 | lookupAddress, 179 | getAvatarUrl, 180 | connect, 181 | disconnect, 182 | } 183 | } 184 | 185 | export default useWalletProvider 186 | -------------------------------------------------------------------------------- /hooks/useWindowSize.ts: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect, useState } from 'react' 2 | 3 | const useWindowSize = () => { 4 | const [size, setSize] = useState<[number, number]>([0, 0]) 5 | 6 | useLayoutEffect(() => { 7 | function updateSize() { 8 | setSize([window.innerWidth, window.innerHeight]) 9 | } 10 | window.addEventListener('resize', updateSize) 11 | updateSize() 12 | return () => window.removeEventListener('resize', updateSize) 13 | }, []) 14 | return size 15 | } 16 | 17 | export default useWindowSize 18 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: "jsdom", 3 | setupFilesAfterEnv: ["/jest.setup.ts"], 4 | testPathIgnorePatterns: ["/.next/", "/node_modules/"], 5 | }; 6 | -------------------------------------------------------------------------------- /jest.setup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom' 2 | import failOnConsole from 'jest-fail-on-console' 3 | 4 | failOnConsole() 5 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | images: { 5 | loader: 'akamai', 6 | path: '', 7 | }, 8 | webpack: (config, { isServer }) => { 9 | if (!isServer) { 10 | // Fixes npm packages that depend on `fs` module 11 | // https://github.com/vercel/next.js/issues/7755#issuecomment-937721514 12 | config.resolve.fallback.fs = false 13 | } 14 | config.resolve.mainFields = ['browser', 'main', 'module'] 15 | return config 16 | }, 17 | } 18 | 19 | module.exports = nextConfig 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "xmtp-react-chat-example", 3 | "version": "1.0.0", 4 | "description": "Example chat application using xmtp-js client", 5 | "private": true, 6 | "scripts": { 7 | "dev": "next dev", 8 | "build": "next build && next export", 9 | "start": "next start", 10 | "lint": "next lint", 11 | "test": "jest" 12 | }, 13 | "author": "XMTP Labs ", 14 | "license": "MIT", 15 | "homepage": "https://github.com/xmtp/example-chat-react", 16 | "repository": { 17 | "type": "git", 18 | "url": "https:git@github.com:xmtp/example-chat-react.git" 19 | }, 20 | "bugs": { 21 | "url": "https://github.com/xmtp/example-chat-react/issues" 22 | }, 23 | "dependencies": { 24 | "@headlessui/react": "^1.4.3", 25 | "@heroicons/react": "^1.0.5", 26 | "@tailwindcss/line-clamp": "^0.4.2", 27 | "@xmtp/xmtp-js": "^7.7.1", 28 | "ethers": "^5.5.3", 29 | "imagemin-svgo": "^9.0.0", 30 | "next": "13.0.5", 31 | "react": "18.2.0", 32 | "react-blockies": "^1.4.1", 33 | "react-dom": "18.2.0", 34 | "react-emoji-render": "^1.2.4", 35 | "react-infinite-scroll-component": "^6.1.0", 36 | "zustand": "^4.1.4" 37 | }, 38 | "overrides": { 39 | "autoprefixer": "10.4.5" 40 | }, 41 | "devDependencies": { 42 | "@tailwindcss/forms": "^0.4.0", 43 | "@testing-library/dom": "^8.19.0", 44 | "@testing-library/jest-dom": "^5.16.5", 45 | "@testing-library/react": "^13.4.0", 46 | "@testing-library/user-event": "^13.5.0", 47 | "@types/jest": "^27.4.0", 48 | "@types/node": "18.11.9", 49 | "@types/react": "18.0.25", 50 | "@typescript-eslint/eslint-plugin": "^5.10.1", 51 | "@walletconnect/web3-provider": "^1.7.1", 52 | "autoprefixer": "10.4.5", 53 | "babel-jest": "^27.5.1", 54 | "eslint": "8.28.0", 55 | "eslint-config-next": "13.0.5", 56 | "eslint-config-prettier": "^8.3.0", 57 | "jest": "^27.5.1", 58 | "jest-fail-on-console": "^2.4.2", 59 | "node-localstorage": "^2.2.1", 60 | "postcss": "^8.4.5", 61 | "prettier": "^2.5.1", 62 | "tailwindcss": "^3.2.4", 63 | "typescript": "4.9.3", 64 | "walletlink": "^2.4.6", 65 | "web3modal": "^1.9.5" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /pages/404.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from 'next' 2 | 3 | const CustomError: NextPage = () => { 4 | return
5 | } 6 | 7 | export default CustomError 8 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '../styles/globals.css' 2 | import type { AppProps } from 'next/app' 3 | import dynamic from 'next/dynamic' 4 | 5 | const AppWithoutSSR = dynamic(() => import('../components/App'), { 6 | ssr: false, 7 | }) 8 | 9 | function AppWrapper({ Component, pageProps }: AppProps) { 10 | return ( 11 | 12 | 13 | 14 | ) 15 | } 16 | 17 | export default AppWrapper 18 | -------------------------------------------------------------------------------- /pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import Document, { 2 | Html, 3 | Head, 4 | Main, 5 | NextScript, 6 | DocumentContext, 7 | } from 'next/document' 8 | 9 | class AppDocument extends Document { 10 | static async getInitialProps(ctx: DocumentContext) { 11 | const initialProps = await Document.getInitialProps(ctx) 12 | return { ...initialProps } 13 | } 14 | 15 | render() { 16 | return ( 17 | 18 | 19 | 20 | 21 | 25 | 26 | 27 |
28 | 29 | 30 | 31 | ) 32 | } 33 | } 34 | 35 | export default AppDocument 36 | -------------------------------------------------------------------------------- /pages/dm/[...recipientWalletAddr].tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import type { NextPage } from 'next' 3 | import { useRouter } from 'next/router' 4 | import { Conversation } from '../../components/Conversation' 5 | import useWalletProvider from '../../hooks/useWalletProvider' 6 | import { isEns } from '../../helpers/string' 7 | 8 | const ConversationPage: NextPage = () => { 9 | const router = useRouter() 10 | const { resolveName } = useWalletProvider() 11 | const [recipientWalletAddr, setRecipientWalletAddr] = useState() 12 | 13 | useEffect(() => { 14 | const routeAddress = 15 | (Array.isArray(router.query.recipientWalletAddr) 16 | ? router.query.recipientWalletAddr.join('/') 17 | : router.query.recipientWalletAddr) ?? '' 18 | setRecipientWalletAddr(routeAddress) 19 | }, [router.query.recipientWalletAddr]) 20 | 21 | useEffect(() => { 22 | if (!recipientWalletAddr && window.location.pathname.includes('/dm')) { 23 | router.push(window.location.pathname) 24 | setRecipientWalletAddr(window.location.pathname.replace('/dm/', '')) 25 | } 26 | const checkIfEns = async () => { 27 | if (recipientWalletAddr && isEns(recipientWalletAddr)) { 28 | const address = await resolveName(recipientWalletAddr) 29 | router.push(`/dm/${address}`) 30 | } 31 | } 32 | checkIfEns() 33 | }, [recipientWalletAddr, window.location.pathname]) 34 | 35 | return 36 | } 37 | 38 | export default React.memo(ConversationPage) 39 | -------------------------------------------------------------------------------- /pages/dm/index.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from 'next' 2 | 3 | const BlankConversation: NextPage = () => { 4 | return
5 | } 6 | 7 | export default BlankConversation 8 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from 'next' 2 | 3 | const Home: NextPage = () => { 4 | return
5 | } 6 | 7 | export default Home 8 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xmtp/example-chat-react/54e19d601d1c350245dc70e85678de89a5a35027/public/favicon.ico -------------------------------------------------------------------------------- /public/up-arrow-green.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/up-arrow-grey.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/xmtp-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xmtp/example-chat-react/54e19d601d1c350245dc70e85678de89a5a35027/public/xmtp-icon.png -------------------------------------------------------------------------------- /public/xmtp-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xmtp/example-chat-react/54e19d601d1c350245dc70e85678de89a5a35027/public/xmtp-logo.png -------------------------------------------------------------------------------- /store/app.tsx: -------------------------------------------------------------------------------- 1 | import { Client, Conversation, DecodedMessage } from '@xmtp/xmtp-js' 2 | import { Signer } from 'ethers' 3 | import create from 'zustand' 4 | import getUniqueMessages from '../helpers/getUniqueMessages' 5 | import Web3Modal from 'web3modal' 6 | 7 | interface AppState { 8 | web3Modal: Web3Modal | undefined 9 | setWeb3Modal: (signer: Web3Modal | undefined) => void 10 | signer: Signer | undefined 11 | setSigner: (signer: Signer | undefined) => void 12 | address: string | undefined 13 | setAddress: (address: string | undefined) => void 14 | client: Client | undefined | null 15 | setClient: (client: Client | undefined | null) => void 16 | conversations: Map 17 | setConversations: (conversations: Map) => void 18 | loadingConversations: boolean 19 | setLoadingConversations: (loadingConversations: boolean) => void 20 | convoMessages: Map 21 | previewMessages: Map 22 | setPreviewMessage: (key: string, message: DecodedMessage) => void 23 | setPreviewMessages: (previewMessages: Map) => void 24 | addMessages: (key: string, newMessages: DecodedMessage[]) => number 25 | reset: () => void 26 | } 27 | 28 | export const useAppStore = create((set) => ({ 29 | web3Modal: undefined, 30 | setWeb3Modal: (web3Modal: Web3Modal | undefined) => 31 | set(() => ({ web3Modal })), 32 | signer: undefined, 33 | setSigner: (signer: Signer | undefined) => set(() => ({ signer })), 34 | address: undefined, 35 | setAddress: (address: string | undefined) => set(() => ({ address })), 36 | client: undefined, 37 | setClient: (client: Client | undefined | null) => set(() => ({ client })), 38 | conversations: new Map(), 39 | setConversations: (conversations: Map) => 40 | set(() => ({ conversations })), 41 | loadingConversations: false, 42 | setLoadingConversations: (loadingConversations: boolean) => 43 | set(() => ({ loadingConversations })), 44 | convoMessages: new Map(), 45 | previewMessages: new Map(), 46 | setPreviewMessage: (key: string, message: DecodedMessage) => 47 | set((state) => { 48 | const newPreviewMessages = new Map(state.previewMessages) 49 | newPreviewMessages.set(key, message) 50 | return { previewMessages: newPreviewMessages } 51 | }), 52 | setPreviewMessages: (previewMessages) => set(() => ({ previewMessages })), 53 | addMessages: (key: string, newMessages: DecodedMessage[]) => { 54 | let numAdded = 0 55 | set((state) => { 56 | const convoMessages = new Map(state.convoMessages) 57 | const existing = state.convoMessages.get(key) || [] 58 | const updated = getUniqueMessages([...existing, ...newMessages]) 59 | numAdded = updated.length - existing.length 60 | // If nothing has been added, return the old item to avoid unnecessary refresh 61 | if (!numAdded) { 62 | return { convoMessages: state.convoMessages } 63 | } 64 | convoMessages.set(key, updated) 65 | return { convoMessages } 66 | }) 67 | return numAdded 68 | }, 69 | reset: () => 70 | set(() => { 71 | return { 72 | client: undefined, 73 | conversations: new Map(), 74 | convoMessages: new Map(), 75 | previewMessages: new Map(), 76 | address: undefined, 77 | signer: undefined, 78 | } 79 | }), 80 | })) 81 | -------------------------------------------------------------------------------- /styles/Home.module.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xmtp/example-chat-react/54e19d601d1c350245dc70e85678de89a5a35027/styles/Home.module.css -------------------------------------------------------------------------------- /styles/Loader.module.css: -------------------------------------------------------------------------------- 1 | .lds-roller { 2 | display: inline-block; 3 | position: relative; 4 | width: 40px; 5 | height: 40px; 6 | } 7 | .lds-roller div { 8 | animation: lds-roller 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite; 9 | transform-origin: 20px 20px; 10 | } 11 | .lds-roller div:after { 12 | content: ' '; 13 | display: block; 14 | position: absolute; 15 | width: 6px; 16 | height: 6px; 17 | border-radius: 50%; 18 | background: #31006e; 19 | margin: 0px 0 0 0px; 20 | } 21 | .lds-roller div:nth-child(1) { 22 | animation-delay: -0.036s; 23 | } 24 | .lds-roller div:nth-child(1):after { 25 | top: 0px; 26 | left: 25px; 27 | } 28 | .lds-roller div:nth-child(2) { 29 | animation-delay: -0.08s; 30 | } 31 | .lds-roller div:nth-child(2):after { 32 | top: 0px; 33 | left: 25px; 34 | } 35 | .lds-roller div:nth-child(3) { 36 | animation-delay: -0.12s; 37 | } 38 | .lds-roller div:nth-child(3):after { 39 | top: 0px; 40 | left: 25px; 41 | } 42 | .lds-roller div:nth-child(4) { 43 | animation-delay: -0.16s; 44 | } 45 | .lds-roller div:nth-child(4):after { 46 | top: 0px; 47 | left: 25px; 48 | } 49 | .lds-roller div:nth-child(5) { 50 | animation-delay: -0.2s; 51 | } 52 | .lds-roller div:nth-child(5):after { 53 | top: 0px; 54 | left: 25px; 55 | } 56 | .lds-roller div:nth-child(6) { 57 | animation-delay: -0.24s; 58 | } 59 | .lds-roller div:nth-child(6):after { 60 | top: 0px; 61 | left: 25px; 62 | } 63 | .lds-roller div:nth-child(7) { 64 | animation-delay: -0.28s; 65 | } 66 | .lds-roller div:nth-child(7):after { 67 | top: 0px; 68 | left: 25px; 69 | } 70 | .lds-roller div:nth-child(8) { 71 | animation-delay: -0.32s; 72 | } 73 | .lds-roller div:nth-child(8):after { 74 | top: 0px; 75 | left: 25px; 76 | } 77 | @keyframes lds-roller { 78 | 0% { 79 | transform: rotate(0deg); 80 | } 81 | 100% { 82 | transform: rotate(360deg); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /styles/MessageComposer.module.css: -------------------------------------------------------------------------------- 1 | .bubble { 2 | border-radius: 24px; 3 | background-color: #fafafa; 4 | border: 1px solid #e5e7eb; 5 | height: 40px; 6 | padding: 10px 8px 10px 16px; 7 | } 8 | 9 | .input { 10 | font-size: 14px; 11 | border: none; 12 | background: none; 13 | padding-left: 0; 14 | margin-top: -2.8px; 15 | height: 25px; 16 | } 17 | 18 | .input:focus { 19 | outline: none; 20 | border: none; 21 | box-shadow: none; 22 | } 23 | 24 | .arrow { 25 | /* 26 | // There must be a more flex-boxy way to do this. 27 | // Problem is that this button needs to start higher than the text 28 | */ 29 | margin-top: -2.8px; 30 | width: 25px; 31 | height: 25px; 32 | } 33 | -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const { fontFamily } = require('tailwindcss/defaultTheme') 3 | 4 | module.exports = { 5 | content: [ 6 | './pages/**/*.{js,ts,jsx,tsx}', 7 | './components/**/*.{js,ts,jsx,tsx}', 8 | ], 9 | theme: { 10 | extend: { 11 | spacing: { 12 | 84: '21rem', 13 | }, 14 | colors: { 15 | b: { 16 | 100: '#BFCEFF', 17 | 200: '#90A9FF', 18 | 300: '#5E80F6', 19 | 400: '#3448FF', 20 | 500: '#0c2cdc', 21 | 600: '#0004a5', 22 | }, 23 | bt: { 24 | 100: '#F5F6FF', 25 | 200: '#EDEFFF', 26 | 300: '#E1E4FF', 27 | }, 28 | g: { 29 | 100: '#61E979', 30 | }, 31 | l: { 32 | 100: '#ffaa85', 33 | 200: '#ff7658', 34 | 300: '#FC4F37', 35 | 400: '#c91812', 36 | 500: '#990101', 37 | 600: '#690000', 38 | }, 39 | n: { 40 | 100: '#c2c6d2', 41 | 200: '#989ca7', 42 | 300: '#70747e', 43 | 400: '#4a4e57', 44 | 500: '#272b34', 45 | 600: '#010613', 46 | }, 47 | p: { 48 | 100: '#ffcfff', 49 | 200: '#da9dfd', 50 | 300: '#AC73E7', 51 | 400: '#824DBD', 52 | 500: '#5a2895', 53 | 600: '#31006e', 54 | }, 55 | y: { 56 | 100: '#f5e33e', 57 | 200: '#c7b902', 58 | }, 59 | }, 60 | fontFamily: { 61 | sans: ['Inter', ...fontFamily.sans], 62 | mono: ['Inconsolata', ...fontFamily.mono], 63 | }, 64 | fontSize: { 65 | xs: '10px', 66 | sm: '12px', 67 | md: '14px', 68 | lg: '16px', 69 | xl: '20px', 70 | }, 71 | }, 72 | }, 73 | plugins: [require('@tailwindcss/forms'), require('@tailwindcss/line-clamp')], 74 | } 75 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true 17 | }, 18 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /types/global.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg' { 2 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 3 | const content: any 4 | export default content 5 | } 6 | 7 | declare module '*.png' { 8 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 9 | const content: any 10 | export default content 11 | } 12 | 13 | declare module '*.jpeg' { 14 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 15 | const content: any 16 | export default content 17 | } 18 | 19 | declare module 'react-blockies' { 20 | import React from 'react' 21 | interface BlockiesProps { 22 | seed: string 23 | size?: number 24 | scale?: number 25 | color?: string 26 | bgColor?: string 27 | spotColor?: string 28 | className?: string 29 | } 30 | const Blockies: React.FC 31 | 32 | export default Blockies 33 | } 34 | --------------------------------------------------------------------------------