├── .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 | 
4 |
5 | 
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 |
15 | )
16 | }
17 | if (avatarUrl) {
18 | return (
19 |
20 |
21 |
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 |
13 | Close message view
14 |
15 |
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 |
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 |
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 |
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 |
159 |
160 | Your message list is empty
161 |
162 |
163 | There are no messages for this address
164 |
165 |
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 |
46 |
47 |
48 |
49 |
50 |
51 | {walletAddress && client &&
}
52 |
53 |
54 |
58 |
59 |
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 |
42 |
43 | No wallet connected
44 |
45 |
46 | Please connect a wallet to begin
47 |
48 |
49 | {children}
50 |
51 | )
52 | }
53 |
54 | const ConnectButton = ({ onConnect }: ConnectButtonProps): JSX.Element => {
55 | return (
56 |
60 |
61 | Connect your wallet
62 |
63 |
64 |
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 |
96 |
97 |
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 |
16 | + New Message
17 |
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 |
29 | )
30 | }
31 | return avatarUrl ? (
32 |
33 |
34 |
39 |
40 | ) : (
41 |
42 | )
43 | }
44 |
45 | const NotConnected = ({ onConnect }: UserMenuProps): JSX.Element => {
46 | return (
47 | <>
48 |
60 |
64 | Connect
65 |
69 |
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 |
147 |
148 |
149 |
158 |
159 |
160 |
161 |
162 | xmtp-js v
163 | {packageJson.dependencies['@xmtp/xmtp-js'].substring(
164 | 1
165 | )}
166 |
167 |
168 |
169 |
184 |
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 |
--------------------------------------------------------------------------------