├── .nvmrc ├── .prettierignore ├── src ├── components │ ├── App │ │ ├── index.ts │ │ ├── App.test.jsx │ │ └── App.tsx │ ├── Page │ │ ├── index.ts │ │ ├── Page.test.jsx │ │ └── Page.tsx │ ├── AppBar │ │ ├── index.ts │ │ └── AppBar.tsx │ ├── Drawer │ │ ├── index.ts │ │ └── Drawer.tsx │ ├── Footer │ │ ├── index.ts │ │ ├── Footer.test.jsx │ │ └── Footer.tsx │ ├── Layout │ │ ├── index.ts │ │ ├── Layout.test.jsx │ │ └── Layout.tsx │ ├── PageTitle │ │ ├── index.ts │ │ ├── PageTitle.test.jsx │ │ └── PageTitle.tsx │ ├── AddressChip │ │ ├── index.ts │ │ └── AddressChip.tsx │ ├── ClaimBanner │ │ ├── index.ts │ │ └── ClaimBanner.tsx │ ├── DialogTitle │ │ ├── index.ts │ │ └── DialogTitle.tsx │ ├── LinkPreview │ │ ├── index.ts │ │ └── LinkPreview.tsx │ ├── ThemeToggle │ │ ├── index.ts │ │ └── ThemeToggle.tsx │ ├── MySpacesList │ │ ├── index.ts │ │ └── MySpacesList.tsx │ ├── PageSubtitle │ │ ├── index.ts │ │ ├── PageSubtitle.test.jsx │ │ └── PageSubtitle.tsx │ ├── WhatIsASpace │ │ ├── index.ts │ │ └── WhatIsASpace.tsx │ ├── ActivityTable │ │ └── index.ts │ ├── ClaimedDialog │ │ ├── index.ts │ │ └── ClaimedDialog.tsx │ ├── ErrorBoundary │ │ ├── index.ts │ │ ├── ErrorBoundary.test.jsx │ │ └── ErrorBoundary.tsx │ ├── LifelineDialog │ │ ├── index.ts │ │ ├── LifelineDoneDialog.tsx │ │ └── LifelineDialog.tsx │ ├── MetaMaskSelect │ │ ├── index.ts │ │ └── MetaMaskSelect.tsx │ ├── MoveSpaceDialog │ │ ├── index.ts │ │ ├── MoveSpaceSuccessDialog.tsx │ │ └── MoveSpaceDialog.tsx │ ├── TypewrittingInput │ │ ├── index.ts │ │ └── TypewrittingInput.tsx │ ├── TransferFundsDialog │ │ ├── index.ts │ │ ├── NoFundsDialog.tsx │ │ └── TransferFundsSuccessDialog.tsx │ ├── DeleteKeyValueDialog │ │ ├── index.ts │ │ └── DeleteKeyValueDialog.tsx │ ├── KeyValueInput │ │ ├── index.ts │ │ ├── KeyValueInputEdit.tsx │ │ └── KeyValueInput.tsx │ └── README.md ├── vite-env.d.ts ├── theming │ ├── README.md │ ├── rainbowText.ts │ ├── customPalette.ts │ ├── palette.ts │ ├── purpleButton.ts │ ├── typography.ts │ ├── rainbowButton.ts │ ├── theme.ts │ └── overrides.ts ├── assets │ ├── activity.jpg │ ├── terminal.png │ ├── javascript.png │ ├── spaces-logo.png │ ├── nothing-here.jpg │ ├── whats-a-space.jpg │ ├── README.md │ ├── avax-logo-official.svg │ └── metamask-fox.svg ├── hooks │ ├── README.md │ ├── useThemeLocalStorage.ts │ ├── useThemeLocalStorage.test.jsx │ ├── useEventListener.ts │ └── useLocalStorage.ts ├── utils │ ├── shuffleArray.ts │ ├── verifyAddress.ts │ ├── numberUtils.ts │ ├── encoding.ts │ ├── obfuscateAddress.ts │ ├── detectOperatingSystem.ts │ ├── parseJSON.ts │ ├── typewritting.ts │ ├── setClipboard.ts │ ├── getMeta.ts │ ├── parseJSON.test.ts │ ├── tryNTimes.ts │ ├── calculateCost.ts │ └── spacesVM.ts ├── constants │ ├── README.md │ └── index.ts ├── pages │ ├── CustomSignature │ │ ├── SubmitButton.tsx │ │ └── CustomSignature.tsx │ ├── PingSpaces │ │ └── PingSpaces.tsx │ ├── Routes.tsx │ ├── Page404 │ │ └── Page404.tsx │ ├── KeyDetails │ │ └── KeyDetails.tsx │ └── SpaceDetails │ │ └── SpaceKeyValueRow.tsx ├── main.tsx ├── types │ └── index.ts ├── auto-imports.d.ts ├── test-utils.tsx └── providers │ └── MetaMaskProvider.tsx ├── .eslintignore ├── public ├── _headers ├── googledca5ff4666e43251.html ├── robots.txt ├── spaces_og.png └── spaces_og2.png ├── .gitignore ├── .editorconfig ├── .prettierrc ├── .vscode ├── extensions.json └── settings.json ├── __mocks__ ├── cssMock.js ├── svgMock.js ├── jest.setup.js └── api │ └── metrics.json ├── babel.config.js ├── .eslintrc-auto-import.json ├── auto-imports.d.ts ├── tsconfig.json ├── README.md ├── LICENSE ├── vite.config.ts ├── index.html ├── package.json ├── .eslintrc.js └── badges ├── coverage-lines.svg ├── coverage-branches.svg ├── coverage-functions.svg ├── coverage-statements.svg └── coverage-jest coverage.svg /.nvmrc: -------------------------------------------------------------------------------- 1 | v16 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package.json 2 | -------------------------------------------------------------------------------- /src/components/App/index.ts: -------------------------------------------------------------------------------- 1 | export { App } from './App' 2 | -------------------------------------------------------------------------------- /src/components/Page/index.ts: -------------------------------------------------------------------------------- 1 | export { Page } from './Page' 2 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/components/AppBar/index.ts: -------------------------------------------------------------------------------- 1 | export { AppBar } from './AppBar' 2 | -------------------------------------------------------------------------------- /src/components/Drawer/index.ts: -------------------------------------------------------------------------------- 1 | export { Drawer } from './Drawer' 2 | -------------------------------------------------------------------------------- /src/components/Footer/index.ts: -------------------------------------------------------------------------------- 1 | export { Footer } from './Footer' 2 | -------------------------------------------------------------------------------- /src/components/Layout/index.ts: -------------------------------------------------------------------------------- 1 | export { Layout } from './Layout' 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | public 4 | 5 | /**/*.d.ts 6 | -------------------------------------------------------------------------------- /public/_headers: -------------------------------------------------------------------------------- 1 | https://:project.pages.dev/* 2 | X-Robots-Tag: noindex 3 | -------------------------------------------------------------------------------- /src/components/PageTitle/index.ts: -------------------------------------------------------------------------------- 1 | export { PageTitle } from './PageTitle' 2 | -------------------------------------------------------------------------------- /src/components/AddressChip/index.ts: -------------------------------------------------------------------------------- 1 | export { AddressChip } from './AddressChip' 2 | -------------------------------------------------------------------------------- /src/components/ClaimBanner/index.ts: -------------------------------------------------------------------------------- 1 | export { ClaimBanner } from './ClaimBanner' 2 | -------------------------------------------------------------------------------- /src/components/DialogTitle/index.ts: -------------------------------------------------------------------------------- 1 | export { DialogTitle } from './DialogTitle' 2 | -------------------------------------------------------------------------------- /src/components/LinkPreview/index.ts: -------------------------------------------------------------------------------- 1 | export { LinkPreview } from './LinkPreview' 2 | -------------------------------------------------------------------------------- /src/components/ThemeToggle/index.ts: -------------------------------------------------------------------------------- 1 | export { ThemeToggle } from './ThemeToggle' 2 | -------------------------------------------------------------------------------- /src/theming/README.md: -------------------------------------------------------------------------------- 1 | # Theming 2 | 3 | Minimal styling layer on top of MUI. 4 | -------------------------------------------------------------------------------- /public/googledca5ff4666e43251.html: -------------------------------------------------------------------------------- 1 | google-site-verification: googledca5ff4666e43251.html -------------------------------------------------------------------------------- /src/components/MySpacesList/index.ts: -------------------------------------------------------------------------------- 1 | export { MySpacesList } from './MySpacesList' 2 | -------------------------------------------------------------------------------- /src/components/PageSubtitle/index.ts: -------------------------------------------------------------------------------- 1 | export { PageSubtitle } from './PageSubtitle' 2 | -------------------------------------------------------------------------------- /src/components/WhatIsASpace/index.ts: -------------------------------------------------------------------------------- 1 | export { WhatIsASpace } from './WhatIsASpace' 2 | -------------------------------------------------------------------------------- /src/components/ActivityTable/index.ts: -------------------------------------------------------------------------------- 1 | export { ActivityTable } from './ActivityTable' 2 | -------------------------------------------------------------------------------- /src/components/ClaimedDialog/index.ts: -------------------------------------------------------------------------------- 1 | export { ClaimedDialog } from './ClaimedDialog' 2 | -------------------------------------------------------------------------------- /src/components/ErrorBoundary/index.ts: -------------------------------------------------------------------------------- 1 | export { ErrorBoundary } from './ErrorBoundary' 2 | -------------------------------------------------------------------------------- /src/components/LifelineDialog/index.ts: -------------------------------------------------------------------------------- 1 | export { LifelineDialog } from './LifelineDialog' 2 | -------------------------------------------------------------------------------- /src/components/MetaMaskSelect/index.ts: -------------------------------------------------------------------------------- 1 | export { MetaMaskSelect } from './MetaMaskSelect' 2 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/spaces_og.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ava-labs/spacesvm-js/HEAD/public/spaces_og.png -------------------------------------------------------------------------------- /public/spaces_og2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ava-labs/spacesvm-js/HEAD/public/spaces_og2.png -------------------------------------------------------------------------------- /src/components/MoveSpaceDialog/index.ts: -------------------------------------------------------------------------------- 1 | export { MoveSpaceDialog } from './MoveSpaceDialog' 2 | -------------------------------------------------------------------------------- /src/assets/activity.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ava-labs/spacesvm-js/HEAD/src/assets/activity.jpg -------------------------------------------------------------------------------- /src/assets/terminal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ava-labs/spacesvm-js/HEAD/src/assets/terminal.png -------------------------------------------------------------------------------- /src/components/TypewrittingInput/index.ts: -------------------------------------------------------------------------------- 1 | export { TypewrittingInput } from './TypewrittingInput' 2 | -------------------------------------------------------------------------------- /src/assets/javascript.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ava-labs/spacesvm-js/HEAD/src/assets/javascript.png -------------------------------------------------------------------------------- /src/assets/spaces-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ava-labs/spacesvm-js/HEAD/src/assets/spaces-logo.png -------------------------------------------------------------------------------- /src/components/TransferFundsDialog/index.ts: -------------------------------------------------------------------------------- 1 | export { TransferFundsDialog } from './TransferFundsDialog' 2 | -------------------------------------------------------------------------------- /src/hooks/README.md: -------------------------------------------------------------------------------- 1 | # Hooks 2 | 3 | Everything should be re-exported from the `hoos/index.ts` file. 4 | -------------------------------------------------------------------------------- /src/assets/nothing-here.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ava-labs/spacesvm-js/HEAD/src/assets/nothing-here.jpg -------------------------------------------------------------------------------- /src/assets/whats-a-space.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ava-labs/spacesvm-js/HEAD/src/assets/whats-a-space.jpg -------------------------------------------------------------------------------- /src/components/DeleteKeyValueDialog/index.ts: -------------------------------------------------------------------------------- 1 | export { DeleteKeyValueDialog } from './DeleteKeyValueDialog' 2 | -------------------------------------------------------------------------------- /src/utils/shuffleArray.ts: -------------------------------------------------------------------------------- 1 | export const shuffleArray = (arr: any[]): any[] => arr.sort(() => Math.random() - 0.5) 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | dist-ssr 5 | *.local 6 | stats.html 7 | coverage 8 | yarn-error.log 9 | -------------------------------------------------------------------------------- /src/utils/verifyAddress.ts: -------------------------------------------------------------------------------- 1 | export const isValidWalletAddress = (address: string) => /^0x[a-fA-F0-9]{40}$/.test(address) 2 | -------------------------------------------------------------------------------- /src/utils/numberUtils.ts: -------------------------------------------------------------------------------- 1 | export const numberWithCommas = (num: number) => num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') 2 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | charset = utf-8 6 | indent_style = tab 7 | indent_size = 2 8 | insert_final_newline = true -------------------------------------------------------------------------------- /src/assets/README.md: -------------------------------------------------------------------------------- 1 | # Assets 2 | 3 | Here goes anything that's can be directly linked as an asset. 4 | 5 | - Logos 6 | - Favicons 7 | - Fonts 8 | -------------------------------------------------------------------------------- /src/components/KeyValueInput/index.ts: -------------------------------------------------------------------------------- 1 | export { KeyValueInput } from './KeyValueInput' 2 | export { KeyValueInputEdit } from './KeyValueInputEdit' 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "trailingComma": "all", 4 | "tabWidth": 2, 5 | "singleQuote": true, 6 | "printWidth": 120, 7 | "useTabs": true 8 | } -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "esbenp.prettier-vscode", 4 | "dbaeumer.vscode-eslint", 5 | "EditorConfig.EditorConfig" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /src/constants/README.md: -------------------------------------------------------------------------------- 1 | # Constants 2 | 3 | Any constant that can be used in multiple places. 4 | 5 | - localStorage keys 6 | - global layout variables 7 | - global colors 8 | - generic constants 9 | -------------------------------------------------------------------------------- /src/utils/encoding.ts: -------------------------------------------------------------------------------- 1 | export const utf8_to_b64 = (str: string) => btoa(unescape(encodeURIComponent(str))) 2 | 3 | export const b64_to_utf8 = (str: string) => decodeURIComponent(escape(atob(str))) 4 | -------------------------------------------------------------------------------- /__mocks__/cssMock.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | process() { 3 | return 'module.exports = {};' 4 | }, 5 | getCacheKey() { 6 | // The output is always the same. 7 | return 'cssTransform' 8 | }, 9 | } 10 | -------------------------------------------------------------------------------- /__mocks__/svgMock.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | process() { 3 | return 'module.exports = {};' 4 | }, 5 | getCacheKey() { 6 | // The output is always the same. 7 | return 'svgTransform' 8 | }, 9 | } 10 | -------------------------------------------------------------------------------- /src/components/README.md: -------------------------------------------------------------------------------- 1 | # Components 2 | 3 | Our components. 4 | 5 | Here's a little overview of the components we have: 6 | 7 | ## AreaChart 8 | 9 | TODO 10 | 11 | ## LineChart 12 | 13 | TODO 14 | -------------------------------------------------------------------------------- /src/components/App/App.test.jsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react' 2 | 3 | import { App } from './App' 4 | 5 | it('renders document.body', () => { 6 | render() 7 | 8 | expect(document.body).toBeInTheDocument() 9 | }) 10 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // Make import.meta work with jest... 3 | plugins: [ 4 | () => ({ 5 | visitor: { 6 | MetaProperty(path) { 7 | path.replaceWithSourceString('process') 8 | }, 9 | }, 10 | }), 11 | ], 12 | } 13 | -------------------------------------------------------------------------------- /src/pages/CustomSignature/SubmitButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button, styled } from '@mui/material' 2 | 3 | import { purpleButton } from '@/theming/purpleButton' 4 | 5 | export const SubmitButton = styled(Button)(({ theme }: any) => ({ 6 | ...purpleButton(theme), 7 | })) 8 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import ReactDOM from 'react-dom' 3 | 4 | import { App } from '@/components/App' 5 | 6 | ReactDOM.render( 7 | 8 | 9 | , 10 | document.getElementById('root'), 11 | ) 12 | -------------------------------------------------------------------------------- /src/utils/obfuscateAddress.ts: -------------------------------------------------------------------------------- 1 | export const obfuscateAddress = (str?: string): string => { 2 | if (str) { 3 | const firstChars = str.substr(0, 5) 4 | const lastChars = str.substr(-4) 5 | return `${firstChars}...${lastChars}` 6 | } 7 | 8 | return '' 9 | } 10 | -------------------------------------------------------------------------------- /src/components/Layout/Layout.test.jsx: -------------------------------------------------------------------------------- 1 | import { render } from './../../test-utils' 2 | import { Layout } from './Layout' 3 | 4 | it('renders children', () => { 5 | const { getByText } = render(hello) 6 | 7 | expect(getByText(/hello/i)).toBeInTheDocument() 8 | }) 9 | -------------------------------------------------------------------------------- /src/components/PageTitle/PageTitle.test.jsx: -------------------------------------------------------------------------------- 1 | import { render } from './../../test-utils' 2 | import { PageTitle } from './PageTitle' 3 | 4 | it('renders children', () => { 5 | const { getByText } = render(hello) 6 | 7 | expect(getByText(/hello/i)).toBeInTheDocument() 8 | }) 9 | -------------------------------------------------------------------------------- /.eslintrc-auto-import.json: -------------------------------------------------------------------------------- 1 | { 2 | "globals": { 3 | "createContext": true, 4 | "memo": true, 5 | "useCallback": true, 6 | "useContext": true, 7 | "useEffect": true, 8 | "useMemo": true, 9 | "useReducer": true, 10 | "useRef": true, 11 | "useState": true 12 | } 13 | } -------------------------------------------------------------------------------- /src/components/PageSubtitle/PageSubtitle.test.jsx: -------------------------------------------------------------------------------- 1 | import { render } from './../../test-utils' 2 | import { PageSubtitle } from './PageSubtitle' 3 | 4 | it('renders children', () => { 5 | const { getByText } = render(hello) 6 | 7 | expect(getByText(/hello/i)).toBeInTheDocument() 8 | }) 9 | -------------------------------------------------------------------------------- /src/components/Footer/Footer.test.jsx: -------------------------------------------------------------------------------- 1 | import { render } from './../../test-utils' 2 | import { Footer } from './Footer' 3 | 4 | it('renders copyright text', () => { 5 | const { getByText } = render() 6 | 7 | expect(getByText(/©/i)).toBeInTheDocument() 8 | expect(getByText(/Spaces/i)).toBeInTheDocument() 9 | }) 10 | -------------------------------------------------------------------------------- /src/utils/detectOperatingSystem.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | interface Window { 3 | opera: any 4 | MSStream: any 5 | } 6 | } 7 | 8 | export const userAgent = navigator.userAgent || navigator.vendor || window.opera 9 | 10 | export const isAndroid = /android/i.test(userAgent) 11 | export const isIOS = /iPad|iPhone|iPod/.test(userAgent) && !window.MSStream 12 | -------------------------------------------------------------------------------- /src/utils/parseJSON.ts: -------------------------------------------------------------------------------- 1 | // A wrapper for "JSON.parse()"" to support "undefined" value 2 | export const parseJSON = (value: string | null): T | undefined => { 3 | try { 4 | return value === 'undefined' ? undefined : JSON.parse(value ?? '') 5 | } catch (error) { 6 | // eslint-disable-next-line no-console 7 | console.error('parsing error on', { value }) 8 | return undefined 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export enum TxType { 2 | Claim = 'claim', 3 | Lifeline = 'lifeline', 4 | Set = 'set', 5 | Delete = 'delete', 6 | Move = 'move', 7 | Transfer = 'transfer', 8 | } 9 | 10 | export type TransactionInfo = { 11 | type: TxType 12 | space?: string 13 | key?: string 14 | value?: string 15 | to?: string 16 | units?: number 17 | } 18 | 19 | export type SpaceKeyValue = { 20 | key: string 21 | value: string 22 | } 23 | -------------------------------------------------------------------------------- /src/components/PageSubtitle/PageSubtitle.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Typography } from '@mui/material' 2 | 3 | export const PageSubtitle = memo(({ children, ...rest }: any) => ( 4 | 12 | 13 | {children} 14 | 15 | 16 | )) 17 | -------------------------------------------------------------------------------- /src/utils/typewritting.ts: -------------------------------------------------------------------------------- 1 | export type TimeoutRange = number | [number, number] 2 | 3 | const MINIMUM_THRESHOLD = 30 4 | 5 | export const randomizeTimeout = (ms: TimeoutRange): number => 6 | Array.isArray(ms) 7 | ? // random value inside the specified min and max thresholds 8 | ms[0] + Math.random() * (ms[1] - ms[0]) 9 | : // randomize the value - with a minimum threshold 10 | Math.max(Math.random() * ms, MINIMUM_THRESHOLD) 11 | -------------------------------------------------------------------------------- /src/utils/setClipboard.ts: -------------------------------------------------------------------------------- 1 | export const setClipboard = async ({ value, onSuccess, onFailure }: any) => { 2 | try { 3 | const type = 'text/plain' 4 | const blob = new Blob([value], { type }) 5 | const data = [new ClipboardItem({ [type]: blob })] 6 | 7 | await navigator.clipboard.write(data) 8 | 9 | if (onSuccess) { 10 | onSuccess() 11 | } 12 | } catch (err) { 13 | if (onFailure) { 14 | onFailure() 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/utils/getMeta.ts: -------------------------------------------------------------------------------- 1 | export const getMeta = (input: string) => { 2 | const meta: { [key: string]: string } = {} 3 | 4 | const el = document.createElement('html') 5 | el.innerHTML = input 6 | 7 | Array.from(el.getElementsByTagName('meta')).forEach((item) => { 8 | const k = item.getAttribute('name') || item.getAttribute('property') 9 | const v = item.getAttribute('content') 10 | if (k && v) meta[k] = v 11 | }) 12 | 13 | return { 14 | ...meta, 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "[html]": { 4 | "editor.formatOnSave": false 5 | }, 6 | "[javascript]": { 7 | "editor.formatOnSave": false 8 | }, 9 | "[javascriptreact]": { 10 | "editor.formatOnSave": false 11 | }, 12 | "[typescript]": { 13 | "editor.formatOnSave": false 14 | }, 15 | "[typescriptreact]": { 16 | "editor.formatOnSave": false 17 | }, 18 | "editor.codeActionsOnSave": { 19 | "source.fixAll.eslint": true 20 | }, 21 | } 22 | -------------------------------------------------------------------------------- /src/components/Page/Page.test.jsx: -------------------------------------------------------------------------------- 1 | import { APP_NAME } from './../../constants' 2 | import { render, waitFor } from './../../test-utils' 3 | import { Page } from './Page' 4 | 5 | it('renders children', () => { 6 | const { getByText } = render(hello) 7 | 8 | expect(getByText(/hello/i)).toBeInTheDocument() 9 | }) 10 | 11 | it('set document title', async () => { 12 | render(hello) 13 | 14 | await waitFor(() => expect(document.title).toEqual(`bonjour — ${APP_NAME}`)) 15 | }) 16 | -------------------------------------------------------------------------------- /src/theming/rainbowText.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from '@mui/material' 2 | 3 | export const rainbowText: any = { 4 | backgroundSize: '400% 100%', 5 | backgroundClip: 'text', 6 | wordBreak: 'break-word', 7 | textFillColor: 'transparent', 8 | backgroundColor: (theme: Theme) => (theme.palette.mode === 'dark' ? '#fff' : '#aaa'), 9 | animation: 'hue 5s infinite alternate', 10 | caretColor: '#523df1', 11 | backgroundImage: 12 | 'linear-gradient(60deg,rgba(239,0,143,.5),rgba(110,195,244,.5),rgba(112,56,255,.5),rgba(255,186,39,.5))', 13 | } 14 | -------------------------------------------------------------------------------- /src/utils/parseJSON.test.ts: -------------------------------------------------------------------------------- 1 | import { parseJSON } from './parseJSON' 2 | 3 | it('parse json', () => { 4 | expect(parseJSON('{"hello":"world"}')).toEqual({ hello: 'world' }) 5 | }) 6 | 7 | it('returns undefined when passed "undefined"', () => { 8 | expect(parseJSON('undefined')).toEqual(undefined) 9 | }) 10 | 11 | it('returns undefined when passed an empty string', () => { 12 | expect(parseJSON('')).toEqual(undefined) 13 | }) 14 | 15 | it('returns undefined on error json', () => { 16 | expect(parseJSON({})).toEqual(undefined) 17 | }) 18 | -------------------------------------------------------------------------------- /src/theming/customPalette.ts: -------------------------------------------------------------------------------- 1 | const darkCustomPalette = { 2 | customBackground: '#090719', 3 | } 4 | 5 | const lightCustomPalette = { 6 | customBackground: '#f7f7f7', 7 | } 8 | 9 | export type CustomPalette = { 10 | [Key in keyof typeof darkCustomPalette]: typeof darkCustomPalette[Key] 11 | } 12 | 13 | const lightCustomPaletteTyped: CustomPalette = lightCustomPalette 14 | const darkCustomPaletteTyped: CustomPalette = darkCustomPalette 15 | 16 | export { darkCustomPaletteTyped as darkCustomPalette, lightCustomPaletteTyped as lightCustomPalette } 17 | -------------------------------------------------------------------------------- /src/theming/palette.ts: -------------------------------------------------------------------------------- 1 | import { PaletteOptions } from '@mui/material' 2 | 3 | export const darkPalette: PaletteOptions = { 4 | mode: 'dark', 5 | primary: { 6 | main: 'rgb(235, 52, 119)', 7 | }, 8 | secondary: { 9 | main: '#7362f4', 10 | }, 11 | background: { 12 | paper: '#252334', 13 | }, 14 | } 15 | 16 | export const lightPalette: PaletteOptions = { 17 | mode: 'light', 18 | primary: { 19 | main: 'rgb(235, 52, 119)', 20 | }, 21 | secondary: { 22 | main: '#523df1', 23 | }, 24 | background: { 25 | paper: '#FFFFFF', 26 | }, 27 | } 28 | -------------------------------------------------------------------------------- /auto-imports.d.ts: -------------------------------------------------------------------------------- 1 | // Generated by 'unplugin-auto-import' 2 | // We suggest you to commit this file into source control 3 | declare global { 4 | const useCallback: typeof import('react')['useCallback'] 5 | const useContext: typeof import('react')['useContext'] 6 | const useEffect: typeof import('react')['useEffect'] 7 | const useMemo: typeof import('react')['useMemo'] 8 | const useReducer: typeof import('react')['useReducer'] 9 | const useRef: typeof import('react')['useRef'] 10 | const useState: typeof import('react')['useState'] 11 | } 12 | export {} 13 | -------------------------------------------------------------------------------- /src/components/PageTitle/PageTitle.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Typography } from '@mui/material' 2 | 3 | import { rainbowText } from '@/theming/rainbowText' 4 | 5 | export const PageTitle = memo(({ isRainbow = false, children, sx = {}, ...rest }: any) => ( 6 | 16 | 22 | {children} 23 | 24 | 25 | )) 26 | -------------------------------------------------------------------------------- /src/theming/purpleButton.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from '@mui/material' 2 | 3 | export const purpleButton: any = (theme: Theme) => ({ 4 | backgroundColor: '#523df1', 5 | padding: theme.spacing(1, 10), 6 | height: 80, 7 | minWidth: 280, 8 | fontWeight: 900, 9 | fontSize: 24, 10 | position: 'relative', 11 | boxShadow: '0 0 24px rgb(82 61 241 / 60%)', 12 | '&:hover': { 13 | backgroundColor: '#7a68ff', 14 | boxShadow: '0 0 24px rgb(82 61 241 / 80%)', 15 | }, 16 | '&.Mui-disabled': { 17 | backgroundColor: theme.palette.mode === 'dark' ? 'hsla(0,0%,100%,0.1)' : 'hsla(0,0%,0%,0.1)', 18 | }, 19 | }) 20 | -------------------------------------------------------------------------------- /src/hooks/useThemeLocalStorage.ts: -------------------------------------------------------------------------------- 1 | import { THEME_LOCAL_STORAGE_KEY } from '@/constants' 2 | import { useLocalStorage } from '@/hooks/useLocalStorage' 3 | 4 | export const useThemeLocalStorage = () => { 5 | const [themeLocalStorage] = useLocalStorage(THEME_LOCAL_STORAGE_KEY, 'dark') // track theme in localStorage 6 | 7 | useEffect(() => { 8 | // change scrollbar/browser color based on theme 9 | document.documentElement.setAttribute('style', `color-scheme: ${themeLocalStorage}`) 10 | document.documentElement.setAttribute('class', themeLocalStorage) 11 | }, [themeLocalStorage]) 12 | 13 | return themeLocalStorage 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/tryNTimes.ts: -------------------------------------------------------------------------------- 1 | export const tryNTimes = async (callbackToTry: any, times = 5, interval = 300) => { 2 | const delay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) 3 | 4 | if (times < 1) throw new Error(`Bad argument: 'times' must be greater than 0, but ${times} was received.`) 5 | let attemptCount = 0 6 | // eslint-disable-next-line no-constant-condition 7 | while (true) { 8 | try { 9 | const result = await callbackToTry() 10 | return result 11 | } catch (error) { 12 | if (++attemptCount >= times) throw error 13 | } 14 | await delay(interval) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/auto-imports.d.ts: -------------------------------------------------------------------------------- 1 | // Generated by 'unplugin-auto-import' 2 | // We suggest you to commit this file into source control 3 | declare global { 4 | const createContext: typeof import('react')['createContext'] 5 | const memo: typeof import('react')['memo'] 6 | const useCallback: typeof import('react')['useCallback'] 7 | const useContext: typeof import('react')['useContext'] 8 | const useEffect: typeof import('react')['useEffect'] 9 | const useMemo: typeof import('react')['useMemo'] 10 | const useReducer: typeof import('react')['useReducer'] 11 | const useRef: typeof import('react')['useRef'] 12 | const useState: typeof import('react')['useState'] 13 | } 14 | export {} 15 | -------------------------------------------------------------------------------- /src/theming/typography.ts: -------------------------------------------------------------------------------- 1 | import { ThemeOptions } from '@mui/material' 2 | 3 | export const typography: ThemeOptions['typography'] = { 4 | fontFamily: [ 5 | 'Inter', // Main fontFamily 6 | '-apple-system', // fallback fonts 7 | 'BlinkMacSystemFont', 8 | '"Segoe UI"', 9 | 'Roboto', 10 | '"Helvetica Neue"', 11 | 'Arial', 12 | 'sans-serif', 13 | '"Apple Color Emoji"', 14 | '"Segoe UI Emoji"', 15 | '"Segoe UI Symbol"', 16 | ].join(','), 17 | h1: { 18 | fontWeight: 900, 19 | }, 20 | h2: { 21 | fontWeight: 900, 22 | }, 23 | h3: { 24 | fontWeight: 900, 25 | }, 26 | h4: { 27 | fontWeight: 900, 28 | }, 29 | h5: { 30 | fontWeight: 900, 31 | }, 32 | h6: { 33 | fontWeight: 900, 34 | }, 35 | } 36 | -------------------------------------------------------------------------------- /src/components/ErrorBoundary/ErrorBoundary.test.jsx: -------------------------------------------------------------------------------- 1 | import { render } from './../../test-utils' 2 | import { ErrorBoundaryFallback } from './ErrorBoundary' 3 | 4 | it('renders ErrorBoundaryFallback error message correctly', () => { 5 | const resetErrorBoundary = jest.fn() 6 | const { getByText } = render( 7 | , 8 | ) 9 | 10 | //fireEvent.click(getByText(/try again/i)) 11 | //expect(resetErrorBoundary).toHaveBeenCalled() 12 | 13 | //expect(getByText(/try again/i)).toBeInTheDocument() 14 | expect(getByText(/Error/i)).toBeInTheDocument() 15 | expect(getByText(/Something bad happened/i)).toBeInTheDocument() 16 | expect(getByText(/world/i)).toBeInTheDocument() 17 | }) 18 | -------------------------------------------------------------------------------- /src/hooks/useThemeLocalStorage.test.jsx: -------------------------------------------------------------------------------- 1 | import { THEME_LOCAL_STORAGE_KEY } from '../constants' 2 | import { render } from '../test-utils' 3 | import { useThemeLocalStorage } from './useThemeLocalStorage' 4 | 5 | const setup = (theme) => { 6 | localStorage.setItem(THEME_LOCAL_STORAGE_KEY, theme) 7 | let returnVal = null 8 | const TestComponent = () => { 9 | returnVal = useThemeLocalStorage() 10 | return null 11 | } 12 | 13 | render() 14 | 15 | return returnVal 16 | } 17 | 18 | it('renders proper theme based on localstorage', () => { 19 | const theme = setup('"light"') 20 | 21 | expect(theme).toBe('light') 22 | }) 23 | 24 | it('renders proper theme based on localstorage', () => { 25 | const theme = setup('"dark"') 26 | 27 | expect(theme).toBe('dark') 28 | }) 29 | -------------------------------------------------------------------------------- /src/components/LinkPreview/LinkPreview.tsx: -------------------------------------------------------------------------------- 1 | import { getMeta } from '@/utils/getMeta' 2 | 3 | export const LinkPreview = memo(({ url, render }: any) => { 4 | const [loading, setLoading] = useState(true) 5 | const [preview, setPreviewData] = useState({}) 6 | 7 | useEffect(() => { 8 | const fetchData = async () => { 9 | setLoading(true) 10 | 11 | fetch(`https://api.allorigins.win/get?url=${encodeURIComponent(url)}`) 12 | .then((response) => { 13 | if (response.ok) return response.json() 14 | throw new Error('Network response was not ok.') 15 | }) 16 | .then((data) => { 17 | setPreviewData(getMeta(data.contents)) 18 | setLoading(false) 19 | }) 20 | } 21 | fetchData() 22 | }, [url]) 23 | 24 | return render({ 25 | loading: loading, 26 | preview: preview, 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /src/components/LifelineDialog/LifelineDoneDialog.tsx: -------------------------------------------------------------------------------- 1 | import { Twemoji } from 'react-emoji-render' 2 | import { Dialog, Grow, Typography } from '@mui/material' 3 | 4 | import { DialogTitle } from '@/components/DialogTitle' 5 | import { rainbowText } from '@/theming/rainbowText' 6 | 7 | type LifelineDoneDialogProps = { 8 | open: boolean 9 | onClose(): void 10 | } 11 | 12 | export const LifelineDoneDialog = ({ open, onClose }: LifelineDoneDialogProps) => ( 13 | 14 | 15 | 22 | Lifeline extended! 23 | 24 | 25 | 26 | 27 | ) 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": false, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | "baseUrl": "src", 19 | "rootDir": "src", 20 | "types": ["@honkhonk/vite-plugin-svgr/client"], 21 | "paths": { 22 | "@/*": ["*"] 23 | } 24 | }, 25 | "include": ["./src"], 26 | "exclude" : ["src/**/*.test.ts", "src/**/*.test.tsx", "src/**/*.test.jsx", "src/**/*.test.js"] 27 | } 28 | -------------------------------------------------------------------------------- /src/components/DialogTitle/DialogTitle.tsx: -------------------------------------------------------------------------------- 1 | import { IoCloseCircleOutline } from 'react-icons/io5' 2 | import { DialogTitle as MuiDialogTitle, IconButton, Tooltip } from '@mui/material' 3 | 4 | export interface DialogTitleProps { 5 | children?: React.ReactNode 6 | onClose: () => void 7 | } 8 | 9 | export const DialogTitle = ({ children, onClose, ...other }: DialogTitleProps) => ( 10 | 11 | {children} 12 | {onClose ? ( 13 | 14 | theme.palette.grey[500], 22 | }} 23 | > 24 | 25 | 26 | 27 | ) : null} 28 | 29 | ) 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Spaces ⚛️ 2 | 3 | This is the frontend codebase for SpacesVM. It's a demo project to show off some of the amazing things you can do with Subnets! 4 | 5 | SpacesVM is a new Avalanche-native VM written from scratch to optimize for storage-related operations. If you're interested in learning more about it, check out the [SpacesVM codebase here](https://github.com/ava-labs/spacesvm)! 6 | 7 | ## See it live 8 | 9 | https://tryspaces.xyz 10 | 11 | ## Getting started 🏃 12 | 13 | This project is built with [Vite](https://vitejs.dev/) and [TypeScript](https://www.typescriptlang.org/). 🚀 14 | 15 | ```sh 16 | yarn install 17 | yarn dev 18 | ``` 19 | 20 | And open your browser at http://localhost:3000/. 21 | 22 | ## Contributions 23 | 24 | Feel free to open an issue/PR either here or in the [SpacesVM](https://github.com/ava-labs/spacesvm) project if you're interested in contributing! 25 | -------------------------------------------------------------------------------- /src/components/Layout/Layout.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | import { Box, useMediaQuery, useTheme } from '@mui/material' 3 | 4 | import { AppBar } from '@/components/AppBar' 5 | import { MetaMaskSelect } from '@/components/MetaMaskSelect' 6 | 7 | export const Layout: FC = memo(({ children }) => { 8 | const theme = useTheme() 9 | const isMobile = useMediaQuery(theme.breakpoints.down('sm')) 10 | 11 | return ( 12 | 13 | {!window.location.href.includes('/ping') && } 14 | 25 | {children} 26 | 27 | {isMobile && } 28 | 29 | ) 30 | }) 31 | -------------------------------------------------------------------------------- /src/pages/PingSpaces/PingSpaces.tsx: -------------------------------------------------------------------------------- 1 | import { CircularProgress, Typography } from '@mui/material' 2 | 3 | import { Page } from '@/components/Page' 4 | import { isConnectedToSpacesVM } from '@/utils/spacesVM' 5 | 6 | export const PingSpaces = () => { 7 | const [pingSuccess, setPingSuccess] = useState(null) 8 | useEffect(() => { 9 | const doPingThing = async () => { 10 | const isConnected = await isConnectedToSpacesVM() 11 | setPingSuccess(isConnected) 12 | } 13 | doPingThing() 14 | setInterval(doPingThing, 3000) 15 | }, []) 16 | 17 | return ( 18 | 19 | 20 | {pingSuccess === null ? 'Checking connection to SpacesVM...' : pingSuccess ? 'PING SUCCESSFUL' : 'PING FAILED'} 21 | 22 | {pingSuccess === undefined && } 23 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/components/ErrorBoundary/ErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import { ErrorBoundary as ReactErrorBoundary } from 'react-error-boundary' 2 | import { IoAlertCircleOutline } from 'react-icons/io5' 3 | import { Alert, AlertTitle, Typography } from '@mui/material' 4 | 5 | type ErrorBoundaryFallbackProps = { 6 | error: Error 7 | resetErrorBoundary: () => void 8 | } 9 | export const ErrorBoundaryFallback = memo(({ error /*resetErrorBoundary*/ }: ErrorBoundaryFallbackProps) => ( 10 | }> 11 | Error 12 | 13 | Something bad happened —{' '} 14 | 15 | 16 | {error.message} 17 | 18 | 19 | )) 20 | 21 | export const ErrorBoundary = memo(({ children }) => ( 22 | {children} 23 | )) 24 | -------------------------------------------------------------------------------- /src/pages/Routes.tsx: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | 3 | import { Navigate, Route, Routes as Switch } from 'react-router-dom' 4 | 5 | import { CustomSignature } from './CustomSignature/CustomSignature' 6 | import { Home } from './Home/Home' 7 | import { KeyDetails } from './KeyDetails/KeyDetails' 8 | import { Page404 } from './Page404/Page404' 9 | import { PingSpaces } from './PingSpaces/PingSpaces' 10 | import { SpaceDetails } from './SpaceDetails/SpaceDetails' 11 | 12 | export const Routes = () => ( 13 | 14 | } /> 15 | 16 | } /> 17 | } /> 18 | 19 | } /> 20 | } /> 21 | } /> 22 | 23 | } /> 24 | 25 | ) 26 | 27 | export default Routes 28 | -------------------------------------------------------------------------------- /__mocks__/jest.setup.js: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom' 2 | import '@testing-library/jest-dom/extend-expect' 3 | import 'whatwg-fetch' 4 | 5 | import React from 'react' 6 | 7 | import { API_DOMAIN } from '@/constants' 8 | 9 | import genericMetricDataMock from './api/genericMetricDataMock.json' 10 | import metrics from './api/metrics.json' 11 | 12 | global.React = React 13 | 14 | const getMetricDataMock = (url) => { 15 | const metricId = url.replace(`${API_DOMAIN}/metrics/`, '') 16 | const metricDataMock = {} 17 | Object.keys(genericMetricDataMock).forEach((timestamp) => { 18 | metricDataMock[timestamp] = genericMetricDataMock[timestamp].map((datum) => ({ 19 | ...datum, 20 | valueName: metricId, 21 | })) 22 | }) 23 | return metricDataMock 24 | } 25 | 26 | const fetchResponses = { 27 | [`${API_DOMAIN}/metrics`]: metrics, 28 | '/anything': { value: 42 }, 29 | } 30 | 31 | global.fetch = (url) => 32 | Promise.resolve({ 33 | ok: true, 34 | json: () => Promise.resolve(fetchResponses[url] ?? getMetricDataMock(url)), 35 | }) 36 | -------------------------------------------------------------------------------- /src/test-utils.tsx: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | 3 | import { FC } from 'react' 4 | import { BrowserRouter } from 'react-router-dom' 5 | import { ThemeProvider } from '@mui/material' 6 | import { render } from '@testing-library/react' 7 | 8 | import { darkTheme } from '@/theming/theme' 9 | 10 | const AllTheProviders: FC = ({ children }) => ( 11 | 12 | {children} 13 | 14 | ) 15 | 16 | // Wrapper the usual `render` method with our upgraded providers 17 | const customRender = (ui: any, options?: any) => render(ui, { wrapper: AllTheProviders, ...options }) 18 | 19 | // When you want to easily render a component with a specific URL 20 | const renderWithRouter = (ui: any, { route = '/' } = {}) => { 21 | window.history.pushState({}, 'Test page', route) 22 | 23 | return render(ui, { wrapper: AllTheProviders }) 24 | } 25 | 26 | // re-export everything 27 | export * from '@testing-library/react' 28 | 29 | // override render method 30 | export { customRender as render, renderWithRouter } 31 | -------------------------------------------------------------------------------- /src/theming/rainbowButton.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from '@mui/material' 2 | 3 | export const rainbowButton: any = (theme: Theme) => ({ 4 | backgroundColor: '#e70256', 5 | backgroundImage: 'linear-gradient(100deg,#aa039f,#ed014d,#f67916)', 6 | padding: theme.spacing(1, 10), 7 | height: 80, 8 | minWidth: 280, 9 | fontWeight: 900, 10 | fontSize: 24, 11 | position: 'relative', 12 | boxShadow: '0 0 24px rgb(231 2 86 / 60%)', 13 | '&.Mui-disabled': { 14 | backgroundColor: 'hsla(0,0%,100%,0.1)', 15 | backgroundImage: 'unset', 16 | }, 17 | '&:after': { 18 | content: "''", 19 | position: 'absolute', 20 | top: 0, 21 | right: 0, 22 | bottom: 0, 23 | left: 0, 24 | zIndex: 0, 25 | display: 'block', 26 | borderRadius: 'inherit', 27 | backgroundColor: 'hsla(0,0%,100%,.6)', 28 | mixBlendMode: 'overlay', 29 | pointerEvents: 'none', 30 | transition: 'opacity .25s ease', 31 | opacity: 0, 32 | }, 33 | '&:hover': { 34 | backgroundImage: 'linear-gradient(100deg,#aa039f,#ed014d,#f67916)', 35 | boxShadow: '0 0 24px rgb(231 2 86 / 80%)', 36 | '&:after': { 37 | opacity: 1, 38 | }, 39 | }, 40 | }) 41 | -------------------------------------------------------------------------------- /src/components/TransferFundsDialog/NoFundsDialog.tsx: -------------------------------------------------------------------------------- 1 | import { Twemoji } from 'react-emoji-render' 2 | import { Box, Button, Dialog, DialogContent, Grow, Typography } from '@mui/material' 3 | 4 | import { DialogTitle } from '@/components/DialogTitle' 5 | 6 | type NoFundsDialogProps = { 7 | open: boolean 8 | onClose(): void 9 | } 10 | 11 | export const NoFundsDialog = ({ open, onClose }: NoFundsDialogProps) => ( 12 | 13 | 14 | 15 | Your pockets are empty! 16 | 17 | 18 | 19 | 20 | 21 | You're out of SPC... 22 | 23 | 24 | 25 | Close 26 | 27 | 28 | 29 | 30 | ) 31 | -------------------------------------------------------------------------------- /src/components/ClaimBanner/ClaimBanner.tsx: -------------------------------------------------------------------------------- 1 | import { LazyLoadComponent } from 'react-lazy-load-image-component' 2 | import { Box, Button, styled } from '@mui/material' 3 | 4 | import ActivityBg from '@/assets/activity.jpg' 5 | import { rainbowButton } from '@/theming/rainbowButton' 6 | 7 | const ClaimButton = styled(Button)(({ theme }: any) => ({ 8 | ...rainbowButton(theme), 9 | })) 10 | 11 | export const ClaimBanner = memo(() => ( 12 | 13 | 28 | { 30 | // @ts-ignore 31 | document.querySelector('#layout').scrollTo({ top: 0, left: 0, behavior: 'smooth' }) 32 | }} 33 | variant="contained" 34 | size="large" 35 | > 36 | Claim your space 37 | 38 | 39 | 40 | )) 41 | -------------------------------------------------------------------------------- /src/theming/theme.ts: -------------------------------------------------------------------------------- 1 | import { createTheme, responsiveFontSizes, Theme } from '@mui/material' 2 | import merge from 'lodash/merge' 3 | 4 | import { CustomPalette, darkCustomPalette, lightCustomPalette } from './customPalette' 5 | import { commonOverrides, darkOverrides, lightOverrides } from './overrides' 6 | import { darkPalette, lightPalette } from './palette' 7 | import { typography } from './typography' 8 | 9 | declare module '@mui/material/styles' { 10 | interface Theme { 11 | customPalette: CustomPalette 12 | } 13 | interface ThemeOptions { 14 | customPalette?: CustomPalette 15 | } 16 | } 17 | 18 | export const darkTheme: Theme = responsiveFontSizes( 19 | createTheme({ 20 | palette: { ...darkPalette }, 21 | customPalette: { ...darkCustomPalette }, 22 | typography: { ...typography }, 23 | components: merge({}, { ...commonOverrides }, { ...darkOverrides }), 24 | }), 25 | ) 26 | 27 | export const lightTheme: Theme = responsiveFontSizes( 28 | createTheme({ 29 | palette: { ...lightPalette }, 30 | customPalette: { ...lightCustomPalette }, 31 | typography: { ...typography }, 32 | components: merge({}, { ...commonOverrides }, { ...lightOverrides }), 33 | }), 34 | ) 35 | -------------------------------------------------------------------------------- /src/components/Page/Page.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react' 2 | import { Box, Container, Fade } from '@mui/material' 3 | import { useDocumentTitle } from '@react-hookz/web' 4 | 5 | import { Footer } from '@/components/Footer' 6 | import { APP_NAME, APP_SLOGAN } from '@/constants' 7 | 8 | type PageProps = { 9 | title?: string 10 | showFooter?: boolean 11 | noPadding?: boolean 12 | } 13 | 14 | export const Page = memo(({ title, showFooter = true, children, noPadding = false }: PropsWithChildren) => { 15 | useDocumentTitle(title ? `${title} — ${APP_NAME}` : `${APP_NAME} — ${APP_SLOGAN}`) 16 | 17 | return ( 18 | 19 | 20 | 30 | 38 | {children} 39 | 40 | {showFooter && } 41 | 42 | 43 | 44 | ) 45 | }) 46 | -------------------------------------------------------------------------------- /src/components/App/App.tsx: -------------------------------------------------------------------------------- 1 | import { BrowserRouter } from 'react-router-dom' 2 | import { CssBaseline, Grow, ThemeProvider } from '@mui/material' 3 | import { SnackbarProvider } from 'notistack' 4 | 5 | import { Layout } from '@/components/Layout' 6 | import { useThemeLocalStorage } from '@/hooks/useThemeLocalStorage' 7 | import { Routes } from '@/pages/Routes' 8 | import { MetaMaskProvider } from '@/providers/MetaMaskProvider' 9 | import { darkTheme, lightTheme } from '@/theming/theme' 10 | 11 | export const App = memo(() => { 12 | const themeLocalStorage = useThemeLocalStorage() 13 | 14 | return ( 15 | 16 | 17 | 18 | 19 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | ) 40 | }) 41 | -------------------------------------------------------------------------------- /src/components/MoveSpaceDialog/MoveSpaceSuccessDialog.tsx: -------------------------------------------------------------------------------- 1 | import { Twemoji } from 'react-emoji-render' 2 | import { Box, Button, Dialog, DialogContent, Grow, Typography } from '@mui/material' 3 | 4 | import { DialogTitle } from '@/components/DialogTitle' 5 | import { rainbowText } from '@/theming/rainbowText' 6 | 7 | type MoveSpaceSuccessDialogProps = { 8 | open: boolean 9 | onClose(): void 10 | } 11 | 12 | export const MoveSpaceSuccessDialog = ({ open, onClose }: MoveSpaceSuccessDialogProps) => ( 13 | 14 | 15 | 16 | 23 | Your space has been moved! 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | Close 32 | 33 | 34 | 35 | 36 | ) 37 | -------------------------------------------------------------------------------- /src/components/MySpacesList/MySpacesList.tsx: -------------------------------------------------------------------------------- 1 | import { Twemoji } from 'react-emoji-render' 2 | import { NavLink } from 'react-router-dom' 3 | import { List, ListItem, ListItemIcon, ListItemText, Typography } from '@mui/material' 4 | 5 | export const MySpacesList = memo(({ noMarginOnTitle, onClose, spaces }: any) => ( 6 | <> 7 | {spaces && spaces?.length > 0 && ( 8 | <> 9 | 10 | Your spaces 11 | 12 | 13 | 14 | {spaces.map((space: any, i: number) => ( 15 | 24 | 25 | 26 | 27 | 28 | 31 | {space} 32 | 33 | } 34 | /> 35 | 36 | ))} 37 | 38 | > 39 | )} 40 | > 41 | )) 42 | -------------------------------------------------------------------------------- /src/constants/index.ts: -------------------------------------------------------------------------------- 1 | import { FIRST_NAMES } from './firstNames' 2 | 3 | import { shuffleArray } from '@/utils/shuffleArray' 4 | 5 | // localStorage keys 6 | export const THEME_LOCAL_STORAGE_KEY = 'spaces-theme' 7 | export const ACTIVITY_TABLE_TAB_STORAGE_KEY = 'spaces-activity-table-tab' 8 | 9 | // api 10 | export const API_DOMAIN = 'https://api.tryspaces.xyz/public' 11 | export const SUBNET_ID_URL = 'https://testnet.avascan.info/blockchains?subnet=' 12 | export const CHAIN_ID_URL = 'https://testnet.avascan.info/blockchain/' 13 | 14 | // Overview 15 | export const APP_NAME = 'Spaces' 16 | export const APP_SLOGAN = 'Claim your space on the Avalanche blockchain' 17 | 18 | // units 19 | export const ONE_SECOND_IN_MS = 1000 20 | export const ONE_MINUTES_IN_MS = 60000 21 | export const TEN_MINUTES_IN_MS = 600000 22 | export const PRICE_PER_SPC = 0.05 // $0.05 per SPC, bogus for now 23 | 24 | // regexes 25 | export const VALID_KEY_REGEX = /^[a-zA-Z0-9]{1,256}$/ 26 | export const USERNAME_REGEX_QUERY = /[^\w\s]/gi 27 | export const URL_REGEX = /(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-/]))?/ 28 | export const IMAGE_REGEX = /^http[^?]*.(jpg|jpeg|gif|png|tiff|bmp)(\?(.*))?$/gim 29 | 30 | // variables 31 | export const USERNAMES = shuffleArray(FIRST_NAMES) 32 | -------------------------------------------------------------------------------- /src/hooks/useEventListener.ts: -------------------------------------------------------------------------------- 1 | import { RefObject } from 'react' 2 | 3 | export const useEventListener = ( 4 | eventName: keyof WindowEventMap | string, // string to allow custom event 5 | handler: (event: Event) => void, 6 | element?: RefObject, 7 | ) => { 8 | // Create a ref that stores handler 9 | const savedHandler = useRef<(event: Event) => void>() 10 | 11 | useEffect(() => { 12 | // Define the listening target 13 | const targetElement: T | Window = element?.current || window 14 | if (!(targetElement && targetElement.addEventListener)) { 15 | return 16 | } 17 | 18 | // Update saved handler if necessary 19 | if (savedHandler.current !== handler) { 20 | savedHandler.current = handler 21 | } 22 | 23 | // Create event listener that calls handler function stored in ref 24 | const eventListener = (event: Event) => { 25 | // eslint-disable-next-line no-extra-boolean-cast 26 | if (!!savedHandler?.current) { 27 | savedHandler.current(event) 28 | } 29 | } 30 | 31 | targetElement.addEventListener(eventName, eventListener) 32 | 33 | // Remove event listener on cleanup 34 | return () => { 35 | targetElement.removeEventListener(eventName, eventListener) 36 | } 37 | }, [eventName, element, handler]) 38 | } 39 | -------------------------------------------------------------------------------- /src/components/AddressChip/AddressChip.tsx: -------------------------------------------------------------------------------- 1 | import { Chip, SxProps, Tooltip, TooltipProps } from '@mui/material' 2 | import { useSnackbar } from 'notistack' 3 | 4 | import { obfuscateAddress } from '@/utils/obfuscateAddress' 5 | import { setClipboard } from '@/utils/setClipboard' 6 | 7 | type AddressChipProps = { 8 | address: string 9 | tooltipPlacement?: TooltipProps['placement'] 10 | isMetaMaskAddress?: boolean 11 | sx?: SxProps 12 | copyText?: string 13 | copySuccessText?: string 14 | isObfuscated?: boolean 15 | } 16 | 17 | export const AddressChip = ({ 18 | address, 19 | copyText = 'Copy address', 20 | copySuccessText = 'Address copied!', 21 | tooltipPlacement = 'bottom', 22 | sx = {}, 23 | isObfuscated = true, 24 | isMetaMaskAddress = false, 25 | ...rest 26 | }: AddressChipProps) => { 27 | const { enqueueSnackbar } = useSnackbar() 28 | 29 | return ( 30 | 31 | { 35 | setClipboard({ 36 | value: address, 37 | onSuccess: () => enqueueSnackbar(isMetaMaskAddress ? 'MetaMask address copied!' : copySuccessText), 38 | onFailure: () => enqueueSnackbar("Can't copy!", { variant: 'error' }), 39 | }) 40 | }} 41 | {...rest} 42 | /> 43 | 44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /src/components/TransferFundsDialog/TransferFundsSuccessDialog.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Dialog, DialogContent, Grow, Typography } from '@mui/material' 2 | 3 | import { AddressChip } from '@/components/AddressChip/AddressChip' 4 | import { DialogTitle } from '@/components/DialogTitle' 5 | import { rainbowText } from '@/theming/rainbowText' 6 | import { numberWithCommas } from '@/utils/numberUtils' 7 | 8 | type TransferFundsSuccessDialogProps = { 9 | open: boolean 10 | onClose(): void 11 | transferAmount: number 12 | toAddress: string 13 | } 14 | 15 | export const TransferFundsSuccessDialog = ({ 16 | open, 17 | onClose, 18 | transferAmount, 19 | toAddress, 20 | }: TransferFundsSuccessDialogProps) => ( 21 | 22 | 23 | 24 | 31 | {numberWithCommas(transferAmount)} SPC sent! 32 | 33 | 34 | 35 | 36 | 37 | To: 38 | 39 | 40 | 41 | 42 | ) 43 | -------------------------------------------------------------------------------- /src/assets/avax-logo-official.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/components/ThemeToggle/ThemeToggle.tsx: -------------------------------------------------------------------------------- 1 | import { IoMoonOutline, IoSunnyOutline } from 'react-icons/io5' 2 | import { Grid, IconButton, Tooltip, Typography } from '@mui/material' 3 | 4 | import { THEME_LOCAL_STORAGE_KEY } from '@/constants' 5 | import { useLocalStorage } from '@/hooks/useLocalStorage' 6 | 7 | export const ThemeToggle = () => { 8 | const [themeLocalStorage, setThemeLocalStorage] = useLocalStorage(THEME_LOCAL_STORAGE_KEY, 'dark') // track theme in localStorage 9 | 10 | return ( 11 | 12 | 13 | 14 | setThemeLocalStorage('dark')} 19 | > 20 | 21 | 22 | 23 | 24 | 25 | 26 | / 27 | 28 | 29 | 30 | 31 | setThemeLocalStorage('light')} 36 | > 37 | 38 | 39 | 40 | 41 | 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /src/pages/Page404/Page404.tsx: -------------------------------------------------------------------------------- 1 | import { Twemoji } from 'react-emoji-render' 2 | import { Link } from 'react-router-dom' 3 | import { Box, Link as MuiLink, Typography } from '@mui/material' 4 | 5 | import { Page } from '@/components/Page' 6 | import { rainbowText } from '@/theming/rainbowText' 7 | 8 | export const Page404 = memo(() => ( 9 | 10 | 17 | 27 | 404 28 | 29 | 30 | There's NOTHING here... 31 | just empty{' '} 32 | 39 | space! 40 | 41 | 42 | 43 | 44 | 45 | Looks like something went wrong... 46 | 47 | 48 | 52 | Head on back to safe territory! 53 | 54 | 55 | 56 | 57 | )) 58 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2022, Ava Labs, Inc. 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /src/components/ClaimedDialog/ClaimedDialog.tsx: -------------------------------------------------------------------------------- 1 | import { Twemoji } from 'react-emoji-render' 2 | import { Link } from 'react-router-dom' 3 | import { Box, Button, Dialog, DialogContent, DialogTitle, styled, Typography } from '@mui/material' 4 | 5 | import { purpleButton } from '@/theming/purpleButton' 6 | import { rainbowText } from '@/theming/rainbowText' 7 | 8 | const SeeItLiveButton = styled(Button)(({ theme }: any) => ({ 9 | ...purpleButton(theme), 10 | })) 11 | 12 | export const ClaimedDialog = memo(({ spaceId, ...rest }: any) => ( 13 | 14 | 15 | 23 | You have successfully claimed your space!{' '} 24 | 25 | 26 | 27 | 28 | 29 | 30 | 39 | {spaceId} 40 | 41 | 42 | } 46 | fullWidth 47 | // @ts-ignore 48 | component={Link} 49 | to={`/${spaceId}/`} 50 | > 51 | See it live 52 | 53 | 54 | 55 | 56 | )) 57 | -------------------------------------------------------------------------------- /src/utils/calculateCost.ts: -------------------------------------------------------------------------------- 1 | import { addSeconds, format, formatDistance } from 'date-fns' 2 | 3 | export const TRANSFER_COST = 1 4 | 5 | const SPACE_DESIRABILITY_MULTIPLIER = 5 6 | const MIN_CLAIM_FEE = 100 7 | const MAX_SPACE_NAME_LENGTH = 256 8 | export const getSpaceNameUnits = (spaceName: string): number => { 9 | const desirability = (MAX_SPACE_NAME_LENGTH - spaceName.length) * SPACE_DESIRABILITY_MULTIPLIER 10 | return Math.max(desirability, MIN_CLAIM_FEE) 11 | } 12 | 13 | export const calculateClaimCost = (spaceName: string): number => 5 + getSpaceNameUnits(spaceName) 14 | 15 | const LIFELINE_BASE_COST = 5 16 | export const calculateLifelineCost = (spaceName: string, extendUnits: number): number => 17 | extendUnits * (getSpaceNameUnits(spaceName) / 10) + LIFELINE_BASE_COST 18 | 19 | // 1 unit in lifelineTx == DefaultFreeClaimDuration * DefaultFreeClaimUnits 20 | const DEFAULT_FREE_CLAIM_DURATION = 60 * 60 * 24 * 30 // 30 days 21 | const DEFAULT_FREE_CLAIM_UNITS = 1000 // 1MiB/1KiB 22 | export const HOURS_PER_LIFELINE_UNIT = DEFAULT_FREE_CLAIM_DURATION * DEFAULT_FREE_CLAIM_UNITS 23 | 24 | export const getLifelineExtendedSeconds = (extendUnits: number, spaceUnits: number) => 25 | extendUnits * (HOURS_PER_LIFELINE_UNIT / spaceUnits) 26 | 27 | // Pretty date that represents the amount of time that will be extended 28 | export const getDisplayLifelineTime = (extendUnits: number, spaceUnits: number) => 29 | formatDistance(new Date(), addSeconds(new Date(), getLifelineExtendedSeconds(extendUnits, spaceUnits))) 30 | 31 | // Pretty date that represents the exact date it will be extended to 32 | export const getExtendToTime = (extendUnits: number, spaceUnits: number, existingExpiry: number) => 33 | format(addSeconds(new Date(existingExpiry * 1000), getLifelineExtendedSeconds(extendUnits, spaceUnits)), 'MM/dd/yyyy') 34 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import svgr from '@honkhonk/vite-plugin-svgr' 2 | import react from '@vitejs/plugin-react' 3 | import path from 'path' 4 | import summary from 'rollup-plugin-summary' 5 | import bundleVisualizer from 'rollup-plugin-visualizer' 6 | import AutoImport from 'unplugin-auto-import/vite' 7 | import { defineConfig, Plugin } from 'vite' 8 | 9 | import { dependencies } from './package.json' 10 | 11 | const getBundleVisualizerPlugin = () => 12 | ({ 13 | ...bundleVisualizer({ 14 | template: 'treemap', // or sunburst 15 | open: true, 16 | gzipSize: true, 17 | }), 18 | apply: 'build', 19 | enforce: 'post', 20 | } as Plugin) 21 | 22 | const renderChunks = (deps: Record) => { 23 | const chunks = {} 24 | 25 | Object.keys(deps).forEach((key) => { 26 | if ( 27 | ['react', 'react-router-dom', 'react-dom', 'lodash', 'rollup-plugin-summary', '@metamask/onboarding'].includes( 28 | key, 29 | ) 30 | ) 31 | return 32 | chunks[key] = [key] 33 | }) 34 | return chunks 35 | } 36 | 37 | // https://vitejs.dev/config/ 38 | export default defineConfig({ 39 | plugins: [ 40 | AutoImport({ 41 | imports: [ 42 | 'react', 43 | { 44 | react: [ 45 | // named imports 46 | 'memo', 47 | 'createContext', 48 | ], 49 | }, 50 | ], 51 | dts: './src/auto-imports.d.ts', 52 | eslintrc: { 53 | enabled: true, // Default `false` 54 | filepath: './.eslintrc-auto-import.json', // Default `./.eslintrc-auto-import.json` 55 | globalsPropValue: true, // Default `true`, (true | false | 'readonly' | 'readable' | 'writable' | 'writeable') 56 | }, 57 | }), 58 | react(), 59 | svgr(), 60 | // @ts-ignore 61 | summary({ 62 | showBrotliSize: false, 63 | }), 64 | // uncomment this to analyze bundle 65 | // getBundleVisualizerPlugin(), 66 | ], 67 | resolve: { 68 | alias: [{ find: '@', replacement: path.resolve(__dirname, '/src') }], 69 | }, 70 | build: { 71 | sourcemap: false, 72 | rollupOptions: { 73 | output: { 74 | manualChunks: { 75 | vendor: ['react', 'react-router-dom', 'react-dom'], 76 | ...renderChunks(dependencies), 77 | }, 78 | chunkFileNames: '[name].[hash].js', 79 | }, 80 | }, 81 | }, 82 | }) 83 | -------------------------------------------------------------------------------- /src/pages/KeyDetails/KeyDetails.tsx: -------------------------------------------------------------------------------- 1 | import { Navigate, useParams } from 'react-router-dom' 2 | import { Box, LinearProgress, Typography } from '@mui/material' 3 | 4 | import { Page } from '@/components/Page' 5 | import { URL_REGEX } from '@/constants' 6 | import { rainbowText } from '@/theming/rainbowText' 7 | import { querySpaceKey } from '@/utils/spacesVM' 8 | 9 | export const KeyDetails = () => { 10 | const { spaceId, key } = useParams() 11 | const [redirecting, setRedirecting] = useState(false) 12 | const [value, setValue] = useState(null) 13 | const [isInvalidPage, setIsInvalidPage] = useState(false) 14 | const [isLoading, setIsLoading] = useState(false) 15 | 16 | const getSpaceValue = useCallback(async () => { 17 | if (!spaceId || !key) { 18 | setIsInvalidPage(true) 19 | return 20 | } 21 | setIsLoading(true) 22 | const { value, exists } = await querySpaceKey(spaceId, key) 23 | 24 | if (!exists || value === undefined) { 25 | setIsInvalidPage(true) 26 | return 27 | } 28 | // redirect if it's a url 29 | const valueIsUrl = URL_REGEX.test(value) 30 | if (valueIsUrl) { 31 | setRedirecting(true) 32 | window.location.replace(value) 33 | return 34 | } 35 | setValue(value) 36 | setIsLoading(false) 37 | }, [spaceId, key]) 38 | 39 | useEffect(() => { 40 | getSpaceValue() 41 | }, [spaceId, key, getSpaceValue]) 42 | 43 | if (isInvalidPage) return 44 | 45 | if (redirecting) { 46 | return ( 47 | theme.customPalette.customBackground, 56 | }} 57 | > 58 | {value && ( 59 | 67 | {redirecting ? 'Redirecting...' : value} 68 | 69 | )} 70 | 71 | ) 72 | } 73 | 74 | return ( 75 | 76 | {isLoading && } 77 | {value && ( 78 | 86 | {value} 87 | 88 | )} 89 | 90 | ) 91 | } 92 | -------------------------------------------------------------------------------- /src/theming/overrides.ts: -------------------------------------------------------------------------------- 1 | import { ThemeOptions } from '@mui/material' 2 | 3 | export const commonOverrides: ThemeOptions['components'] = { 4 | MuiFilledInput: { 5 | styleOverrides: { 6 | root: { 7 | fontFamily: 'DM Serif Display', 8 | }, 9 | input: { 10 | paddingTop: 12, 11 | }, 12 | }, 13 | }, 14 | MuiTooltip: { 15 | styleOverrides: { 16 | tooltip: { 17 | borderRadius: 9999, 18 | }, 19 | }, 20 | }, 21 | MuiDialog: { 22 | styleOverrides: { 23 | paper: { 24 | backgroundImage: 'unset', 25 | borderRadius: 18, 26 | padding: 18, 27 | }, 28 | }, 29 | }, 30 | MuiDrawer: { 31 | styleOverrides: { 32 | paper: { 33 | backgroundImage: 'unset', 34 | }, 35 | }, 36 | }, 37 | MuiPaper: { 38 | styleOverrides: { 39 | rounded: { 40 | borderRadius: 16, 41 | }, 42 | }, 43 | }, 44 | MuiChip: { 45 | styleOverrides: { 46 | root: { 47 | color: '#523df1', 48 | backgroundColor: 'rgba(82, 61, 241, 0.04)', 49 | }, 50 | }, 51 | }, 52 | MuiTableCell: { 53 | styleOverrides: { 54 | root: { 55 | padding: '8px 12px', 56 | height: 50, 57 | }, 58 | }, 59 | }, 60 | MuiTableRow: { 61 | styleOverrides: { 62 | root: { 63 | '& td:first-of-type': { 64 | paddingLeft: 4, 65 | }, 66 | '& td:last-of-type': { 67 | paddingRight: 4, 68 | }, 69 | }, 70 | }, 71 | }, 72 | MuiTabs: { 73 | styleOverrides: { 74 | root: { 75 | justifyContent: 'center', 76 | }, 77 | scroller: { 78 | flexGrow: '0', 79 | }, 80 | }, 81 | }, 82 | MuiTab: { 83 | styleOverrides: { 84 | root: { 85 | minHeight: 'unset', 86 | flexDirection: 'row', 87 | alignItems: 'center', 88 | fontFamily: 'DM Serif Display', 89 | textTransform: 'unset', 90 | fontWeight: 900, 91 | fontSize: 16, 92 | }, 93 | }, 94 | }, 95 | MuiButton: { 96 | styleOverrides: { 97 | root: { 98 | borderRadius: 9999, 99 | whiteSpace: 'nowrap', 100 | textTransform: 'unset', 101 | minWidth: 42, 102 | }, 103 | outlined: { 104 | color: 'inherit', 105 | }, 106 | contained: { 107 | textTransform: 'unset', 108 | }, 109 | }, 110 | }, 111 | MuiOutlinedInput: { 112 | styleOverrides: { 113 | notchedOutline: { 114 | border: 'none', 115 | }, 116 | }, 117 | }, 118 | } 119 | 120 | export const lightOverrides: ThemeOptions['components'] = { 121 | MuiChip: { 122 | styleOverrides: { 123 | root: { 124 | color: '#523df1', 125 | backgroundColor: 'rgba(82, 61, 241, 0.04)', 126 | }, 127 | }, 128 | }, 129 | } 130 | 131 | export const darkOverrides: ThemeOptions['components'] = { 132 | MuiTooltip: { 133 | styleOverrides: { 134 | tooltip: { 135 | backgroundColor: '#000000EE', 136 | border: '1px solid #FFFFFF33', 137 | }, 138 | }, 139 | }, 140 | MuiChip: { 141 | styleOverrides: { 142 | root: { 143 | color: '#aba0f8', 144 | backgroundColor: 'rgba(115, 98, 244, 0.08)', 145 | }, 146 | }, 147 | }, 148 | } 149 | -------------------------------------------------------------------------------- /src/hooks/useLocalStorage.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch, SetStateAction } from 'react' 2 | import isEqual from 'lodash/isEqual' 3 | 4 | import { useEventListener } from './useEventListener' 5 | 6 | import { parseJSON } from '@/utils/parseJSON' 7 | 8 | type SetValue = Dispatch> 9 | 10 | export const useLocalStorage = (key: string, initialValue: T): [T, SetValue] => { 11 | const prevValueRef = useRef(initialValue) 12 | 13 | // Get from local storage then 14 | // parse stored json or return initialValue 15 | const readValue = (): T => { 16 | // Prevent build error "window is undefined" but keep keep working 17 | if (typeof window === 'undefined') { 18 | return initialValue 19 | } 20 | 21 | try { 22 | const item = window.localStorage.getItem(key) 23 | return item ? (parseJSON(item) as T) : initialValue 24 | } catch (error) { 25 | // eslint-disable-next-line no-console 26 | console.warn(`Error reading localStorage key “${key}”:`, error) 27 | return initialValue 28 | } 29 | } 30 | 31 | // State to store our value 32 | // Pass initial state function to useState so logic is only executed once 33 | const [storedValue, setStoredValue] = useState(readValue) 34 | 35 | // Return a wrapped version of useState's setter function that ... 36 | // ... persists the new value to localStorage. 37 | const setValue: SetValue = (value) => { 38 | // Prevent build error "window is undefined" but keeps working 39 | if (typeof window == 'undefined') { 40 | // eslint-disable-next-line no-console 41 | console.warn(`Tried setting localStorage key “${key}” even though environment is not a client`) 42 | } 43 | 44 | try { 45 | // Allow value to be a function so we have the same API as useState 46 | const newValue = value instanceof Function ? value(storedValue) : value 47 | 48 | // Save to local storage 49 | window.localStorage.setItem(key, JSON.stringify(newValue)) 50 | 51 | // Save state 52 | setStoredValue(newValue) 53 | 54 | // We dispatch a custom event so every useLocalStorage hook are notified 55 | window.dispatchEvent(new Event('local-storage')) 56 | } catch (error) { 57 | // eslint-disable-next-line no-console 58 | console.warn(`Error setting localStorage key “${key}”:`, error) 59 | } 60 | } 61 | 62 | useEffect(() => { 63 | const newValue = readValue() 64 | setStoredValue(newValue) 65 | prevValueRef.current = newValue 66 | // eslint-disable-next-line react-hooks/exhaustive-deps 67 | }, []) 68 | 69 | const handleStorageChange = () => { 70 | const newValue = readValue() 71 | 72 | if (isEqual(newValue, prevValueRef.current)) return 73 | setStoredValue(newValue) 74 | prevValueRef.current = newValue 75 | // eslint-disable-next-line react-hooks/exhaustive-deps 76 | } 77 | 78 | // this only works for other documents, not the current one 79 | useEventListener('storage', handleStorageChange) 80 | 81 | // this is a custom event, triggered in writeValueToLocalStorage 82 | // See: useLocalStorage() 83 | useEventListener('local-storage', handleStorageChange) 84 | 85 | return [storedValue, setValue] 86 | } 87 | -------------------------------------------------------------------------------- /src/components/DeleteKeyValueDialog/DeleteKeyValueDialog.tsx: -------------------------------------------------------------------------------- 1 | import { Twemoji } from 'react-emoji-render' 2 | import { useParams } from 'react-router-dom' 3 | import { Button, Dialog, DialogContent, Fade, styled, Typography, useTheme } from '@mui/material' 4 | 5 | import MetaMaskFoxLogo from '@/assets/metamask-fox.svg' 6 | import { DialogTitle } from '@/components/DialogTitle' 7 | import { useMetaMask } from '@/providers/MetaMaskProvider' 8 | import { rainbowButton } from '@/theming/rainbowButton' 9 | import { TxType } from '@/types' 10 | import { getSuggestedFee } from '@/utils/spacesVM' 11 | 12 | export const DeleteButton: any = styled(Button)(({ theme }: any) => ({ 13 | ...rainbowButton(theme), 14 | })) 15 | 16 | type DeleteKeyValueDialogProps = { 17 | open: boolean 18 | close(): void 19 | refreshSpaceDetails(): void 20 | spaceKey?: string 21 | } 22 | 23 | export const DeleteKeyValueDialog = ({ open, close, spaceKey, refreshSpaceDetails }: DeleteKeyValueDialogProps) => { 24 | const theme = useTheme() 25 | const { signWithMetaMask, issueTx } = useMetaMask() 26 | const { spaceId } = useParams() 27 | const [isSigning, setIsSigning] = useState(false) 28 | 29 | const deleteKeyValue = async () => { 30 | if (!spaceKey || !spaceId) return 31 | const { typedData } = await getSuggestedFee({ type: TxType.Delete, space: spaceId, key: spaceKey }) 32 | setIsSigning(true) 33 | const signature = await signWithMetaMask(typedData) 34 | setIsSigning(false) 35 | if (!signature) return 36 | const success = await issueTx(typedData, signature) 37 | if (!success) { 38 | // show some sort of failure dialog 39 | return 40 | } 41 | refreshSpaceDetails() 42 | close() 43 | } 44 | 45 | if (spaceKey === undefined) return null 46 | 47 | return ( 48 | 49 | 50 | 51 | Are you SURE you want to delete this item? 52 | 53 | 54 | 55 | 69 | {spaceKey} 70 | 71 | 79 | {isSigning ? ( 80 | 81 | 82 | 83 | ) : ( 84 | 'Delete' 85 | )} 86 | 87 | 88 | 89 | ) 90 | } 91 | -------------------------------------------------------------------------------- /src/components/TypewrittingInput/TypewrittingInput.tsx: -------------------------------------------------------------------------------- 1 | import { PureComponent, ReactNode } from 'react' 2 | 3 | import { randomizeTimeout, TimeoutRange } from '@/utils/typewritting' 4 | 5 | enum Tick { 6 | INIT, 7 | WRITE, 8 | DELETE, 9 | START_DELETE, 10 | } 11 | 12 | const DEFAULTS = { 13 | WRITE_SPEED_MS: 100, 14 | DELETE_SPEED_MS: 60, 15 | WAIT_BEFORE_DELETE_MS: 9000, 16 | } 17 | 18 | interface RenderArgs { 19 | currentText: string 20 | fullCurrentText: string 21 | } 22 | 23 | interface Props { 24 | strings: string[] 25 | waitBeforeDeleteMs?: number 26 | writeSpeedMs?: TimeoutRange 27 | deleteSpeedMs?: TimeoutRange 28 | children: (args: RenderArgs) => ReactNode 29 | } 30 | 31 | interface State { 32 | currentStringIdx: number 33 | currentCharPos: number 34 | isDeleting: boolean 35 | } 36 | 37 | const moveToNextString = ( 38 | prevState: State, 39 | props: Props, 40 | ): Pick => { 41 | const nextStringIdx = prevState.currentStringIdx + 1 42 | return { 43 | isDeleting: false, 44 | currentCharPos: 0, 45 | currentStringIdx: nextStringIdx < props.strings.length ? nextStringIdx : 0, 46 | } 47 | } 48 | 49 | const moveCharPos = 50 | (change: number) => 51 | (prevState: State): Pick => ({ 52 | currentCharPos: prevState.currentCharPos + change, 53 | }) 54 | 55 | const startDeleting = (): Pick => ({ 56 | isDeleting: true, 57 | }) 58 | 59 | export class TypewrittingInput extends PureComponent { 60 | private tickTimeout: number | null = null 61 | 62 | state = { 63 | currentStringIdx: 0, 64 | currentCharPos: 0, 65 | isDeleting: false, 66 | } 67 | 68 | componentDidMount() { 69 | this.queueTick(Tick.INIT) 70 | } 71 | 72 | componentWillUnmount() { 73 | if (this.tickTimeout != null) { 74 | clearTimeout(this.tickTimeout) 75 | } 76 | } 77 | 78 | private queueTick(tickType: Tick) { 79 | const { writeSpeedMs, deleteSpeedMs, waitBeforeDeleteMs } = this.props 80 | 81 | const timeout = 82 | tickType === Tick.INIT 83 | ? 0 84 | : tickType === Tick.WRITE 85 | ? randomizeTimeout(writeSpeedMs != null ? writeSpeedMs : DEFAULTS.WRITE_SPEED_MS) 86 | : tickType === Tick.DELETE 87 | ? randomizeTimeout(deleteSpeedMs != null ? deleteSpeedMs : DEFAULTS.DELETE_SPEED_MS) 88 | : tickType === Tick.START_DELETE 89 | ? waitBeforeDeleteMs != null 90 | ? waitBeforeDeleteMs 91 | : DEFAULTS.WAIT_BEFORE_DELETE_MS 92 | : 0 // ¯\_(ツ)_/¯ 93 | 94 | this.tickTimeout = window.setTimeout(() => this.tick(), timeout) 95 | } 96 | 97 | private tick() { 98 | const { currentStringIdx, currentCharPos, isDeleting } = this.state 99 | const currentText = this.props.strings[currentStringIdx] 100 | 101 | if (!isDeleting) { 102 | if (currentCharPos >= currentText.length) { 103 | this.setState(startDeleting, () => this.queueTick(Tick.START_DELETE)) 104 | } else { 105 | this.setState(moveCharPos(1), () => this.queueTick(Tick.WRITE)) 106 | } 107 | } else { 108 | if (currentCharPos <= 0) { 109 | this.setState(moveToNextString, () => this.queueTick(Tick.WRITE)) 110 | } else { 111 | this.setState(moveCharPos(-1), () => this.queueTick(Tick.DELETE)) 112 | } 113 | } 114 | } 115 | 116 | render() { 117 | const { strings } = this.props 118 | const { currentStringIdx, currentCharPos } = this.state 119 | 120 | const fullCurrentText = strings[currentStringIdx] 121 | const currentText = fullCurrentText.slice(0, currentCharPos) 122 | 123 | return this.props.children({ currentText, fullCurrentText }) as any 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 10 | 11 | 12 | 13 | Spaces 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 23 | 24 | 25 | 26 | 116 | 122 | 123 | 124 | Your browser does not support JavaScript 125 | 126 | 127 | 128 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spacesvm-js", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "build": "tsc && vite build", 7 | "dev": "vite", 8 | "lint": "eslint --ext .js,.jsx,.ts,.tsx src", 9 | "serve": "vite preview", 10 | "test": "DEBUG_PRINT_LIMIT=300000 yarn run jest --runInBand --coverage" 11 | }, 12 | "sideEffects": false, 13 | "dependencies": { 14 | "@emotion/react": "11.7.1", 15 | "@emotion/styled": "11.6.0", 16 | "@metamask/onboarding": "1.0.1", 17 | "@mui/material": "5.3.1", 18 | "@mui/styles": "5.3.0", 19 | "@react-hookz/web": "12.3.0", 20 | "date-fns": "2.28.0", 21 | "lodash": "4.17.21", 22 | "notistack": "2.0.3", 23 | "react": "17.0.2", 24 | "react-device-detect": "2.1.2", 25 | "react-dom": "17.0.2", 26 | "react-emoji-render": "1.2.4", 27 | "react-error-boundary": "3.1.4", 28 | "react-icons": "4.3.1", 29 | "react-lazy-load-image-component": "1.5.1", 30 | "react-router-dom": "6.2.1" 31 | }, 32 | "devDependencies": { 33 | "@honkhonk/vite-plugin-svgr": "1.1.0", 34 | "@testing-library/dom": "8.11.3", 35 | "@testing-library/jest-dom": "5.16.1", 36 | "@testing-library/react": "12.1.2", 37 | "@testing-library/user-event": "13.5.0", 38 | "@types/lodash": "4.14.178", 39 | "@types/node": "17.0.12", 40 | "@types/react-dom": "17.0.11", 41 | "@types/react-lazy-load-image-component": "1.5.2", 42 | "@typescript-eslint/eslint-plugin": "5.10.1", 43 | "@typescript-eslint/parser": "5.10.1", 44 | "@vitejs/plugin-react": "1.1.4", 45 | "babel-jest": "27.4.6", 46 | "babel-preset-react-app": "10.0.1", 47 | "eslint": "8.7.0", 48 | "eslint-config-prettier": "8.3.0", 49 | "eslint-plugin-jsx-a11y": "6.5.1", 50 | "eslint-plugin-prettier": "4.0.0", 51 | "eslint-plugin-react": "7.28.0", 52 | "eslint-plugin-react-hooks": "4.3.0", 53 | "eslint-plugin-simple-import-sort": "7.0.0", 54 | "jest": "27.4.7", 55 | "prettier": "2.5.1", 56 | "rollup-plugin-summary": "1.3.0", 57 | "rollup-plugin-visualizer": "5.5.4", 58 | "typescript": "4.5.5", 59 | "unplugin-auto-import": "0.5.11", 60 | "vite": "2.7.13" 61 | }, 62 | "jest": { 63 | "roots": [ 64 | "/src" 65 | ], 66 | "setupFilesAfterEnv": [ 67 | "/__mocks__/jest.setup.js" 68 | ], 69 | "collectCoverageFrom": [ 70 | "src/**/*.{js,jsx,ts,tsx}", 71 | "!src/**/*.d.ts", 72 | "!src/main.tsx", 73 | "!src/types/*.{js,jsx,ts,tsx}", 74 | "!src/constants/*.{js,jsx,ts,tsx}", 75 | "!src/theming/*.{js,jsx,ts,tsx}" 76 | ], 77 | "collectCoverage": true, 78 | "coverageDirectory": "/coverage", 79 | "coverageReporters": [ 80 | "html", 81 | "json-summary", 82 | "text", 83 | "lcov" 84 | ], 85 | "testMatch": [ 86 | "/src/**/__tests__/**/*.{js,jsx,ts,tsx}", 87 | "/src/**/*.{spec,test}.{js,jsx,ts,tsx}" 88 | ], 89 | "testEnvironment": "jsdom", 90 | "transform": { 91 | "^.+\\.(js|jsx|mjs|cjs|ts|tsx)$": "/node_modules/babel-jest", 92 | "^.+\\.css$": "/__mocks__/cssMock.js", 93 | "^.+\\.svg$": "/__mocks__/svgMock.js" 94 | }, 95 | "transformIgnorePatterns": [ 96 | "[/\\\\]node_modules[/\\\\].+\\.(js|jsx|mjs|cjs|ts|tsx)$", 97 | "^.+\\.module\\.(css)$" 98 | ], 99 | "moduleNameMapper": { 100 | "@/(.*)$": "/src/$1", 101 | "^.+\\.module\\.(css)$": "identity-obj-proxy" 102 | }, 103 | "watchPlugins": [ 104 | "jest-watch-typeahead/filename", 105 | "jest-watch-typeahead/testname" 106 | ], 107 | "resetMocks": true 108 | }, 109 | "babel": { 110 | "env": { 111 | "test": { 112 | "presets": [ 113 | "react-app" 114 | ] 115 | } 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | 3 | const foldersUnderSrc = fs 4 | .readdirSync('src', { withFileTypes: true }) 5 | .filter((dirent) => dirent.isDirectory()) 6 | .map((dirent) => dirent.name) 7 | 8 | module.exports = { 9 | parser: '@typescript-eslint/parser', // Specifies the ESLint parser 10 | extends: [ 11 | 'eslint:recommended', 12 | 'plugin:react/recommended', 13 | 'plugin:react/jsx-runtime', 14 | 'plugin:@typescript-eslint/recommended', // Uses the recommended rules from the @typescript-eslint/eslint-plugin 15 | 'prettier', // Uses eslint-config-prettier to disable ESLint rules from @typescript-eslint/eslint-plugin that would conflict with prettier 16 | 'plugin:jsx-a11y/strict', 17 | './.eslintrc-auto-import.json', 18 | 'plugin:prettier/recommended', // Enables eslint-plugin-prettier and eslint-config-prettier. This will display prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array. 19 | ], 20 | env: { 21 | browser: true, 22 | jasmine: true, 23 | jest: true, 24 | }, 25 | plugins: ['react', 'react-hooks', 'jsx-a11y', 'simple-import-sort'], 26 | parserOptions: { 27 | ecmaVersion: 'latest', // Allows for the parsing of modern ECMAScript features 28 | sourceType: 'module', // Allows for the use of imports 29 | ecmaFeatures: { 30 | jsx: true, // Allows for the parsing of JSX 31 | }, 32 | }, 33 | rules: { 34 | // Place to specify ESLint rules. Can be used to overwrite rules specified from the extended configs 35 | // e.g. "@typescript-eslint/explicit-function-return-type": "off", 36 | '@typescript-eslint/explicit-member-accessibility': 0, 37 | '@typescript-eslint/explicit-function-return-type': 'off', 38 | '@typescript-eslint/no-non-null-assertion': 0, 39 | '@typescript-eslint/no-var-requires': 0, 40 | '@typescript-eslint/ban-ts-comment': 0, 41 | '@typescript-eslint/ban-types': 0, 42 | '@typescript-eslint/camelcase': 0, 43 | '@typescript-eslint/ban-ts-ignore': 0, 44 | '@typescript-eslint/no-explicit-any': 0, 45 | 'no-async-promise-executor': 0, 46 | 'arrow-body-style': ['error', 'as-needed'], 47 | 'no-console': 'warn', 48 | 'no-irregular-whitespace': 0, 49 | 'react/jsx-key': 0, 50 | 'react-hooks/exhaustive-deps': 1, 51 | 'react/jsx-sort-default-props': [ 52 | 'warn', 53 | { 54 | ignoreCase: false, 55 | }, 56 | ], 57 | 'simple-import-sort/imports': 'warn', 58 | 'simple-import-sort/exports': 'warn', 59 | 'react-hooks/rules-of-hooks': 1, 60 | 'react/prop-types': 0, 61 | 'react/display-name': 0, 62 | 'react/no-unescaped-entities': 0, 63 | 'jsx-a11y/no-autofocus': 0, 64 | 'jsx-a11y/media-has-caption': 0, 65 | '@typescript-eslint/no-empty-function': 0, 66 | }, 67 | settings: { 68 | react: { 69 | pragma: 'React', 70 | version: 'detect', // Tells eslint-plugin-react to automatically detect the version of React to use 71 | }, 72 | 'import/resolver': { 73 | alias: { 74 | map: [['@/*', './src/']], 75 | extensions: ['.js', '.jsx', '.json', '.ts', '.tsx'], 76 | }, 77 | }, 78 | }, 79 | overrides: [ 80 | { 81 | files: ['**/*.ts', '**/*.tsx', '**/*.jsx'], 82 | rules: { 83 | 'simple-import-sort/imports': [ 84 | 'warn', 85 | { 86 | groups: [ 87 | // Packages. `react` related packages come first. 88 | // Things that start with a letter (or digit or underscore), or `@` followed by a letter. 89 | ['^react', '^@?\\w'], 90 | // Absolute imports and Relative imports. 91 | [`^(${foldersUnderSrc.join('|')})(/.*|$)`, '^\\.'], 92 | // for other local imports. 93 | ['^[^.]'], 94 | ], 95 | }, 96 | ], 97 | }, 98 | }, 99 | ], 100 | globals: { 101 | global: 'readonly', 102 | Atomics: 'readonly', 103 | process: true, 104 | SharedArrayBuffer: 'readonly', 105 | Promise: 'readonly', 106 | Buffer: 'readonly', 107 | WeakSet: 'readonly', 108 | setImmediate: 'readonly', 109 | setInterval: 'readonly', 110 | setTimeout: 'readonly', 111 | shallow: 'readonly', 112 | page: 'readonly', 113 | }, 114 | } 115 | -------------------------------------------------------------------------------- /src/assets/metamask-fox.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 50 | 51 | 52 | 54 | 56 | 57 | 58 | 59 | 61 | 62 | -------------------------------------------------------------------------------- /src/components/AppBar/AppBar.tsx: -------------------------------------------------------------------------------- 1 | import { Twemoji } from 'react-emoji-render' 2 | import { Link } from 'react-router-dom' 3 | import { 4 | AppBar as MuiAppBar, 5 | Box, 6 | Container, 7 | Dialog, 8 | DialogContent, 9 | Grid, 10 | Grow, 11 | IconButton, 12 | Toolbar, 13 | Tooltip, 14 | Typography, 15 | useMediaQuery, 16 | useTheme, 17 | } from '@mui/material' 18 | 19 | import { DialogTitle } from '../DialogTitle' 20 | import { MySpacesList } from '../MySpacesList' 21 | 22 | import Logo from '@/assets/spaces-logo.png' 23 | import { Drawer } from '@/components/Drawer' 24 | import { MetaMaskSelect } from '@/components/MetaMaskSelect' 25 | import { ThemeToggle } from '@/components/ThemeToggle' 26 | import { APP_NAME } from '@/constants' 27 | import { useMetaMask } from '@/providers/MetaMaskProvider' 28 | 29 | export const AppBar = memo(() => { 30 | const theme = useTheme() 31 | const isMobile = useMediaQuery(theme.breakpoints.down('sm')) 32 | const [dialogOpen, setDialogOpen] = useState(false) 33 | const { currentAddress, fetchOwnedSpaces } = useMetaMask() 34 | const [myOwnedSpaces, setOwnedSpaces] = useState() 35 | 36 | useEffect(() => { 37 | if (!currentAddress) return 38 | 39 | const fetchMySpaces = async () => { 40 | setOwnedSpaces(await fetchOwnedSpaces(currentAddress)) 41 | } 42 | 43 | fetchMySpaces() 44 | }, [currentAddress, fetchOwnedSpaces]) 45 | 46 | return ( 47 | 48 | 58 | theme.palette.mode === 'dark' ? 'rgba(9, 7, 25, 0.9)' : 'rgba(247, 247, 247, 0.9)', 59 | 60 | /* if backdrop support: very transparent and blurred */ 61 | '@supports ((-webkit-backdrop-filter: none) or (backdrop-filter: none))': { 62 | backdropFilter: 'blur(5px)', 63 | backgroundColor: 'unset', 64 | }, 65 | }} 66 | > 67 | 68 | 69 | 70 | 71 | 79 | 80 | 86 | {APP_NAME} 87 | 88 | 89 | 90 | 91 | 99 | 100 | 101 | 102 | 103 | {!isMobile && ( 104 | 105 | 106 | 107 | )} 108 | 109 | {myOwnedSpaces && myOwnedSpaces?.length > 0 && ( 110 | 111 | 112 | 113 | setDialogOpen(true)}> 114 | 115 | 116 | 117 | 118 | 119 | setDialogOpen(false)} 124 | TransitionComponent={Grow} 125 | > 126 | setDialogOpen(false)} /> 127 | 128 | setDialogOpen(false)} spaces={myOwnedSpaces} /> 129 | 130 | 131 | 132 | )} 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | ) 144 | }) 145 | -------------------------------------------------------------------------------- /badges/coverage-lines.svg: -------------------------------------------------------------------------------- 1 | lines: 68.89%lineslines68.89%68.89% -------------------------------------------------------------------------------- /badges/coverage-branches.svg: -------------------------------------------------------------------------------- 1 | branches: 52.01%branchesbranches52.01%52.01% -------------------------------------------------------------------------------- /badges/coverage-functions.svg: -------------------------------------------------------------------------------- 1 | functions: 65.18%functionsfunctions65.18%65.18% -------------------------------------------------------------------------------- /badges/coverage-statements.svg: -------------------------------------------------------------------------------- 1 | statements: 68.42%statementsstatements68.42%68.42% -------------------------------------------------------------------------------- /badges/coverage-jest coverage.svg: -------------------------------------------------------------------------------- 1 | jest coverage: 63.63%jest coveragejest coverage63.63%63.63% -------------------------------------------------------------------------------- /src/utils/spacesVM.ts: -------------------------------------------------------------------------------- 1 | import { b64_to_utf8, utf8_to_b64 } from './encoding' 2 | import { tryNTimes } from './tryNTimes' 3 | 4 | import { API_DOMAIN } from '@/constants' 5 | import { TransactionInfo } from '@/types' 6 | 7 | export const fetchSpaces = async (method: string, params = {}) => { 8 | const response = await fetch(`${API_DOMAIN}`, { 9 | headers: { 10 | 'Content-Type': 'application/json', 11 | }, 12 | method: 'POST', 13 | body: JSON.stringify({ 14 | jsonrpc: '2.0', 15 | method: `spacesvm.${method}`, 16 | params, 17 | id: 1, 18 | }), 19 | }) 20 | 21 | // Recursively read file chunks until done 22 | const reader = response.body?.getReader() 23 | const decoder = new TextDecoder() 24 | let decodedResult = '' 25 | const processChunk = async ({ done, value }: any) => { 26 | if (done) return 27 | decodedResult += decoder.decode(value) 28 | await reader?.read().then(processChunk) 29 | } 30 | await reader?.read().then(processChunk) 31 | 32 | const data = JSON.parse(decodedResult) 33 | if (data.error) throw data.error 34 | return data?.result 35 | } 36 | 37 | /** 38 | * Pings SpacesVM returns whether connected or not 39 | */ 40 | export const isConnectedToSpacesVM = async (): Promise => { 41 | try { 42 | const { success } = await fetchSpaces('ping') 43 | return success 44 | } catch { 45 | return false 46 | } 47 | } 48 | 49 | export const querySpace = async (space: string) => { 50 | try { 51 | return await fetchSpaces('info', { 52 | space, 53 | }) 54 | } catch (err) { 55 | // eslint-disable-next-line no-console 56 | console.log(`err`, err) 57 | return 58 | } 59 | } 60 | 61 | export const querySpaceKey = async (space: string, key: string) => { 62 | const keyData = await fetchSpaces('resolve', { path: `${space}/${key}` }) 63 | return { 64 | ...keyData, 65 | value: keyData?.value && b64_to_utf8(keyData.value), 66 | } 67 | } 68 | 69 | export const getLatestBlockID = async () => { 70 | const blockData = await fetchSpaces('lastAccepted') 71 | return blockData?.blockId 72 | } 73 | 74 | export const getOwnedSpaces = async (currentAddress: string) => { 75 | if (!currentAddress) return 76 | const ownedSpaces = await fetchSpaces('owned', { address: currentAddress }) 77 | 78 | return ownedSpaces 79 | } 80 | 81 | export const getLatestActivity = async () => { 82 | const activity = await fetchSpaces('recentActivity') 83 | 84 | return activity 85 | } 86 | 87 | export const getNetworks = async () => { 88 | const networks = await fetchSpaces('network') 89 | 90 | return networks 91 | } 92 | 93 | export const isAlreadyClaimed = async (space: string) => { 94 | const response = await fetchSpaces('claimed', { 95 | space, 96 | }) 97 | if (!response) throw 'Unable to validate space' 98 | return response.claimed 99 | } 100 | 101 | // Requests for the estimated difficulty from VM. 102 | export const estimateDifficulty = async () => await fetchSpaces('difficultyEstimate') 103 | 104 | export const getAddressBalance = async (address: string) => fetchSpaces('balance', { address }) // some random balance for now 105 | 106 | /** 107 | * Query spacesVM to get the fee for a transaction, and the typedData that needs to be signed 108 | * in orde to submit the transaction 109 | * 110 | * @param transactionInfo Object containing type, space, units, key, value, and to (some optional) 111 | * @returns Object wit 112 | */ 113 | export const getSuggestedFee = async (transactionInfo: TransactionInfo) => { 114 | const input = { ...transactionInfo } 115 | if (transactionInfo.value) { 116 | input.value = utf8_to_b64(transactionInfo.value) 117 | } 118 | return await fetchSpaces('suggestedFee', { input }) 119 | } 120 | 121 | /** 122 | * Issues a transaction to spacesVM and polls the VM until the transaction is confirmed. 123 | * Used for claim, lifeline, set, delete, move, and transfer 124 | * https://github.com/ava-labs/spacesvm#transaction-types 125 | * 126 | * @param typedData typedData from getSuggestedFee 127 | * @param signature signed typedData 128 | * @returns if successful, response has a txId 129 | */ 130 | export const issueAndConfirmTransaction = async (typedData: any, signature: string): Promise => { 131 | const txResponse = await fetchSpaces('issueTx', { typedData, signature }) 132 | if (!txResponse?.txId) return false 133 | 134 | const checkIsAccepted = async () => { 135 | try { 136 | const { accepted } = await fetchSpaces('hasTx', { txId: txResponse.txId }) 137 | if (accepted) return true 138 | throw 'Transaction has not yet been accepted.' 139 | } catch (error) { 140 | throw 'Failed to fetch.' 141 | } 142 | } 143 | 144 | try { 145 | const response = await tryNTimes(checkIsAccepted, 20, 500) 146 | if (response) return true 147 | } catch { 148 | return false 149 | } 150 | return false 151 | } 152 | -------------------------------------------------------------------------------- /src/components/Drawer/Drawer.tsx: -------------------------------------------------------------------------------- 1 | import { Twemoji } from 'react-emoji-render' 2 | import { IoCloseCircleOutline, IoMenu } from 'react-icons/io5' 3 | import { NavLink } from 'react-router-dom' 4 | import { 5 | Box, 6 | Divider, 7 | Grid, 8 | IconButton, 9 | List, 10 | ListItem, 11 | ListItemIcon, 12 | ListItemText, 13 | SwipeableDrawer, 14 | Tooltip, 15 | Typography, 16 | useMediaQuery, 17 | useTheme, 18 | } from '@mui/material' 19 | 20 | import { MySpacesList } from '../MySpacesList' 21 | 22 | import Javascript from '@/assets/javascript.png' 23 | import Logo from '@/assets/spaces-logo.png' 24 | import Terminal from '@/assets/terminal.png' 25 | import { ThemeToggle } from '@/components/ThemeToggle' 26 | import { useMetaMask } from '@/providers/MetaMaskProvider' 27 | 28 | export const Drawer = memo(() => { 29 | const [myOwnedSpaces, setOwnedSpaces] = useState() 30 | const [open, setOpen] = useState(false) 31 | const theme = useTheme() 32 | const { currentAddress, fetchOwnedSpaces } = useMetaMask() 33 | const isMobile = useMediaQuery(theme.breakpoints.down('sm')) 34 | 35 | useEffect(() => { 36 | if (!currentAddress) return 37 | 38 | const fetchMySpaces = async () => { 39 | setOwnedSpaces(await fetchOwnedSpaces(currentAddress)) 40 | } 41 | 42 | fetchMySpaces() 43 | }, [currentAddress, fetchOwnedSpaces]) 44 | 45 | return ( 46 | <> 47 | 48 | setOpen(true)} edge={isMobile ? undefined : 'end'}> 49 | 50 | 51 | 52 | setOpen(true)} 54 | PaperProps={{ 55 | sx: { 56 | backgroundColor: (theme) => theme.customPalette.customBackground, 57 | borderLeft: '2px solid hsla(0, 0%, 100%, 0.2)', 58 | width: '40vw', 59 | minWidth: 300, 60 | maxWidth: 540, 61 | p: { 62 | xs: 3, 63 | sm: 6, 64 | }, 65 | }, 66 | }} 67 | anchor={'right'} 68 | open={open} 69 | onClose={() => setOpen(false)} 70 | > 71 | 72 | theme.palette.grey[400], 75 | position: 'absolute', 76 | bottom: isMobile ? 12 : 'unset', 77 | top: isMobile ? 'unset' : 12, 78 | right: 22, 79 | }} 80 | > 81 | setOpen(false)} color="inherit"> 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | Menu 90 | 91 | 92 | 101 | 102 | 103 | 104 | 105 | 106 | {[ 107 | { label: 'Home', emoji: , url: '/' }, 108 | { 109 | label: 'SpacesVM', 110 | emoji: , 111 | url: 'https://spacesvm.xyz/', 112 | isExternal: true, 113 | }, 114 | { 115 | label: 'SpacesVM JS', 116 | emoji: , 117 | url: 'https://github.com/ava-labs/spacesvm-js', 118 | isExternal: true, 119 | }, 120 | { 121 | label: 'Spaces CLI', 122 | emoji: , 123 | url: 'https://spaces-cli.xyz/', 124 | isExternal: true, 125 | }, 126 | { 127 | label: 'Subnet CLI', 128 | emoji: , 129 | url: 'https://subnet-cli.xyz/', 130 | isExternal: true, 131 | }, 132 | ].map(({ label, emoji, url, isExternal }) => ( 133 | setOpen(false)} 143 | > 144 | {emoji} 145 | 148 | {label} 149 | 150 | } 151 | /> 152 | 153 | ))} 154 | 155 | 156 | {myOwnedSpaces && myOwnedSpaces?.length > 0 && ( 157 | <> 158 | 159 | 160 | setOpen(false)} /> 161 | > 162 | )} 163 | 164 | > 165 | ) 166 | }) 167 | -------------------------------------------------------------------------------- /src/components/MetaMaskSelect/MetaMaskSelect.tsx: -------------------------------------------------------------------------------- 1 | import { AiOutlineRedo } from 'react-icons/ai' 2 | import { IoSwapVertical } from 'react-icons/io5' 3 | import { 4 | Button, 5 | ButtonGroup, 6 | Grid, 7 | keyframes, 8 | Theme, 9 | Tooltip, 10 | Typography, 11 | useMediaQuery, 12 | useTheme, 13 | } from '@mui/material' 14 | import { useSnackbar } from 'notistack' 15 | 16 | import MetaMaskFoxLogo from '@/assets/metamask-fox.svg' 17 | import { TransferFundsDialog } from '@/components/TransferFundsDialog' 18 | import { useMetaMask } from '@/providers/MetaMaskProvider' 19 | import { numberWithCommas } from '@/utils/numberUtils' 20 | import { obfuscateAddress } from '@/utils/obfuscateAddress' 21 | import { setClipboard } from '@/utils/setClipboard' 22 | 23 | const growWidth = keyframes` 24 | 0% { 25 | max-width: 0; 26 | } 27 | 100% { 28 | max-width: 300px; 29 | } 30 | ` 31 | 32 | export const MetaMaskSelect = () => { 33 | const { enqueueSnackbar } = useSnackbar() 34 | const theme = useTheme() 35 | const { 36 | currentAddress, 37 | connectToMetaMask, 38 | balance, 39 | isConnectingToMM, 40 | metaMaskExists, 41 | isConnectedToSpaces, 42 | checkSpacesConnection, 43 | } = useMetaMask() 44 | const [transferOpen, setTransferOpen] = useState(false) 45 | const isMobile = useMediaQuery(theme.breakpoints.down('sm')) 46 | 47 | const handleMetaMaskClick = () => { 48 | if (!currentAddress) { 49 | connectToMetaMask() 50 | return 51 | } 52 | onCopy() 53 | } 54 | 55 | const onCopy = async () => { 56 | await setClipboard({ 57 | value: currentAddress, 58 | onSuccess: () => enqueueSnackbar('MetaMask address copied!'), 59 | onFailure: () => enqueueSnackbar("Can't copy!", { variant: 'error' }), 60 | }) 61 | } 62 | 63 | const mobileStyles = { 64 | position: 'fixed', 65 | bottom: 0, 66 | paddingTop: 1, 67 | paddingBottom: 1, 68 | borderRadius: 0, 69 | display: metaMaskExists && isConnectedToSpaces ? 'flex' : 'none', 70 | backgroundColor: (theme: Theme) => theme.customPalette.customBackground, 71 | borderTop: (theme: Theme) => `1px solid ${theme.palette.divider}`, 72 | } 73 | 74 | return ( 75 | <> 76 | 82 | 83 | 84 | 85 | } 87 | variant="outlined" 88 | color="secondary" 89 | onClick={handleMetaMaskClick} 90 | disabled={isConnectingToMM} 91 | sx={{ 92 | background: theme.customPalette.customBackground, 93 | '&:hover': { background: theme.customPalette.customBackground }, 94 | }} 95 | > 96 | {currentAddress ? obfuscateAddress(currentAddress) : 'Connect'} 97 | 98 | 99 | 100 | {metaMaskExists && isConnectedToSpaces && ( 101 | 102 | setTransferOpen(true)} 106 | sx={{ 107 | background: theme.customPalette.customBackground, 108 | '&:hover': { background: theme.customPalette.customBackground }, 109 | animation: `800ms ${growWidth} ease`, 110 | animationDirection: 'forwards', 111 | }} 112 | > 113 | 123 | {balance !== null ? numberWithCommas(balance) : 0}{' '} 124 | 132 | SPC 133 | 134 | 135 | 136 | 137 | 138 | )} 139 | {!isConnectedToSpaces && ( 140 | 141 | } 143 | variant="outlined" 144 | color="secondary" 145 | onClick={checkSpacesConnection} 146 | sx={{ 147 | color: theme.palette.primary.main, 148 | background: theme.customPalette.customBackground, 149 | '&:hover': { background: theme.customPalette.customBackground }, 150 | }} 151 | > 152 | Connection failed 153 | 154 | 155 | )} 156 | 157 | 158 | 159 | setTransferOpen(false)} /> 160 | > 161 | ) 162 | } 163 | -------------------------------------------------------------------------------- /src/components/KeyValueInput/KeyValueInputEdit.tsx: -------------------------------------------------------------------------------- 1 | import { IoCloseCircleOutline } from 'react-icons/io5' 2 | import { Button, CircularProgress, Grid, IconButton, styled, TextField, Tooltip, useTheme } from '@mui/material' 3 | import { useSnackbar } from 'notistack' 4 | 5 | import { VALID_KEY_REGEX } from '@/constants' 6 | import { useMetaMask } from '@/providers/MetaMaskProvider' 7 | import { purpleButton } from '@/theming/purpleButton' 8 | import { TxType } from '@/types' 9 | import { getSuggestedFee } from '@/utils/spacesVM' 10 | 11 | const SetButton = styled(Button)(({ theme }) => ({ 12 | ...purpleButton(theme), 13 | padding: 0, 14 | minWidth: 0, 15 | height: 66, 16 | })) 17 | 18 | type KeyValueInputProps = { 19 | spaceId: string 20 | refreshSpaceDetails: any 21 | keyBeingEdited: string 22 | valueBeingEdited: string 23 | onComplete: () => void 24 | } 25 | 26 | export const KeyValueInputEdit = memo( 27 | ({ spaceId, refreshSpaceDetails, keyBeingEdited, valueBeingEdited, onComplete }: KeyValueInputProps) => { 28 | const { signWithMetaMask, issueTx } = useMetaMask() 29 | const theme = useTheme() 30 | const [formValues, setFormValues] = useState<{ keyText?: string; valueText?: string; loading?: boolean }[]>([ 31 | { 32 | keyText: keyBeingEdited, 33 | valueText: valueBeingEdited, 34 | }, 35 | ]) 36 | const { enqueueSnackbar } = useSnackbar() 37 | 38 | const handleChange = (i: any, e: any) => { 39 | const newFormValues = [...formValues] 40 | 41 | if (e.target.name === 'keyText') { 42 | // @ts-ignore 43 | newFormValues[i][e.target.name] = e.target.value.toLowerCase() 44 | } else { 45 | // @ts-ignore 46 | newFormValues[i][e.target.name] = e.target.value 47 | } 48 | 49 | setFormValues(newFormValues) 50 | } 51 | 52 | const removeFormFields = (i: number) => { 53 | const newFormValues = [...formValues] 54 | newFormValues.splice(i, 1) 55 | setFormValues(newFormValues) 56 | } 57 | 58 | const submitKeyValue = async (i: number) => { 59 | const { keyText, valueText } = formValues[i] 60 | if (!keyText || !valueText || !spaceId) return 61 | const { typedData } = await getSuggestedFee({ 62 | type: TxType.Set, 63 | space: spaceId, 64 | key: keyText, 65 | value: valueText, 66 | }) 67 | const signature = await signWithMetaMask(typedData) 68 | 69 | if (!signature) { 70 | handleChange(i, { 71 | target: { 72 | name: 'loading', 73 | value: false, 74 | }, 75 | }) 76 | return 77 | } 78 | 79 | const success = await issueTx(typedData, signature) 80 | 81 | if (!success) { 82 | enqueueSnackbar('Something went wrong...', { variant: 'error' }) 83 | return 84 | } 85 | 86 | enqueueSnackbar('Item edited successfully!', { variant: 'success' }) 87 | refreshSpaceDetails() 88 | handleChange(i, { 89 | target: { 90 | name: 'loading', 91 | value: false, 92 | }, 93 | }) 94 | removeFormFields(i) 95 | onComplete() 96 | } 97 | 98 | return ( 99 | <> 100 | {formValues.map(({ keyText, valueText, loading }, i) => ( 101 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | { 122 | if (e.target.value === '' || VALID_KEY_REGEX.test(e.target.value)) { 123 | handleChange(i, e) 124 | } 125 | }} 126 | placeholder="Key" 127 | fullWidth 128 | InputProps={{ 129 | sx: { fontSize: 32, fontWeight: 900 }, 130 | }} 131 | inputProps={{ 132 | spellCheck: 'false', 133 | }} 134 | /> 135 | 136 | 137 | handleChange(i, e)} 145 | placeholder="Value" 146 | fullWidth 147 | InputProps={{ 148 | sx: { fontSize: 32, fontWeight: 900 }, 149 | }} 150 | inputProps={{ 151 | spellCheck: 'false', 152 | }} 153 | /> 154 | 155 | 156 | { 159 | handleChange(i, { 160 | target: { 161 | name: 'loading', 162 | value: true, 163 | }, 164 | }) 165 | 166 | submitKeyValue(i) 167 | }} 168 | disabled={loading || keyText?.length === 0 || valueText?.length === 0} 169 | variant="contained" 170 | size="large" 171 | > 172 | {loading ? : 'Set'} 173 | 174 | 175 | 176 | ))} 177 | > 178 | ) 179 | }, 180 | ) 181 | -------------------------------------------------------------------------------- /src/components/WhatIsASpace/WhatIsASpace.tsx: -------------------------------------------------------------------------------- 1 | import { Twemoji } from 'react-emoji-render' 2 | import { IoCloseCircleOutline, IoInformationCircleOutline } from 'react-icons/io5' 3 | import { Box, Button, Drawer, IconButton, Link, Tooltip, Typography } from '@mui/material' 4 | 5 | import WhatsASpaceBg from '@/assets/whats-a-space.jpg' 6 | 7 | type WhatIsASpaceProps = { 8 | isFooter?: boolean 9 | } 10 | 11 | export const WhatIsASpace = memo(({ isFooter = false }: WhatIsASpaceProps) => { 12 | const [open, setOpen] = useState(false) 13 | 14 | return ( 15 | <> 16 | 17 | setOpen(true)} 19 | color="secondary" 20 | size="small" 21 | startIcon={ 22 | 23 | 24 | 25 | } 26 | > 27 | What's a space? 28 | 29 | 30 | (theme.palette.mode === 'dark' ? '2px solid hsla(0, 0%, 100%, 0.2)' : 'unset'), 34 | width: '40vw', 35 | minWidth: 300, 36 | maxWidth: 540, 37 | p: { 38 | xs: 3, 39 | sm: 6, 40 | }, 41 | }, 42 | }} 43 | anchor={'right'} 44 | open={open} 45 | onClose={() => setOpen(false)} 46 | > 47 | 48 | theme.palette.grey[400], position: 'absolute', top: 8, left: 8, zIndex: 1 }}> 49 | setOpen(false)} color="inherit"> 50 | 51 | 52 | 53 | 54 | theme.customPalette.customBackground, 57 | backgroundImage: `url(${WhatsASpaceBg})`, 58 | backgroundSize: 'cover', 59 | backgroundPosition: 'center', 60 | backgroundRepeat: 'no-repeat', 61 | height: 180, 62 | display: 'flex', 63 | justifyContent: 'center', 64 | alignItems: 'center', 65 | position: 'absolute', 66 | top: 0, 67 | right: 0, 68 | left: 0, 69 | }} 70 | > 71 | 72 | What's a space? 73 | 74 | 75 | 76 | 77 | A space is your digital home. A place to store and share links, images, and files. Instead of living in a 78 | private server somewhere, your space is stored on a blockchain running in an Avalanche Subnet on the Fuji 79 | Network (this is only a demo for now 😉 ). 80 | 81 | 82 | 83 | Just like a home, the only one who can make changes to a space is the owner (the person who claims the 84 | space). Not just whoever we say the owner is, no no no. The owner is an EVM-formatted address that is backed 85 | by a private key in any{' '} 86 | 87 | EIP-712 88 | {' '} 89 | compatible wallet. Only actions signed by this private key can modify a space. 90 | 91 | 92 | 93 | EIP-712 compliance in this case, however, does not mean that{' '} 94 | 95 | SpacesVM 96 | {' '} 97 | is an EVM or even an EVM derivative. SpacesVM is a new Avalanche-native VM written from scratch to optimize 98 | for storage-related operations. 99 | 100 | 101 | 102 | In this demo, the ~970k addresses that have interacted with the{' '} 103 | 108 | Avalanche C-Chain 109 | {' '} 110 | more than twice have received an airdrop of 10,000 SPC tokens 111 | that can be used to claim spaces and store items in them. 112 | 113 | 114 | 115 | Anyone can run their own SpacesVM instance to store spaces data and/or validate that any modifications that 116 | occur to spaces are only done by the owner. If you'd like to get involved, check us out on{' '} 117 | 118 | Github 119 | 120 | ! 121 | 122 | 123 | 124 | Lastly, a short disclaimer. SpacesVM is new, unaudited code and should be treated as such. For this reason, 125 | the Spaces Chain may be restarted to rollout new features and/or repair any incorrect state. This site 126 | exists solely to demonstrate the coolness of Avalanche Subnets 127 | and the VMs you could build on them. If you have any suggestions for what could be improved, open an 128 | issue/PR on{' '} 129 | 130 | SpacesVM 131 | {' '} 132 | or{' '} 133 | 134 | SpacesVM JS 135 | 136 | . 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | > 145 | ) 146 | }) 147 | -------------------------------------------------------------------------------- /__mocks__/api/metrics.json: -------------------------------------------------------------------------------- 1 | { 2 | "ActiveAddresses": ["Ethereum", "Polygon", "Binance", "Solana", "Avalanche"], 3 | "AlexaRank": ["Ethereum", "Binance", "Fantom", "Polygon", "Terra", "Solana", "Avalanche"], 4 | "AverageBlockSize": ["Ethereum", "Polygon", "Fantom", "Binance", "Avalanche"], 5 | "AverageBlockTime": ["Ethereum", "Polygon", "Fantom", "Binance", "Avalanche"], 6 | "AverageGasLimit": ["Ethereum", "Polygon", "Binance", "Avalanche"], 7 | "AverageGasPrice": ["Ethereum", "Polygon", "Fantom", "Binance", "Avalanche"], 8 | "AverageGasUsedOverGasLimitPercent": ["Ethereum", "Polygon", "Fantom", "Binance", "Avalanche"], 9 | "AverageTPS": ["Binance", "Avalanche", "Ethereum", "Terra", "Fantom", "Polygon", "Solana", "Optimism", "Arbitrum"], 10 | "BingMatches": ["Ethereum", "Binance", "Fantom", "Polygon", "Terra", "Solana", "Avalanche"], 11 | "BlockCount": ["Ethereum", "Polygon", "Fantom", "Binance", "Avalanche"], 12 | "ChainTVL": ["Ethereum", "Polygon", "Binance", "Terra", "Avalanche", "Solana", "Fantom", "Arbitrum"], 13 | "ClosedIssues": ["Ethereum", "Binance", "Fantom", "Polygon", "Terra", "Solana", "Avalanche"], 14 | "CoinGeckoScore": ["Polygon", "Solana", "Ethereum", "Fantom", "Terra", "Binance", "Avalanche"], 15 | "CommitCountLastFourWeeks": ["Ethereum", "Binance", "Fantom", "Polygon", "Terra", "Solana", "Avalanche"], 16 | "CommunityScore": ["Polygon", "Solana", "Ethereum", "Fantom", "Terra", "Binance", "Avalanche"], 17 | "DailyFeesInUSD": ["Polkadot", "Avalanche", "Fantom", "Arbitrum", "Optimism", "Polygon", "Ethereum"], 18 | "DailyGasUsed": ["Ethereum", "Polygon", "Binance", "Avalanche"], 19 | "DailyNetworkFeePaidNatively": ["Ethereum", "Polygon", "Fantom", "Binance", "Avalanche"], 20 | "DailyTransactions": ["Ethereum", "Polygon", "Fantom", "Binance", "Avalanche", "Optimism", "Arbitrum", "Solana"], 21 | "DailyUniqueAddresses": ["Avalanche"], 22 | "DailyVolume": ["Ethereum", "Binance", "Fantom", "Polygon", "Terra", "Solana", "Avalanche"], 23 | "DerivedAvgCostPerERC20Transfer": ["Ethereum", "Binance", "Fantom", "Avalanche", "Polygon"], 24 | "DerivedAvgCostPerSwap": ["Ethereum", "Binance", "Fantom", "Avalanche", "Polygon"], 25 | "DerivedAvgCostPerTransfer": ["Ethereum", "Binance", "Fantom", "Avalanche", "Polygon"], 26 | "DerivedCirculatingSupply": ["Ethereum", "Binance", "Terra", "Solana", "Fantom", "Avalanche", "Polygon"], 27 | "DerivedCumulativeDailyFeesInUSD": ["Polygon", "Arbitrum", "Polkadot", "Optimism", "Avalanche", "Fantom", "Ethereum"], 28 | "DerivedCumulativeVerifiedContracts": ["Ethereum", "Fantom", "Optimism", "Polygon", "Arbitrum", "Avalanche"], 29 | "DerivedDailyChainTVL": ["Ethereum", "Polygon", "Binance", "Terra", "Avalanche", "Solana", "Fantom", "Arbitrum"], 30 | "DerivedDailyDexProtocolTvl": [ 31 | "Arbitrum", 32 | "Avalanche", 33 | "Binance", 34 | "Ethereum", 35 | "Fantom", 36 | "Polygon", 37 | "Solana", 38 | "Terra" 39 | ], 40 | "DerivedDailyLendingProtocolTvl": [ 41 | "Arbitrum", 42 | "Avalanche", 43 | "Binance", 44 | "Ethereum", 45 | "Fantom", 46 | "Polygon", 47 | "Solana", 48 | "Terra" 49 | ], 50 | "DerivedDailyUniqueAddresses": ["Ethereum", "Polygon", "Fantom", "Binance", "Avalanche", "Optimism", "Arbitrum"], 51 | "DerivedDailyYieldProtocolTvl": [ 52 | "Arbitrum", 53 | "Avalanche", 54 | "Binance", 55 | "Ethereum", 56 | "Fantom", 57 | "Polygon", 58 | "Solana", 59 | "Terra" 60 | ], 61 | "DerivedMarketCapOverTotalValueLocked": ["Ethereum", "Polygon", "Binance", "Terra", "Avalanche", "Solana", "Fantom"], 62 | "DerivedTimeToFinalityMovingAvg1D": [ 63 | "Binance", 64 | "Terra", 65 | "Fantom", 66 | "Polygon", 67 | "Avalanche", 68 | "Solana", 69 | "Arbitrum", 70 | "Ethereum", 71 | "Optimism" 72 | ], 73 | "DeveloperScore": ["Polygon", "Solana", "Ethereum", "Fantom", "Terra", "Binance", "Avalanche"], 74 | "DexProtocolTvl": ["Ethereum", "Polygon", "Arbitrum", "Binance", "Solana", "Terra", "Fantom", "Avalanche"], 75 | "Erc20ActiveAddresses": ["Ethereum", "Polygon", "Binance", "Avalanche"], 76 | "Erc20Transfers": ["Ethereum", "Polygon", "Fantom", "Binance"], 77 | "FacebookLikes": ["Ethereum", "Binance", "Fantom", "Polygon", "Terra", "Solana", "Avalanche"], 78 | "Forks": ["Ethereum", "Binance", "Fantom", "Polygon", "Terra", "Solana", "Avalanche"], 79 | "LendingProtocolTvl": ["Binance", "Ethereum", "Avalanche", "Terra", "Solana", "Arbitrum", "Fantom", "Polygon"], 80 | "LiquidityScore": ["Polygon", "Solana", "Ethereum", "Fantom", "Terra", "Binance", "Avalanche"], 81 | "MarketCap": ["Ethereum", "Binance", "Fantom", "Polygon", "Terra", "Solana", "Avalanche"], 82 | "PriceInUSD": ["Ethereum", "Binance", "Fantom", "Polygon", "Terra", "Solana", "Avalanche"], 83 | "PullRequestContributors": ["Ethereum", "Binance", "Fantom", "Polygon", "Terra", "Solana", "Avalanche"], 84 | "PullRequestsMerged": ["Ethereum", "Binance", "Fantom", "Polygon", "Terra", "Solana", "Avalanche"], 85 | "RedditAverageComments48H": ["Ethereum", "Binance", "Fantom", "Polygon", "Terra", "Solana", "Avalanche"], 86 | "RedditAveragePosts48H": ["Ethereum", "Binance", "Fantom", "Polygon", "Terra", "Solana", "Avalanche"], 87 | "RedditSubscribers": ["Ethereum", "Binance", "Fantom", "Polygon", "Terra", "Solana", "Avalanche"], 88 | "SolanaDailyTransactions": ["Solana"], 89 | "Stars": ["Ethereum", "Binance", "Fantom", "Polygon", "Terra", "Solana", "Avalanche"], 90 | "Subscribers": ["Ethereum", "Binance", "Fantom", "Polygon", "Terra", "Solana", "Avalanche"], 91 | "TelegramChannelUserCount": ["Ethereum", "Binance", "Fantom", "Polygon", "Terra", "Solana", "Avalanche"], 92 | "TimeToFinality": [ 93 | "Binance", 94 | "Terra", 95 | "Fantom", 96 | "Polygon", 97 | "Avalanche", 98 | "Solana", 99 | "Optimism", 100 | "Arbitrum", 101 | "Ethereum" 102 | ], 103 | "TotalIssues": ["Ethereum", "Binance", "Fantom", "Polygon", "Terra", "Solana", "Avalanche"], 104 | "TwitterFollowers": ["Ethereum", "Binance", "Fantom", "Polygon", "Terra", "Solana", "Avalanche"], 105 | "UniqueAddresses": ["Ethereum", "Polygon", "Fantom", "Binance", "Avalanche", "Optimism", "Arbitrum"], 106 | "VerifiedContracts": ["Ethereum", "Fantom", "Optimism", "Polygon", "Arbitrum", "Avalanche"], 107 | "VoteDailyTransactions": ["Solana"], 108 | "YieldProtocolTvl": ["Arbitrum", "Binance", "Fantom", "Polygon", "Avalanche", "Solana", "Terra", "Ethereum"] 109 | } 110 | -------------------------------------------------------------------------------- /src/components/MoveSpaceDialog/MoveSpaceDialog.tsx: -------------------------------------------------------------------------------- 1 | import { Twemoji } from 'react-emoji-render' 2 | import { AiOutlineRedo } from 'react-icons/ai' 3 | import { IoCloseCircleOutline } from 'react-icons/io5' 4 | import { useParams } from 'react-router-dom' 5 | import { 6 | Box, 7 | Button, 8 | Dialog, 9 | DialogContent, 10 | Fade, 11 | IconButton, 12 | Table, 13 | TableBody, 14 | TableCell, 15 | TableRow, 16 | TextField, 17 | Tooltip, 18 | Typography, 19 | useMediaQuery, 20 | useTheme, 21 | } from '@mui/material' 22 | import capitalize from 'lodash/capitalize' 23 | import { useSnackbar } from 'notistack' 24 | 25 | import { MoveSpaceSuccessDialog } from './MoveSpaceSuccessDialog' 26 | 27 | import MetaMaskFoxLogo from '@/assets/metamask-fox.svg' 28 | import { AddressChip } from '@/components/AddressChip/AddressChip' 29 | import { DialogTitle } from '@/components/DialogTitle' 30 | import { SubmitButton } from '@/pages/CustomSignature/SubmitButton' 31 | import { useMetaMask } from '@/providers/MetaMaskProvider' 32 | import { TxType } from '@/types' 33 | import { getSuggestedFee } from '@/utils/spacesVM' 34 | import { isValidWalletAddress } from '@/utils/verifyAddress' 35 | 36 | type MoveSpaceDialogProps = { 37 | open: boolean 38 | onClose(): void 39 | refreshSpaceDetails(): void 40 | } 41 | 42 | export const MoveSpaceDialog = ({ open, onClose, refreshSpaceDetails }: MoveSpaceDialogProps) => { 43 | const { spaceId } = useParams() 44 | const { currentAddress, signWithMetaMask, issueTx } = useMetaMask() 45 | const [toAddress, setToAddress] = useState('') 46 | const [addressInputError, setAddressInputError] = useState() 47 | const { enqueueSnackbar, closeSnackbar } = useSnackbar() 48 | const [isSigning, setIsSigning] = useState(false) 49 | const [isDone, setIsDone] = useState(false) 50 | const theme = useTheme() 51 | const isMobile = useMediaQuery(theme.breakpoints.down('sm')) 52 | 53 | const onSubmit = async () => { 54 | setIsSigning(true) 55 | try { 56 | const { typedData } = await getSuggestedFee({ 57 | type: TxType.Move, 58 | to: toAddress, 59 | space: spaceId, 60 | }) 61 | const signature = await signWithMetaMask(typedData) 62 | if (!signature) { 63 | setIsSigning(false) 64 | return 65 | } 66 | const success = await issueTx(typedData, signature) 67 | setIsSigning(false) 68 | if (!success) { 69 | // show some sort of failure dialog 70 | onSubmitFailure() 71 | return 72 | } 73 | setIsDone(true) 74 | } catch (error: any) { 75 | // eslint-disable-next-line no-console 76 | console.error(error) 77 | enqueueSnackbar(capitalize(error?.message), { 78 | variant: 'error', 79 | }) 80 | setIsSigning(false) 81 | } 82 | refreshSpaceDetails() 83 | } 84 | 85 | const onSubmitFailure = async () => { 86 | enqueueSnackbar("Oops! Something went wrong and we couldn't move your space. Try again!", { 87 | variant: 'warning', 88 | persist: true, 89 | action: ( 90 | <> 91 | } 93 | variant="outlined" 94 | color="inherit" 95 | onClick={() => { 96 | closeSnackbar() 97 | onSubmit() 98 | }} 99 | > 100 | Retry transfer 101 | 102 | 103 | closeSnackbar()} color="inherit"> 104 | 105 | 106 | 107 | > 108 | ), 109 | }) 110 | } 111 | 112 | useEffect(() => { 113 | const isValidToAddress = isValidWalletAddress(toAddress) 114 | setAddressInputError( 115 | !isValidToAddress && toAddress?.length !== 0 ? 'Please enter a valid public wallet address.' : undefined, 116 | ) 117 | }, [toAddress]) 118 | 119 | const handleClose = () => { 120 | setToAddress('') 121 | setAddressInputError(undefined) 122 | setIsDone(false) 123 | onClose() 124 | } 125 | 126 | return ( 127 | <> 128 | 129 | 130 | 131 | Move space 132 | 133 | 134 | You can move this space to a different wallet. 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | From: 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | To: 151 | 152 | 153 | setToAddress(e.target.value)} 162 | placeholder="0x address" 163 | fullWidth 164 | InputProps={{ 165 | sx: { fontSize: 18, fontWeight: 600, paddingBottom: '2px' }, 166 | }} 167 | inputProps={{ 168 | spellCheck: 'false', 169 | style: { paddingTop: 8 }, 170 | }} 171 | autoComplete="off" 172 | /> 173 | 174 | 175 | 176 | 177 | 178 | } 180 | disabled={!!addressInputError || !toAddress?.length || isSigning || isDone} 181 | variant="contained" 182 | type="submit" 183 | size="large" 184 | onClick={onSubmit} 185 | > 186 | {isSigning ? ( 187 | 188 | 189 | 190 | ) : ( 191 | 'Move' 192 | )} 193 | 194 | 195 | 196 | 197 | 198 | > 199 | ) 200 | } 201 | -------------------------------------------------------------------------------- /src/components/Footer/Footer.tsx: -------------------------------------------------------------------------------- 1 | import { IoLogoGithub, IoOpenOutline } from 'react-icons/io5' 2 | import { Link as ReactRouterLink } from 'react-router-dom' 3 | import { 4 | Box, 5 | Button, 6 | ButtonGroup, 7 | Divider, 8 | Grid, 9 | Link, 10 | styled, 11 | Theme, 12 | Typography, 13 | useMediaQuery, 14 | useTheme, 15 | } from '@mui/material' 16 | 17 | import { WhatIsASpace } from '../WhatIsASpace' 18 | 19 | import AvalancheLogo from '@/assets/avax-logo-official.svg' 20 | import Logo from '@/assets/spaces-logo.png' 21 | import { APP_NAME, CHAIN_ID_URL, SUBNET_ID_URL } from '@/constants' 22 | import { obfuscateAddress } from '@/utils/obfuscateAddress' 23 | import { getNetworks } from '@/utils/spacesVM' 24 | 25 | const StyledImg = styled('img')(({ theme }: { theme: Theme }) => ({ 26 | filter: theme.palette.mode === 'dark' ? 'invert(0)' : 'invert(1)', 27 | })) 28 | 29 | export const Footer = memo(() => { 30 | const theme = useTheme() 31 | const isMobile = useMediaQuery(theme.breakpoints.down('sm')) 32 | const truncateChainAndSubnet = useMediaQuery(theme.breakpoints.down('lg')) 33 | 34 | const [quarkNetworks, setQuarkNetworks] = useState<{ 35 | chainId: string 36 | subnetId: string 37 | networkId: string 38 | }>() 39 | 40 | useEffect(() => { 41 | const fetchNetworks = async () => { 42 | const networks = await getNetworks() 43 | setQuarkNetworks(networks) 44 | } 45 | 46 | fetchNetworks() 47 | }, []) 48 | 49 | return ( 50 | 51 | 52 | 53 | 54 | 55 | 63 | 64 | 70 | {APP_NAME} 71 | 72 | 73 | 74 | 75 | 76 | © {`${new Date().getFullYear()} ${APP_NAME}`} 77 | 78 | 79 | 80 | 81 | 82 | Powered by the SpacesVM Avalanche Subnet 83 | 84 | 85 | 86 | 87 | 88 | 89 | Illustrations by Dmitry Moiseenko 90 | 91 | 92 | 93 | 94 | {!isMobile && ( 95 | 96 | 97 | 98 | )} 99 | 100 | 107 | 115 | 116 | 124 | 125 | 126 | 127 | 128 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | {quarkNetworks && ( 144 | <> 145 | 146 | 147 | `${theme.palette.text.primary}!important`, 153 | background: (theme) => theme.customPalette.customBackground, 154 | '&:hover': { background: (theme) => theme.customPalette.customBackground }, 155 | }} 156 | > 157 | Subnet ID 158 | 159 | 160 | } 164 | onClick={() => window.open(`${SUBNET_ID_URL}${quarkNetworks.subnetId}`)} 165 | sx={{ 166 | background: (theme) => theme.customPalette.customBackground, 167 | '&:hover': { background: (theme) => theme.customPalette.customBackground }, 168 | }} 169 | > 170 | {truncateChainAndSubnet ? obfuscateAddress(quarkNetworks.subnetId) : quarkNetworks.subnetId} 171 | 172 | 173 | 174 | 175 | 176 | `${theme.palette.text.primary}!important`, 182 | background: (theme) => theme.customPalette.customBackground, 183 | '&:hover': { background: (theme) => theme.customPalette.customBackground }, 184 | }} 185 | > 186 | Chain ID 187 | 188 | 189 | } 193 | onClick={() => window.open(`${CHAIN_ID_URL}${quarkNetworks.chainId}`)} 194 | sx={{ 195 | background: (theme) => theme.customPalette.customBackground, 196 | '&:hover': { background: (theme) => theme.customPalette.customBackground }, 197 | }} 198 | > 199 | {truncateChainAndSubnet ? obfuscateAddress(quarkNetworks.chainId) : quarkNetworks.chainId} 200 | 201 | 202 | 203 | > 204 | )} 205 | 206 | 207 | 208 | 209 | ) 210 | }) 211 | -------------------------------------------------------------------------------- /src/pages/SpaceDetails/SpaceKeyValueRow.tsx: -------------------------------------------------------------------------------- 1 | import { IoConstructOutline, IoLink, IoTrashOutline } from 'react-icons/io5' 2 | import { 3 | Box, 4 | Card, 5 | CardContent, 6 | CircularProgress, 7 | Grid, 8 | Grow, 9 | IconButton, 10 | LinearProgress, 11 | Link, 12 | Link as MuiLink, 13 | Tooltip, 14 | Typography, 15 | useTheme, 16 | } from '@mui/material' 17 | import { useSnackbar } from 'notistack' 18 | 19 | import { DeleteKeyValueDialog } from '@/components/DeleteKeyValueDialog' 20 | import { LinkPreview } from '@/components/LinkPreview' 21 | import { IMAGE_REGEX, URL_REGEX } from '@/constants' 22 | import { setClipboard } from '@/utils/setClipboard' 23 | import { querySpaceKey } from '@/utils/spacesVM' 24 | 25 | const isImgLink = (url: string): boolean => { 26 | if (typeof url !== 'string') return false 27 | return url.match(IMAGE_REGEX) != null 28 | } 29 | 30 | type SpaceKeyValueRowProps = { 31 | spaceId: string 32 | spaceKey: string 33 | lastTouchTxId: string 34 | isSpaceOwner: boolean 35 | onEdit: (key: string, value: string) => void 36 | refreshSpaceDetails(): void 37 | } 38 | 39 | export const SpaceKeyValueRow = ({ 40 | spaceId, 41 | spaceKey, 42 | lastTouchTxId, 43 | isSpaceOwner, 44 | onEdit, 45 | refreshSpaceDetails, 46 | }: SpaceKeyValueRowProps) => { 47 | const { enqueueSnackbar } = useSnackbar() 48 | const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) 49 | const [valueForKey, setValueForKey] = useState() 50 | // const [valueMeta, setValueMeta] = useState() 51 | // const [valueExists, setValueExists] = useState(false) 52 | const [isLoading, setIsLoading] = useState(true) 53 | const [loadingImageError, setLoadingImageError] = useState(false) 54 | const theme = useTheme() 55 | 56 | const getSpaceValue = useCallback(async () => { 57 | setIsLoading(true) 58 | const { value } = await querySpaceKey(spaceId, spaceKey) 59 | setValueForKey(value) 60 | // setValueMeta(valueMeta) 61 | // setValueExists(exists) 62 | setIsLoading(false) 63 | }, [spaceId, spaceKey]) 64 | 65 | // Load space value whenever the value changes, or when there was an update to that key 66 | useEffect(() => { 67 | getSpaceValue() 68 | }, [lastTouchTxId, getSpaceValue]) 69 | 70 | const valueIsUrl = URL_REGEX.test(valueForKey) 71 | 72 | return ( 73 | 74 | `2px solid ${theme.palette.divider}`, 100 | '.actions': { 101 | opacity: 1, 102 | }, 103 | }, 104 | }} 105 | > 106 | 107 | 118 | 119 | {spaceKey} 120 | 121 | {isLoading && } 122 | {valueIsUrl ? ( 123 | loadingImageError ? ( 124 | {valueForKey} 125 | ) : isImgLink(valueForKey) ? ( 126 | setLoadingImageError(true)} 132 | /> 133 | ) : ( 134 | { 137 | if (loading) { 138 | return 139 | } 140 | return ( 141 | <> 142 | {(preview['og:title'] || preview.title) && ( 143 | 144 | {preview['og:title'] || preview.title} 145 | 146 | )} 147 | {preview['og:image'] && ( 148 | 159 | )} 160 | {(preview['og:description'] || preview.description) && ( 161 | 162 | {preview['og:description'] || preview.description} 163 | 164 | )} 165 | > 166 | ) 167 | }} 168 | /> 169 | ) 170 | ) : ( 171 | valueForKey 172 | )} 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | { 183 | e.preventDefault() 184 | e.stopPropagation() 185 | setClipboard({ 186 | value: `${window.location.origin}/${spaceId}/${spaceKey}/`, 187 | onSuccess: () => enqueueSnackbar('Copied!'), 188 | onFailure: () => enqueueSnackbar("Can't copy!", { variant: 'error' }), 189 | }) 190 | }} 191 | > 192 | 193 | 194 | 195 | 196 | 197 | {isSpaceOwner && ( 198 | <> 199 | 200 | 201 | 202 | { 204 | e.preventDefault() 205 | e.stopPropagation() 206 | onEdit(spaceKey, valueForKey) 207 | }} 208 | > 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | { 220 | e.preventDefault() 221 | e.stopPropagation() 222 | setDeleteDialogOpen(true) 223 | }} 224 | color="primary" 225 | > 226 | 227 | 228 | 229 | 230 | 231 | > 232 | )} 233 | 234 | 235 | setDeleteDialogOpen(false)} 238 | refreshSpaceDetails={refreshSpaceDetails} 239 | spaceKey={spaceKey} 240 | /> 241 | 242 | 243 | ) 244 | } 245 | -------------------------------------------------------------------------------- /src/providers/MetaMaskProvider.tsx: -------------------------------------------------------------------------------- 1 | import { isAndroid, isIOS } from 'react-device-detect' 2 | import { IoArrowRedo, IoCloseCircleOutline, IoDownloadOutline } from 'react-icons/io5' 3 | import MetaMaskOnboarding from '@metamask/onboarding' 4 | import { Button, IconButton, Tooltip } from '@mui/material' 5 | import throttle from 'lodash/throttle' 6 | import { useSnackbar } from 'notistack' 7 | 8 | import { APP_NAME } from '@/constants' 9 | import { getAddressBalance, getOwnedSpaces, isConnectedToSpacesVM, issueAndConfirmTransaction } from '@/utils/spacesVM' 10 | 11 | declare global { 12 | interface Window { 13 | ethereum: any 14 | } 15 | } 16 | 17 | const GOOGLE_PLAY_MM_LINK = 'https://play.google.com/store/apps/details?id=io.metamask' 18 | const APPLE_STORE_MM_LINK = 'https://apps.apple.com/us/app/metamask-blockchain-wallet/id1438144202' 19 | 20 | const ethereum = window.ethereum 21 | const onboarding = new MetaMaskOnboarding() 22 | 23 | const MetaMaskContext = createContext({} as any) 24 | 25 | const throttledCheckSpacesConnection = throttle(isConnectedToSpacesVM, 1000) 26 | 27 | export const MetaMaskProvider = ({ children }: any) => { 28 | const [currentAddress, setCurrentAddress] = useState() 29 | const [isConnectingToMM, setIsConnectingToMM] = useState(false) 30 | const { enqueueSnackbar, closeSnackbar } = useSnackbar() 31 | const [metaMaskExists, setMetaMaskExists] = useState(ethereum !== undefined && ethereum?.isMetaMask) 32 | const [isConnectedToSpaces, setIsConnectedToSpaces] = useState(true) 33 | const [balance, setBalance] = useState(null) 34 | 35 | /** 36 | * Update balance when changing accounts and on mount 37 | */ 38 | const updateBalance = useCallback(async () => { 39 | if (!currentAddress || !isConnectedToSpaces) { 40 | setBalance(null) 41 | return 42 | } 43 | const response = await getAddressBalance(currentAddress) 44 | response?.balance !== undefined && setBalance(response.balance) 45 | }, [currentAddress, isConnectedToSpaces]) 46 | useEffect(() => { 47 | updateBalance() 48 | const balanceUpdateInterval = setInterval(() => { 49 | if (document.visibilityState === 'hidden') return 50 | updateBalance() 51 | }, 10000) // Poll for latest balance every 10s 52 | return () => clearInterval(balanceUpdateInterval) 53 | }, [updateBalance]) 54 | 55 | /** 56 | * Set up ethereum account change listener 57 | */ 58 | useEffect(() => { 59 | if (!metaMaskExists) return 60 | setCurrentAddress(ethereum.selectedAddress) 61 | // Listen for changes to the connected account selections 62 | return ethereum.on('accountsChanged', (accounts: string[]) => { 63 | setCurrentAddress(accounts[0]) 64 | }) 65 | }, [updateBalance, metaMaskExists]) 66 | 67 | /** 68 | * Listen for ethereum initalization in browser 69 | */ 70 | useEffect(() => { 71 | const handleEthereum = () => { 72 | setMetaMaskExists(!!ethereum?.isMetaMask) 73 | setCurrentAddress(ethereum?.selectedAddress) 74 | } 75 | handleEthereum() 76 | setTimeout(handleEthereum, 3000) // If ethereum isn't installed after 3 seconds then MM probably isn't installed. 77 | return window.addEventListener('ethereum#initialized', handleEthereum, { once: true }) 78 | }, []) 79 | 80 | /** 81 | * Connects to MM if not already connected, and returns the connected account 82 | */ 83 | const connectToMetaMask = async (): Promise => { 84 | if (!metaMaskExists) { 85 | onboardToMetaMask() 86 | return [] 87 | } 88 | 89 | setIsConnectingToMM(true) 90 | try { 91 | const accounts = await ethereum.request({ method: 'eth_requestAccounts' }) 92 | setCurrentAddress(accounts[0]) 93 | setIsConnectingToMM(false) 94 | return accounts 95 | } catch (err) { 96 | setIsConnectingToMM(false) 97 | enqueueSnackbar(`Connect your wallet to use ${APP_NAME}!`, { 98 | variant: 'warning', 99 | persist: true, 100 | action: ( 101 | <> 102 | } 104 | variant="outlined" 105 | color="inherit" 106 | onClick={() => { 107 | closeSnackbar() 108 | connectToMetaMask() 109 | }} 110 | > 111 | Connect 112 | 113 | 114 | closeSnackbar()} color="inherit"> 115 | 116 | 117 | 118 | > 119 | ), 120 | }) 121 | return [] 122 | } 123 | } 124 | 125 | const fetchOwnedSpaces = async (currentAddress: string) => { 126 | const ownedSpaces = await getOwnedSpaces(currentAddress) 127 | return ownedSpaces?.spaces 128 | } 129 | 130 | /** 131 | * If MM not installed, prompt user to do so. 132 | */ 133 | const onboardToMetaMask = useCallback(async () => { 134 | if (metaMaskExists) return 135 | enqueueSnackbar('MetaMask needs to be installed.', { 136 | variant: 'warning', 137 | persist: true, 138 | action: ( 139 | <> 140 | } 142 | variant="outlined" 143 | color="inherit" 144 | href={isIOS ? APPLE_STORE_MM_LINK : isAndroid ? GOOGLE_PLAY_MM_LINK : 'javascript:void(0)'} 145 | onClick={() => { 146 | if (isIOS || isAndroid) return 147 | onboarding.startOnboarding() 148 | }} 149 | > 150 | Download MetaMask 151 | 152 | 153 | closeSnackbar()}> 154 | 155 | 156 | 157 | > 158 | ), 159 | }) 160 | }, [enqueueSnackbar, metaMaskExists, closeSnackbar]) 161 | 162 | /** 163 | * Issues a transaction to spacesVM and polls the VM until the transaction is confirmed. 164 | * Used for claim, lifeline, set, delete, move, and transfer 165 | * https://github.com/ava-labs/spacesvm#transaction-types 166 | * 167 | * @param typedData typedData from getSuggestedFee 168 | * @param signature signed typedData 169 | * @returns boolean - whether transaction issuing was successful 170 | */ 171 | const issueTx = useCallback( 172 | async (typedData: any, signature: string) => { 173 | if (!metaMaskExists) { 174 | onboardToMetaMask() 175 | return 176 | } 177 | const success = await issueAndConfirmTransaction(typedData, signature) 178 | updateBalance() 179 | return success 180 | }, 181 | [updateBalance, onboardToMetaMask, metaMaskExists], 182 | ) 183 | 184 | /** 185 | * Signs a typed data payload. The signature is needed to issue transactions to SpacesVM 186 | * 187 | * @param typedData from getSuggestedFee 188 | * @returns signature 189 | */ 190 | const signWithMetaMask = async (typedData: any): Promise => { 191 | if (!metaMaskExists) { 192 | onboardToMetaMask() 193 | return 194 | } 195 | try { 196 | const accounts = await connectToMetaMask() 197 | const signature = await ethereum.request({ 198 | method: 'eth_signTypedData_v4', 199 | params: [accounts[0], JSON.stringify(typedData)], 200 | }) 201 | return signature 202 | } catch (error) { 203 | // eslint-disable-next-line no-console 204 | console.error(error) 205 | } 206 | } 207 | 208 | const checkSpacesConnection = async () => { 209 | const isConnected = await throttledCheckSpacesConnection() 210 | if (isConnected === undefined) return // check was throttled 211 | setIsConnectedToSpaces(isConnected) 212 | } 213 | useEffect(() => { 214 | checkSpacesConnection() 215 | }, []) 216 | 217 | return ( 218 | 232 | {children} 233 | 234 | ) 235 | } 236 | 237 | export const useMetaMask = () => useContext(MetaMaskContext) 238 | -------------------------------------------------------------------------------- /src/components/KeyValueInput/KeyValueInput.tsx: -------------------------------------------------------------------------------- 1 | import { AiOutlineRedo } from 'react-icons/ai' 2 | import { IoAdd, IoCloseCircleOutline, IoInformationCircleOutline } from 'react-icons/io5' 3 | import { Button, CircularProgress, Grid, Grow, IconButton, styled, TextField, Tooltip, Typography } from '@mui/material' 4 | import { Box } from '@mui/system' 5 | import { useSnackbar } from 'notistack' 6 | 7 | import { VALID_KEY_REGEX } from '@/constants' 8 | import { useMetaMask } from '@/providers/MetaMaskProvider' 9 | import { purpleButton } from '@/theming/purpleButton' 10 | import { TxType } from '@/types' 11 | import { getSuggestedFee } from '@/utils/spacesVM' 12 | 13 | const SetButton = styled(Button)(({ theme }) => ({ 14 | ...purpleButton(theme), 15 | padding: 0, 16 | minWidth: 0, 17 | height: 66, 18 | })) 19 | 20 | type KeyValueInputProps = { 21 | spaceId: string 22 | refreshSpaceDetails: any 23 | empty: boolean 24 | } 25 | 26 | export const KeyValueInput = memo(({ spaceId, refreshSpaceDetails, empty }: KeyValueInputProps) => { 27 | const { signWithMetaMask, issueTx } = useMetaMask() 28 | const [formValues, setFormValues] = useState<{ keyText?: string; valueText?: string; loading?: boolean }[]>([]) 29 | const { enqueueSnackbar, closeSnackbar } = useSnackbar() 30 | 31 | const handleChange = (i: any, e: any) => { 32 | const newFormValues = [...formValues] 33 | 34 | if (e.target.name === 'keyText') { 35 | // @ts-ignore 36 | newFormValues[i][e.target.name] = e.target.value.toLowerCase() 37 | } else { 38 | // @ts-ignore 39 | newFormValues[i][e.target.name] = e.target.value 40 | } 41 | 42 | setFormValues(newFormValues) 43 | } 44 | 45 | const addFormFields = () => { 46 | setFormValues([...formValues, { keyText: '', valueText: '', loading: false }]) 47 | } 48 | 49 | const removeFormFields = (i: number) => { 50 | const newFormValues = [...formValues] 51 | newFormValues.splice(i, 1) 52 | setFormValues(newFormValues) 53 | } 54 | 55 | const submitKeyValue = async (i: number) => { 56 | const { keyText, valueText } = formValues[i] 57 | if (!keyText || !valueText || !spaceId) return 58 | const { typedData } = await getSuggestedFee({ 59 | type: TxType.Set, 60 | space: spaceId, 61 | key: keyText, 62 | value: valueText, 63 | }) 64 | 65 | const signature = await signWithMetaMask(typedData) 66 | if (!signature) { 67 | onSubmitFailure(i) 68 | return 69 | } 70 | 71 | const success = await issueTx(typedData, signature) 72 | if (!success) { 73 | onSubmitFailure(i) 74 | return 75 | } 76 | enqueueSnackbar('Item added successfully!', { variant: 'success' }) 77 | refreshSpaceDetails() 78 | handleChange(i, { 79 | target: { 80 | name: 'loading', 81 | value: false, 82 | }, 83 | }) 84 | removeFormFields(i) 85 | } 86 | 87 | const onSubmitFailure = async (i: number) => { 88 | handleChange(i, { 89 | target: { 90 | name: 'loading', 91 | value: false, 92 | }, 93 | }) 94 | enqueueSnackbar('Oops! Something went wrong when saving your key. Try again!', { 95 | variant: 'warning', 96 | persist: true, 97 | action: ( 98 | <> 99 | } 101 | variant="outlined" 102 | color="inherit" 103 | onClick={() => { 104 | closeSnackbar() 105 | submitKeyValue(i) 106 | }} 107 | > 108 | Retry set 109 | 110 | 111 | closeSnackbar()} color="inherit"> 112 | 113 | 114 | 115 | > 116 | ), 117 | }) 118 | } 119 | 120 | return ( 121 | <> 122 | = 5} 124 | onClick={() => addFormFields()} 125 | startIcon={} 126 | variant="outlined" 127 | autoFocus 128 | color="secondary" 129 | sx={{ margin: 'auto', display: 'flex', mb: 4 }} 130 | > 131 | {empty ? 'Start adding' : 'Add more'} 132 | 133 | 134 | {formValues.map( 135 | ({ keyText, valueText, loading }, i) => 136 | i < 5 && ( 137 | 138 | 139 | 140 | { 148 | if (e.target.value === '' || VALID_KEY_REGEX.test(e.target.value)) { 149 | handleChange(i, e) 150 | } 151 | }} 152 | placeholder="Key" 153 | fullWidth 154 | helperText={ 155 | 158 | Any lowercase string - e.g. twitter 159 | 160 | } 161 | placement="bottom" 162 | > 163 | 173 | 174 | 175 | 176 | Hint 177 | 178 | 179 | } 180 | InputProps={{ 181 | sx: { fontSize: 32, fontWeight: 900 }, 182 | }} 183 | inputProps={{ 184 | spellCheck: 'false', 185 | }} 186 | /> 187 | 188 | 189 | handleChange(i, e)} 196 | placeholder="Value" 197 | fullWidth 198 | helperText={ 199 | 202 | Any lowercase string - e.g. https://example.com/ 203 | 204 | } 205 | placement="bottom" 206 | > 207 | 217 | 218 | 219 | 220 | Hint 221 | 222 | 223 | } 224 | InputProps={{ 225 | sx: { fontSize: 32, fontWeight: 900 }, 226 | }} 227 | inputProps={{ 228 | spellCheck: 'false', 229 | }} 230 | /> 231 | 232 | 233 | { 236 | handleChange(i, { 237 | target: { 238 | name: 'loading', 239 | value: true, 240 | }, 241 | }) 242 | 243 | submitKeyValue(i) 244 | }} 245 | disabled={loading || keyText?.length === 0 || valueText?.length === 0} 246 | sx={{ mb: 3 }} 247 | variant="contained" 248 | size="large" 249 | > 250 | {loading ? : 'Set'} 251 | 252 | 253 | 254 | 255 | ), 256 | )} 257 | > 258 | ) 259 | }) 260 | -------------------------------------------------------------------------------- /src/components/LifelineDialog/LifelineDialog.tsx: -------------------------------------------------------------------------------- 1 | import { Twemoji } from 'react-emoji-render' 2 | import { AiOutlineRedo } from 'react-icons/ai' 3 | import { IoAdd, IoCloseCircleOutline, IoRemove } from 'react-icons/io5' 4 | import { useParams } from 'react-router-dom' 5 | import { 6 | Box, 7 | Button, 8 | Dialog, 9 | DialogContent, 10 | Fade, 11 | Grid, 12 | IconButton, 13 | styled, 14 | Tooltip, 15 | Typography, 16 | useMediaQuery, 17 | useTheme, 18 | } from '@mui/material' 19 | import { useSnackbar } from 'notistack' 20 | 21 | import { LifelineDoneDialog } from './LifelineDoneDialog' 22 | 23 | import MetaMaskFoxLogo from '@/assets/metamask-fox.svg' 24 | import { DialogTitle } from '@/components/DialogTitle' 25 | import { useMetaMask } from '@/providers/MetaMaskProvider' 26 | import { purpleButton } from '@/theming/purpleButton' 27 | import { rainbowText } from '@/theming/rainbowText' 28 | import { TxType } from '@/types' 29 | import { calculateLifelineCost, getDisplayLifelineTime, getExtendToTime } from '@/utils/calculateCost' 30 | import { getSuggestedFee } from '@/utils/spacesVM' 31 | 32 | const SubmitButton = styled(Button)(({ theme }: any) => ({ 33 | ...purpleButton(theme), 34 | })) 35 | 36 | type LifelineDialogProps = { 37 | open: boolean 38 | close(): void 39 | existingExpiry: number 40 | spaceUnits: number 41 | refreshSpaceDetails(): void 42 | } 43 | 44 | export const LifelineDialog = ({ 45 | open, 46 | close, 47 | existingExpiry, 48 | spaceUnits, 49 | refreshSpaceDetails, 50 | }: LifelineDialogProps) => { 51 | const { spaceId } = useParams() 52 | const theme = useTheme() 53 | const { issueTx, signWithMetaMask, balance } = useMetaMask() 54 | const [extendUnits, setExtendUnits] = useState(0) 55 | const [fee, setFee] = useState(0) 56 | const [isSigning, setIsSigning] = useState(false) 57 | const [isDone, setIsDone] = useState(false) 58 | const { enqueueSnackbar, closeSnackbar } = useSnackbar() 59 | const isMobile = useMediaQuery(theme.breakpoints.down('sm')) 60 | 61 | // spaceUnits is from the API. More stuff stored = more spaceUnits 62 | 63 | const onSubmit = async () => { 64 | setIsSigning(true) 65 | const { typedData } = await getSuggestedFee({ 66 | type: TxType.Lifeline, 67 | space: spaceId, 68 | units: extendUnits, 69 | }) 70 | const signature = await signWithMetaMask(typedData) 71 | setIsSigning(false) 72 | if (!signature) return 73 | const success = await issueTx(typedData, signature) 74 | if (!success) { 75 | onSubmitFailure() 76 | return 77 | } 78 | setIsDone(true) 79 | refreshSpaceDetails() 80 | } 81 | 82 | const onSubmitFailure = async () => { 83 | enqueueSnackbar("Oops! Something went wrong and we couldn't extend your space's life. Try again!", { 84 | variant: 'warning', 85 | persist: true, 86 | action: ( 87 | <> 88 | } 90 | variant="outlined" 91 | color="inherit" 92 | onClick={() => { 93 | closeSnackbar() 94 | onSubmit() 95 | }} 96 | > 97 | Retry transfer 98 | 99 | 100 | closeSnackbar()} color="inherit"> 101 | 102 | 103 | 104 | > 105 | ), 106 | }) 107 | } 108 | 109 | useEffect(() => { 110 | if (!spaceId) return 111 | const newFee = calculateLifelineCost(spaceId, extendUnits) 112 | setFee(Math.floor(newFee)) 113 | }, [extendUnits, spaceId, open]) 114 | 115 | const handleClose = () => { 116 | setIsDone(false) 117 | setExtendUnits(0) 118 | close() 119 | } 120 | 121 | // Amount of time that will be extended 122 | const extendDurationDisplay = useMemo(() => { 123 | if (extendUnits === 0) return '0' 124 | return getDisplayLifelineTime(extendUnits, spaceUnits) 125 | }, [extendUnits, spaceUnits]) 126 | 127 | // Explicit date that will be extended to 128 | const extendToDateDisplay = useMemo( 129 | () => getExtendToTime(extendUnits, spaceUnits, existingExpiry), 130 | [extendUnits, spaceUnits, existingExpiry], 131 | ) 132 | 133 | return ( 134 | <> 135 | 136 | 137 | 138 | Extend some life to{' '} 139 | 147 | {spaceId} 148 | {' '} 149 | before it expires! 150 | 151 | 152 | 153 | 154 | 155 | Extend by 156 | 157 | 158 | 159 | 160 | {extendDurationDisplay} 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | `1px solid ${theme.palette.secondary.main}`, 173 | }, 174 | '&.Mui-disabled': { 175 | border: (theme) => `1px solid ${theme.palette.action.disabled}`, 176 | }, 177 | }} 178 | color="inherit" 179 | size="large" 180 | disabled={extendUnits <= 0} 181 | onClick={() => setExtendUnits(extendUnits - 1)} 182 | > 183 | 184 | 185 | 186 | 187 | 188 | 189 | = balance ? 'Not enough SPC!' : ''} 192 | > 193 | 194 | `1px solid ${theme.palette.secondary.main}`, 199 | }, 200 | '&.Mui-disabled': { 201 | border: (theme) => `1px solid ${theme.palette.action.disabled}`, 202 | }, 203 | }} 204 | size="large" 205 | color="inherit" 206 | disabled={!spaceId || calculateLifelineCost(spaceId, extendUnits + 1) >= balance} 207 | onClick={() => setExtendUnits(extendUnits + 1)} 208 | > 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 226 | {isSigning ? ( 227 | 228 | 229 | 230 | ) : ( 231 | 'Extend life' 232 | )} 233 | 234 | 235 | 236 | 237 | 238 | Cost: {fee} SPC 239 | 240 | 241 | 242 | 243 | 244 | 245 | > 246 | ) 247 | } 248 | -------------------------------------------------------------------------------- /src/pages/CustomSignature/CustomSignature.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, Card, Container, Fade, Grid, Slide, styled, TextareaAutosize, Typography } from '@mui/material' 2 | 3 | import { SubmitButton } from './SubmitButton' 4 | 5 | import MetaMaskFoxLogo from '@/assets/metamask-fox.svg' 6 | import { Page } from '@/components/Page' 7 | import { PageTitle } from '@/components/PageTitle' 8 | import { TypewrittingInput } from '@/components/TypewrittingInput' 9 | import { useMetaMask } from '@/providers/MetaMaskProvider' 10 | import { rainbowButton } from '@/theming/rainbowButton' 11 | import { shuffleArray } from '@/utils/shuffleArray' 12 | import { fetchSpaces, getSuggestedFee } from '@/utils/spacesVM' 13 | 14 | export const SignButton: any = styled(Button)(({ theme }: any) => ({ 15 | ...rainbowButton(theme), 16 | })) 17 | 18 | const JsonTextArea = styled(TextareaAutosize)` 19 | width: 100%; 20 | max-width: 100%; 21 | min-width: 100%; 22 | border: 0; 23 | outline: none; 24 | max-height: 100%; 25 | overflow-y: auto !important; 26 | resize: none; 27 | font-size: 14px; 28 | ` 29 | 30 | const SectionTitle = styled(Typography)` 31 | width: fit-content; 32 | background-image: linear-gradient(100deg, #aa039f, #ed014d, #f67916); 33 | background-clip: text; 34 | text-fill-color: transparent; 35 | filter: ${({ theme }) => 36 | theme.palette.mode === 'dark' ? 'contrast(30%) brightness(200%)' : 'contrast(60%) brightness(100%)'}; 37 | ` 38 | 39 | const inputSample = `{ 40 | "type": "claim", 41 | "space": "connor" 42 | }` 43 | 44 | const inputPlaceholder = `{ 45 | "type":, 46 | "space":, 47 | "key":, 48 | "value":, 49 | "to":, 50 | "units": 51 | }` 52 | 53 | const DEV_NAMES = shuffleArray([ 54 | 'Patrick', 55 | 'Connor', 56 | 'other Conor', 57 | 'Cohan', 58 | 'Dhruba', 59 | 'Gabriel', 60 | 'Gyuho', 61 | 'Jiten', 62 | 'Xander', 63 | ]) 64 | 65 | export const CustomSignature = () => { 66 | const { signWithMetaMask } = useMetaMask() 67 | 68 | const [jsonInput, setJsonInput] = useState('') 69 | const [isSigning, setIsSigning] = useState(false) 70 | const [signature, setSignature] = useState(null) 71 | const [signatureError, setSignatureError] = useState(null) 72 | 73 | const [isSubmitting, setIsSubmitting] = useState(false) 74 | const [response, setResponse] = useState() 75 | const [submitError, setSubmitError] = useState(null) 76 | 77 | const signJson = async () => { 78 | if (!jsonInput?.length) return 79 | try { 80 | setIsSigning(true) 81 | const signature = await signWithMetaMask(typedData) 82 | setIsSigning(false) 83 | if (!signature) { 84 | setSignatureError('Must sign in metamask!') 85 | return 86 | } 87 | 88 | // eslint-disable-next-line no-console 89 | console.log('signature', signature) 90 | setSignatureError(null) 91 | setSignature(signature) 92 | } catch (err: any) { 93 | setIsSigning(false) 94 | setSignatureError(err.message) 95 | } 96 | } 97 | 98 | const clearOutput = () => { 99 | setSignature(null) 100 | setResponse(null) 101 | setSignatureError(null) 102 | setSubmitError(null) 103 | setTypedData(null) 104 | } 105 | 106 | const submitRequest = async () => { 107 | if (!signature?.length || isSubmitting) return 108 | setIsSubmitting(true) 109 | try { 110 | // eslint-disable-next-line no-console 111 | console.log(`Issuing Tx with Params:`, { 112 | typedData, 113 | signature, 114 | }) 115 | const res = await fetchSpaces('issueTx', { 116 | typedData, 117 | signature, 118 | }) 119 | setIsSubmitting(false) 120 | setResponse(res) 121 | } catch (err: any) { 122 | setIsSubmitting(false) 123 | setSubmitError(err) 124 | } 125 | } 126 | 127 | const [typedData, setTypedData] = useState() 128 | const getTypedData = async () => { 129 | const { typedData } = await getSuggestedFee(JSON.parse(jsonInput)) 130 | setTypedData(typedData) 131 | } 132 | 133 | return ( 134 | 135 | 136 | 137 | Hi,{' '} 138 | 139 | {({ currentText }) => {currentText}} 140 | 141 | ! 142 | 143 | 144 | 145 | 146 | Input: 147 | 148 | 149 | 150 | setJsonInput(inputSample)}> 151 | Fill with sample. 152 | 153 | 154 | 155 | 156 | setJsonInput(e.target.value)} 158 | value={jsonInput} 159 | placeholder={inputPlaceholder} 160 | sx={{ borderRadius: 4, p: 2, height: '100% !important' }} 161 | /> 162 | 163 | 164 | 165 | Reset 166 | 167 | 168 | Prepare for Signing 169 | 👇 170 | 171 | 172 | 173 | 174 | 175 | Typed Data: 176 | 177 | 178 | 179 | 180 | {JSON.stringify(typedData, null, 2)} 181 | 182 | 183 | 184 | 185 | 186 | Reset 187 | 188 | 189 | {isSigning ? ( 190 | 191 | 192 | 193 | ) : ( 194 | <> 195 | Sign 196 | ✍ 197 | > 198 | )} 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | Signature: 207 | 208 | 209 | {signatureError ? ( 210 | 211 | {signatureError} 212 | 213 | ) : ( 214 | 215 | {signature} 216 | 217 | )} 218 | 219 | 220 | 221 | Reset 222 | 223 | 224 | Submit 225 | 🚀 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | Response: 234 | 235 | 236 | {submitError ? ( 237 | 238 | 239 | {JSON.stringify(submitError, null, 2)} 240 | 241 | 242 | ) : ( 243 | 244 | 245 | {JSON.stringify(response, null, 2)} 246 | 247 | 248 | )} 249 | 250 | 251 | Reset 252 | 253 | 254 | 255 | 256 | 257 | ) 258 | } 259 | 260 | /** 261 | [{"type":"string","name":"BlockID","value":"4Cwd9xZXfiBc3djER9zBpYJw6rAYHqN1s9DCpNttbyRPVdYRb"},{"type":"string","name":"Prefix","value":"connor"},{"type":"string","name":"Type","value":"claim"}] 262 | */ 263 | --------------------------------------------------------------------------------
179 | 180 | {JSON.stringify(typedData, null, 2)} 181 | 182 |
238 | 239 | {JSON.stringify(submitError, null, 2)} 240 | 241 |
244 | 245 | {JSON.stringify(response, null, 2)} 246 | 247 |