diff --git a/.env.example b/.env.example index 6b61434..7239454 100644 --- a/.env.example +++ b/.env.example @@ -1 +1 @@ -VITE_MOST_POPULAR_RELAYS=wss://relay.damus.io wss://eden.nostr.land wss://nos.lol wss://relay.snort.social wss://relay.current.fyi wss://brb.io wss://nostr.orangepill.dev wss://nostr-pub.wellorder.net wss://nostr.bitcoiner.social wss://nostr.wine wss://nostr.oxtr.dev wss://relay.nostr.bg wss://nostr.mom wss://nostr.fmt.wiz.biz wss://relay.nostr.band wss://nostr-pub.semisol.dev wss://nostr.milou.lol wss://puravida.nostr.land wss://nostr.onsats.org wss://relay.nostr.info wss://offchain.pub wss://relay.orangepill.dev wss://no.str.cr wss://atlas.nostr.land wss://nostr.zebedee.cloud wss://nostr-relay.wlvs.space wss://relay.nostrati.com wss://relay.nostr.com.au wss://nostr.inosta.cc wss://nostr.rocks \ No newline at end of file +VITE_MOST_POPULAR_RELAYS=wss://relay.damus.io wss://eden.nostr.land wss://nos.lol wss://relay.snort.social wss://relay.current.fyi wss://brb.io wss://nostr.orangepill.dev wss://nostr-pub.wellorder.net wss://nostr.wine wss://nostr.oxtr.dev wss://relay.nostr.bg wss://nostr.mom wss://nostr.fmt.wiz.biz wss://relay.nostr.band wss://nostr-pub.semisol.dev wss://nostr.milou.lol wss://puravida.nostr.land wss://nostr.onsats.org wss://relay.nostr.info wss://offchain.pub wss://relay.orangepill.dev wss://no.str.cr wss://atlas.nostr.land wss://nostr.zebedee.cloud wss://nostr-relay.wlvs.space wss://relay.nostrati.com wss://relay.nostr.com.au wss://nostr.inosta.cc wss://nostr.rocks \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index f1556fa..ac416f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,17 +13,21 @@ "@mui/icons-material": "5.15.11", "@mui/lab": "5.0.0-alpha.166", "@mui/material": "5.15.11", + "@noble/hashes": "^1.4.0", "@nostr-dev-kit/ndk": "2.5.0", "@reduxjs/toolkit": "2.2.1", "axios": "1.6.7", "crypto-hash": "3.0.0", "crypto-js": "^4.2.0", + "dnd-core": "16.0.1", "file-saver": "2.0.5", "jszip": "3.10.1", "lodash": "4.17.21", "mui-file-input": "4.0.4", "nostr-tools": "2.3.1", "react": "^18.2.0", + "react-dnd": "16.0.1", + "react-dnd-html5-backend": "16.0.1", "react-dom": "^18.2.0", "react-redux": "9.1.0", "react-router-dom": "6.22.1", @@ -43,6 +47,7 @@ "eslint": "^8.56.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.5", + "prettier": "3.2.5", "ts-css-modules-vite-plugin": "1.0.20", "typescript": "^5.2.2", "vite": "^5.1.4" @@ -1505,9 +1510,9 @@ } }, "node_modules/@noble/hashes": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz", - "integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", "engines": { "node": ">= 16" }, @@ -1598,6 +1603,17 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@nostr-dev-kit/ndk/node_modules/@noble/hashes": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz", + "integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nostr-dev-kit/ndk/node_modules/nostr-tools": { "version": "1.17.0", "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-1.17.0.tgz", @@ -1628,6 +1644,21 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@react-dnd/asap": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-5.0.2.tgz", + "integrity": "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A==" + }, + "node_modules/@react-dnd/invariant": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-4.0.2.tgz", + "integrity": "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw==" + }, + "node_modules/@react-dnd/shallowequal": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz", + "integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==" + }, "node_modules/@reduxjs/toolkit": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.2.1.tgz", @@ -1863,6 +1894,28 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@scure/bip32/node_modules/@noble/curves/node_modules/@noble/hashes": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz", + "integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32/node_modules/@noble/hashes": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.3.tgz", + "integrity": "sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA==", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@scure/bip39": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.1.tgz", @@ -1875,6 +1928,17 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@scure/bip39/node_modules/@noble/hashes": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.3.tgz", + "integrity": "sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA==", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", @@ -1974,7 +2038,7 @@ "version": "20.11.20", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.20.tgz", "integrity": "sha512-7/rR21OS+fq8IyHTgtLkDK949uzsa6n8BkziAKtPVpugIkO6D+/ooXMvzXxDnZrmtXVfjb1bKQafYpb8s89LOg==", - "dev": true, + "devOptional": true, "peer": true, "dependencies": { "undici-types": "~5.26.4" @@ -2716,6 +2780,24 @@ "node": ">=8" } }, + "node_modules/dnd-core": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-16.0.1.tgz", + "integrity": "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==", + "dependencies": { + "@react-dnd/asap": "^5.0.1", + "@react-dnd/invariant": "^4.0.1", + "redux": "^4.2.0" + } + }, + "node_modules/dnd-core/node_modules/redux": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", + "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", + "dependencies": { + "@babel/runtime": "^7.9.2" + } + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -3166,8 +3248,7 @@ "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-glob": { "version": "3.3.2", @@ -4027,6 +4108,17 @@ } } }, + "node_modules/nostr-tools/node_modules/@noble/hashes": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz", + "integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/nostr-wasm": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz", @@ -4244,6 +4336,21 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz", + "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-bytes": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz", @@ -4320,6 +4427,43 @@ "node": ">=0.10.0" } }, + "node_modules/react-dnd": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz", + "integrity": "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==", + "dependencies": { + "@react-dnd/invariant": "^4.0.1", + "@react-dnd/shallowequal": "^4.0.1", + "dnd-core": "^16.0.1", + "fast-deep-equal": "^3.1.3", + "hoist-non-react-statics": "^3.3.2" + }, + "peerDependencies": { + "@types/hoist-non-react-statics": ">= 3.3.1", + "@types/node": ">= 12", + "@types/react": ">= 16", + "react": ">= 16.14" + }, + "peerDependenciesMeta": { + "@types/hoist-non-react-statics": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-dnd-html5-backend": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz", + "integrity": "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==", + "dependencies": { + "dnd-core": "^16.0.1" + } + }, "node_modules/react-dom": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", @@ -4917,7 +5061,7 @@ "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true, + "devOptional": true, "peer": true }, "node_modules/update-browserslist-db": { diff --git a/package.json b/package.json index 75a6c85..da3e20a 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,8 @@ "build": "tsc && vite build", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint:fix": "eslint . --fix --ext ts,tsx --report-unused-disable-directives --max-warnings 0", - "formatter:check": "npx prettier --check \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"", - "formatter:fix": "npx prettier --write \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"", + "formatter:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"", + "formatter:fix": "prettier --write \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"", "preview": "vite preview" }, "dependencies": { @@ -19,17 +19,21 @@ "@mui/icons-material": "5.15.11", "@mui/lab": "5.0.0-alpha.166", "@mui/material": "5.15.11", + "@noble/hashes": "^1.4.0", "@nostr-dev-kit/ndk": "2.5.0", "@reduxjs/toolkit": "2.2.1", "axios": "1.6.7", "crypto-hash": "3.0.0", "crypto-js": "^4.2.0", + "dnd-core": "16.0.1", "file-saver": "2.0.5", "jszip": "3.10.1", "lodash": "4.17.21", "mui-file-input": "4.0.4", "nostr-tools": "2.3.1", "react": "^18.2.0", + "react-dnd": "16.0.1", + "react-dnd-html5-backend": "16.0.1", "react-dom": "^18.2.0", "react-redux": "9.1.0", "react-router-dom": "6.22.1", @@ -49,6 +53,7 @@ "eslint": "^8.56.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.5", + "prettier": "3.2.5", "ts-css-modules-vite-plugin": "1.0.20", "typescript": "^5.2.2", "vite": "^5.1.4" diff --git a/src/App.tsx b/src/App.tsx index 58f0c71..149bddb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -33,6 +33,14 @@ const App = () => { } } + const handleRootRedirect = () => { + if (authState.loggedIn) return appPrivateRoutes.homePage + const callbackPathEncoded = btoa( + window.location.href.split(`${window.location.origin}/#`)[1] + ) + return `${appPublicRoutes.login}?callbackPath=${callbackPathEncoded}` + } + return ( }> @@ -66,18 +74,7 @@ const App = () => { } })} - - } - /> + } /> ) diff --git a/src/components/AppBar/AppBar.tsx b/src/components/AppBar/AppBar.tsx index 40ca9de..a1971ed 100644 --- a/src/components/AppBar/AppBar.tsx +++ b/src/components/AppBar/AppBar.tsx @@ -10,21 +10,28 @@ import { import { useEffect, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' -import { setAuthState } from '../../store/actions' +import { setAuthState, setMetadataEvent } from '../../store/actions' import { State } from '../../store/rootReducer' import { Dispatch } from '../../store/store' import Username from '../username' import { Link, useNavigate } from 'react-router-dom' -import nostrichAvatar from '../../assets/images/avatar.png' -import { NostrController } from '../../controllers' -import { appPublicRoutes, getProfileRoute } from '../../routes' +import { + appPrivateRoutes, + appPublicRoutes, + getProfileRoute +} from '../../routes' +import { MetadataController, NostrController } from '../../controllers' import { clearAuthToken, + clearState, saveNsecBunkerDelegatedKey, shorten } from '../../utils' import styles from './style.module.scss' +import { setUserRobotImage } from '../../store/userRobotImage/action' + +const metadataController = new MetadataController() export const AppBar = () => { const navigate = useNavigate() @@ -32,21 +39,31 @@ export const AppBar = () => { const dispatch: Dispatch = useDispatch() const [username, setUsername] = useState('') - const [userAvatar, setUserAvatar] = useState(nostrichAvatar) + const [userAvatar, setUserAvatar] = useState('') const [anchorElUser, setAnchorElUser] = useState(null) const authState = useSelector((state: State) => state.auth) const metadataState = useSelector((state: State) => state.metadata) + const userRobotImage = useSelector((state: State) => state.userRobotImage) useEffect(() => { - if (metadataState && metadataState.content) { - const { picture, display_name, name } = JSON.parse(metadataState.content) + if (metadataState) { + if (metadataState.content) { + const { picture, display_name, name } = JSON.parse( + metadataState.content + ) - if (picture) setUserAvatar(picture) + if (picture || userRobotImage) { + setUserAvatar(picture || userRobotImage) + } - setUsername(shorten(display_name || name || '', 7)) + setUsername(shorten(display_name || name || '', 7)) + } else { + setUserAvatar(userRobotImage || '') + setUsername('') + } } - }, [metadataState]) + }, [metadataState, userRobotImage]) const handleOpenUserMenu = (event: React.MouseEvent) => { setAnchorElUser(event.currentTarget) @@ -67,15 +84,19 @@ export const AppBar = () => { handleCloseUserMenu() dispatch( setAuthState({ + keyPair: undefined, loggedIn: false, usersPubkey: undefined, loginMethod: undefined, nsecBunkerPubkey: undefined }) ) + dispatch(setMetadataEvent(metadataController.getEmptyMetadataEvent())) + dispatch(setUserRobotImage(null)) // clear authToken saved in local storage clearAuthToken() + clearState() // update nsecBunker delegated key after logout const nostrController = NostrController.getInstance() @@ -143,6 +164,16 @@ export const AppBar = () => { > Profile + { + navigate(appPrivateRoutes.relays) + }} + sx={{ + justifyContent: 'center' + }} + > + Relays + { } export default Username + +type UserProps = { + pubkey: string + name: string + image?: string +} + +/** + * This component will be used for the displaying username and profile picture. + * If image is not available, robohash image will be displayed + */ +export const UserComponent = ({ pubkey, name, image }: UserProps) => { + const theme = useTheme() + + const npub = hexToNpub(pubkey) + const roboImage = `https://robohash.org/${npub}.png?set=set3` + + return ( + + User Image + + + {name} + + + + ) +} diff --git a/src/controllers/AuthController.ts b/src/controllers/AuthController.ts index 8e488c5..34b2f7a 100644 --- a/src/controllers/AuthController.ts +++ b/src/controllers/AuthController.ts @@ -1,13 +1,18 @@ import { EventTemplate } from 'nostr-tools' import { MetadataController, NostrController } from '.' -import { setAuthState, setMetadataEvent } from '../store/actions' +import { + setAuthState, + setMetadataEvent, + setRelayMapAction +} from '../store/actions' import store from '../store/store' import { base64DecodeAuthToken, base64EncodeSignedEvent, getAuthToken, getVisitedLink, - saveAuthToken + saveAuthToken, + compareObjects } from '../utils' import { appPrivateRoutes } from '../routes' import { SignedEvent } from '../types' @@ -30,14 +35,22 @@ export class AuthController { * @returns url to redirect if authentication successfull * or error if otherwise */ - async authenticateAndFindMetadata(pubkey: string) { + async authAndGetMetadataAndRelaysMap(pubkey: string) { + const emptyMetadata = this.metadataController.getEmptyMetadataEvent() + this.metadataController .findMetadata(pubkey) .then((event) => { - store.dispatch(setMetadataEvent(event)) + if (event) { + store.dispatch(setMetadataEvent(event)) + } else { + store.dispatch(setMetadataEvent(emptyMetadata)) + } }) .catch((err) => { - console.error('Error occurred while finding metadata', err) + console.warn('Error occurred while finding metadata', err) + + store.dispatch(setMetadataEvent(emptyMetadata)) }) // Nostr uses unix timestamps @@ -61,6 +74,27 @@ export class AuthController { }) ) + const relaysState = store.getState().relays + + if (relaysState) { + // Relays state is defined and there is no need to await for the latest relay map + this.nostrController.getRelayMap(pubkey).then((relayMap) => { + if (!compareObjects(relaysState?.map, relayMap)) { + store.dispatch(setRelayMapAction(relayMap.map)) + } + }) + } else { + // Relays state is not defined, await for the latest relay map + const relayMap = await this.nostrController.getRelayMap(pubkey) + + if (Object.keys(relayMap).length < 1) { + // Navigate user to relays page + return Promise.resolve(appPrivateRoutes.relays) + } + + store.dispatch(setRelayMapAction(relayMap.map)) + } + const visitedLink = getVisitedLink() if (visitedLink) { diff --git a/src/controllers/MetadataController.ts b/src/controllers/MetadataController.ts index a2a2e5d..360acc6 100644 --- a/src/controllers/MetadataController.ts +++ b/src/controllers/MetadataController.ts @@ -23,6 +23,18 @@ export class MetadataController { this.nostrController = NostrController.getInstance() } + public getEmptyMetadataEvent = (): Event => { + return { + content: '', + created_at: new Date().valueOf(), + id: '', + kind: 0, + pubkey: '', + sig: '', + tags: [] + } + } + public findMetadata = async (hexKey: string) => { const eventFilter: Filter = { kinds: [kinds.Metadata], @@ -130,6 +142,7 @@ export class MetadataController { public extractProfileMetadataContent = (event: VerifiedEvent) => { try { + if (!event.content) return {} return JSON.parse(event.content) as ProfileMetadata } catch (error) { console.log('error in parsing metadata event content :>> ', error) diff --git a/src/controllers/NostrController.ts b/src/controllers/NostrController.ts index 05dd429..cd433f5 100644 --- a/src/controllers/NostrController.ts +++ b/src/controllers/NostrController.ts @@ -9,20 +9,23 @@ import { Event, EventTemplate, SimplePool, + Filter, UnsignedEvent, finalizeEvent, nip04, - nip19 + nip19, + kinds } from 'nostr-tools' import { EventEmitter } from 'tseep' import { updateNsecbunkerPubkey } from '../store/actions' import { AuthState, LoginMethods } from '../store/auth/types' import store from '../store/store' -import { SignedEvent } from '../types' +import { SignedEvent, RelayMap } from '../types' import { getNsecBunkerDelegatedKey, verifySignedEvent } from '../utils' export class NostrController extends EventEmitter { private static instance: NostrController + private specialMetadataRelay = 'wss://purplepag.es' private bunkerNDK: NDK | undefined private remoteSigner: NDKNip46Signer | undefined @@ -216,12 +219,16 @@ export class NostrController extends EventEmitter { if (publishedRelays.length === 0) { const failedPublishes: any[] = [] + const fallbackRejectionReason = + 'Attempt to publish an event has been rejected with unknown reason.' results.forEach((res, index) => { if (res.status === 'rejected') { failedPublishes.push({ relay: relays[index], - error: res.reason.message + error: res.reason + ? res.reason.message || fallbackRejectionReason + : fallbackRejectionReason }) } }) @@ -319,15 +326,18 @@ export class NostrController extends EventEmitter { } if (loginMethod === LoginMethods.privateKey) { - const keyPair = (store.getState().auth as AuthState).keyPair + const keys = (store.getState().auth as AuthState).keyPair - if (!keyPair) { + if (!keys) { throw new Error( `Login method is ${LoginMethods.privateKey} but private & public key pair is not found.` ) } - const encrypted = await nip04.encrypt(keyPair.private, receiver, content) + const { private: nsec } = keys + const privateKey = nip19.decode(nsec).data as Uint8Array + + const encrypted = await nip04.encrypt(privateKey, receiver, content) return encrypted } @@ -364,6 +374,110 @@ export class NostrController extends EventEmitter { return Promise.resolve(pubKey) } + /** + * Provides relay map. + * @param npub - user's npub + * @returns - promise that resolves into relay map and a timestamp when it has been updated. + */ + getRelayMap = async ( + npub: string + ): Promise<{ map: RelayMap; mapUpdated: number }> => { + const mostPopularRelays = import.meta.env.VITE_MOST_POPULAR_RELAYS + const hardcodedPopularRelays = (mostPopularRelays || '').split(' ') + const popularRelayURIs = [ + this.specialMetadataRelay, + ...hardcodedPopularRelays + ] + + const pool = new SimplePool() + + // More info about this kind of event available https://github.com/nostr-protocol/nips/blob/master/65.md + const eventFilter: Filter = { + kinds: [kinds.RelayList], + authors: [npub] + } + + const event = await pool.get(popularRelayURIs, eventFilter).catch((err) => { + return Promise.reject(err) + }) + + if (event) { + // Handle founded 10002 event + const relaysMap: RelayMap = {} + + // 'r' stands for 'relay' + const relayTags = event.tags.filter((tag) => tag[0] === 'r') + + relayTags.forEach((tag) => { + const uri = tag[1] + const relayType = tag[2] + + // if 3rd element of relay tag is undefined, relay is WRITE and READ + relaysMap[uri] = { + write: relayType ? relayType === 'write' : true, + read: relayType ? relayType === 'read' : true + } + }) + + return Promise.resolve({ map: relaysMap, mapUpdated: event.created_at }) + } else { + return Promise.reject('User relays were not found.') + } + } + + /** + * Publishes relay map. + * @param relayMap - relay map. + * @param npub - user's npub. + * @returns - promise that resolves into a string representing publishing result. + */ + publishRelayMap = async ( + relayMap: RelayMap, + npub: string + ): Promise => { + const timestamp = Math.floor(Date.now() / 1000) + const relayURIs = Object.keys(relayMap) + + // More info about this kind of event available https://github.com/nostr-protocol/nips/blob/master/65.md + const tags: string[][] = relayURIs.map((relayURI) => + [ + 'r', + relayURI, + relayMap[relayURI].read && relayMap[relayURI].write + ? '' + : relayMap[relayURI].write + ? 'write' + : 'read' + ].filter((value) => value !== '') + ) + + const newRelayMapEvent: UnsignedEvent = { + kind: kinds.RelayList, + tags, + content: '', + pubkey: npub, + created_at: timestamp + } + + const signedEvent = await this.signEvent(newRelayMapEvent) + + let relaysToPublish = relayURIs + + // If relay map is empty, use most popular relay URIs + if (!relaysToPublish.length) { + const mostPopularRelays = import.meta.env.VITE_MOST_POPULAR_RELAYS + const hardcodedPopularRelays = (mostPopularRelays || '').split(' ') + + relaysToPublish = [this.specialMetadataRelay, ...hardcodedPopularRelays] + } + + await this.publishEvent(signedEvent, relaysToPublish) + + return Promise.resolve( + `Relay Map published on: ${relaysToPublish.join('\n')}` + ) + } + /** * Generates NDK Private Signer * @returns nSecBunker delegated key diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000..16c8633 --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1 @@ +export * from './store' diff --git a/src/hooks/store.ts b/src/hooks/store.ts new file mode 100644 index 0000000..f3e9b21 --- /dev/null +++ b/src/hooks/store.ts @@ -0,0 +1,6 @@ +import { useDispatch, useSelector } from 'react-redux' +import type { Dispatch, RootState } from '../store/store' + +// Use instead of plain `useDispatch` and `useSelector` +export const useAppDispatch = useDispatch.withTypes() +export const useAppSelector = useSelector.withTypes() diff --git a/src/layouts/Main.tsx b/src/layouts/Main.tsx index b452354..9c4f4e4 100644 --- a/src/layouts/Main.tsx +++ b/src/layouts/Main.tsx @@ -1,24 +1,36 @@ import { Box } from '@mui/material' import Container from '@mui/material/Container' import { useEffect, useState } from 'react' -import { useDispatch } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' import { Outlet } from 'react-router-dom' import { AppBar } from '../components/AppBar/AppBar' -import { restoreState, setAuthState } from '../store/actions' -import { clearAuthToken, loadState, saveNsecBunkerDelegatedKey } from '../utils' +import { restoreState, setAuthState, setMetadataEvent } from '../store/actions' +import { + clearAuthToken, + clearState, + getRoboHashPicture, + loadState, + saveNsecBunkerDelegatedKey +} from '../utils' import { LoadingSpinner } from '../components/LoadingSpinner' import { Dispatch } from '../store/store' -import { NostrController } from '../controllers' +import { MetadataController, NostrController } from '../controllers' import { LoginMethods } from '../store/auth/types' +import { setUserRobotImage } from '../store/userRobotImage/action' +import { State } from '../store/rootReducer' + +const metadataController = new MetadataController() export const MainLayout = () => { const dispatch: Dispatch = useDispatch() const [isLoading, setIsLoading] = useState(true) + const authState = useSelector((state: State) => state.auth) useEffect(() => { const logout = () => { dispatch( setAuthState({ + keyPair: undefined, loggedIn: false, usersPubkey: undefined, loginMethod: undefined, @@ -26,8 +38,11 @@ export const MainLayout = () => { }) ) + dispatch(setMetadataEvent(metadataController.getEmptyMetadataEvent())) + // clear authToken saved in local storage clearAuthToken() + clearState() // update nsecBunker delegated key const newDelegatedKey = @@ -59,6 +74,21 @@ export const MainLayout = () => { setIsLoading(false) }, [dispatch]) + /** + * When authState change user logged in / or app reloaded + * we set robohash avatar in the global state based on user npub + * so that avatar will be consistent across the app when kind 0 is empty + */ + useEffect(() => { + if (authState && authState.loggedIn) { + const pubkey = authState.usersPubkey || authState.keyPair?.public + + if (pubkey) { + dispatch(setUserRobotImage(getRoboHashPicture(pubkey))) + } + } + }, [authState]) + if (isLoading) return return ( diff --git a/src/main.tsx b/src/main.tsx index 0b362fc..135d197 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -14,7 +14,9 @@ store.subscribe( _.throttle(() => { saveState({ auth: store.getState().auth, - metadata: store.getState().metadata + metadata: store.getState().metadata, + userRobotImage: store.getState().userRobotImage, + relays: store.getState().relays }) }, 1000) ) diff --git a/src/pages/create/index.tsx b/src/pages/create/index.tsx index b7e568d..56b07c0 100644 --- a/src/pages/create/index.tsx +++ b/src/pages/create/index.tsx @@ -1,4 +1,4 @@ -import { Clear } from '@mui/icons-material' +import { Clear, DragHandle } from '@mui/icons-material' import { Box, Button, @@ -18,20 +18,24 @@ import { Tooltip, Typography } from '@mui/material' +import JSZip from 'jszip' import { MuiFileInput } from 'mui-file-input' -import { useEffect, useState } from 'react' -import { Link, useNavigate } from 'react-router-dom' -import placeholderAvatar from '../../assets/images/nostr-logo.jpg' +import { useEffect, useRef, useState } from 'react' +import { useSelector } from 'react-redux' +import { useNavigate } from 'react-router-dom' +import { toast } from 'react-toastify' import { LoadingSpinner } from '../../components/LoadingSpinner' +import { UserComponent } from '../../components/username' import { MetadataController, NostrController } from '../../controllers' -import { appPrivateRoutes, getProfileRoute } from '../../routes' -import { ProfileMetadata, User, UserRole } from '../../types' +import { appPrivateRoutes } from '../../routes' +import { State } from '../../store/rootReducer' +import { Meta, ProfileMetadata, User, UserRole } from '../../types' import { encryptArrayBuffer, generateEncryptionKey, getHash, hexToNpub, - pubToHex, + npubToHex, queryNip05, sendDM, shorten, @@ -39,10 +43,10 @@ import { uploadToFileStorage } from '../../utils' import styles from './style.module.scss' -import { toast } from 'react-toastify' -import JSZip from 'jszip' -import { useSelector } from 'react-redux' -import { State } from '../../store/rootReducer' +import { DndProvider } from 'react-dnd' +import { HTML5Backend } from 'react-dnd-html5-backend' +import type { Identifier, XYCoord } from 'dnd-core' +import { useDrag, useDrop } from 'react-dnd' export const CreatePage = () => { const navigate = useNavigate() @@ -85,13 +89,21 @@ export const CreatePage = () => { const addUser = (pubkey: string) => { setUsers((prev) => { + const signers = prev.filter((user) => user.role === UserRole.signer) + const viewers = prev.filter((user) => user.role === UserRole.viewer) + const existingUserIndex = prev.findIndex( (user) => user.pubkey === pubkey ) // add new - if (existingUserIndex === -1) - return [...prev, { pubkey, role: userRole }] + if (existingUserIndex === -1) { + if (userRole === UserRole.signer) { + return [...signers, { pubkey, role: userRole }, ...viewers] + } else { + return [...signers, ...viewers, { pubkey, role: userRole }] + } + } const existingUser = prev[existingUserIndex] @@ -104,12 +116,16 @@ export const CreatePage = () => { updatedUser.role = userRole updatedUsers[existingUserIndex] = updatedUser - return updatedUsers + // signers should be placed at the start of the array + return [ + ...updatedUsers.filter((user) => user.role === UserRole.signer), + ...updatedUsers.filter((user) => user.role === UserRole.viewer) + ] }) } if (userInput.startsWith('npub')) { - const pubkey = await pubToHex(userInput) + const pubkey = npubToHex(userInput) if (pubkey) { addUser(pubkey) setUserInput('') @@ -132,7 +148,7 @@ export const CreatePage = () => { setLoadingSpinnerDesc('') }) - if (nip05Profile) { + if (nip05Profile && nip05Profile.pubkey) { const pubkey = nip05Profile.pubkey addUser(pubkey) setUserInput('') @@ -145,25 +161,40 @@ export const CreatePage = () => { setError('Invalid input! Make sure to provide correct npub or nip05.') } - const handleUserRoleChange = (role: UserRole, index: number) => { - setUsers((prevUsers) => { - // Create a shallow copy of the previous state - const updatedUsers = [...prevUsers] - // Create a shallow copy of the user object at the specified index - const updatedUser = { ...updatedUsers[index] } - // Update the role property of the copied user object - updatedUser.role = role - // Update the user object at the specified index in the copied array - updatedUsers[index] = updatedUser - // Return the updated array - return updatedUsers - }) + const handleUserRoleChange = (role: UserRole, pubkey: string) => { + setUsers((prevUsers) => + prevUsers.map((user) => { + if (user.pubkey === pubkey) { + return { + ...user, + role + } + } + + return user + }) + ) } const handleRemoveUser = (pubkey: string) => { setUsers((prev) => prev.filter((user) => user.pubkey !== pubkey)) } + /** + * changes the position of signer in the signers list + * + * @param dragIndex represents the current position of user + * @param hoverIndex represents the target position of user + */ + const moveSigner = (dragIndex: number, hoverIndex: number) => { + setUsers((prevUsers) => { + const updatedUsers = [...prevUsers] + const [draggedUser] = updatedUsers.splice(dragIndex, 1) + updatedUsers.splice(hoverIndex, 0, draggedUser) + return updatedUsers + }) + } + const handleSelectFiles = (files: File[]) => { setDisplayUserInput(true) setSelectedFiles((prev) => { @@ -246,13 +277,13 @@ export const CreatePage = () => { if (!signedEvent) return // create content for meta file - const meta = { - signers: signers.map((signer) => signer.pubkey), - viewers: viewers.map((viewer) => viewer.pubkey), + const meta: Meta = { + signers: signers.map((signer) => hexToNpub(signer.pubkey)), + viewers: viewers.map((viewer) => hexToNpub(viewer.pubkey)), fileHashes, - submittedBy: usersPubkey, + submittedBy: hexToNpub(usersPubkey!), signedEvents: { - [signedEvent.pubkey]: JSON.stringify(signedEvent, null, 2) + [hexToNpub(signedEvent.pubkey)]: JSON.stringify(signedEvent, null, 2) } } @@ -351,7 +382,7 @@ export const CreatePage = () => { setIsLoading(false) navigate( - `${appPrivateRoutes.verify}?file=${encodeURIComponent( + `${appPrivateRoutes.sign}?file=${encodeURIComponent( fileUrl )}&key=${encodeURIComponent(encryptionKey)}` ) @@ -397,7 +428,7 @@ export const CreatePage = () => { {displayUserInput && ( <> - Select signers and viewers + Add Counterparties { users={users} handleUserRoleChange={handleUserRoleChange} handleRemoveUser={handleRemoveUser} + moveSigner={moveSigner} /> - - - - ) -} diff --git a/src/pages/decrypt/style.module.scss b/src/pages/decrypt/style.module.scss deleted file mode 100644 index ae72e77..0000000 --- a/src/pages/decrypt/style.module.scss +++ /dev/null @@ -1,27 +0,0 @@ -@import '../../colors.scss'; - -.container { - display: flex; - flex-direction: column; - color: $text-color; - - .inputBlock { - position: relative; - display: flex; - flex-direction: column; - gap: 25px; - } - - .fileDragOver { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background-color: rgba(255, 255, 255, 0.8); - z-index: 1; - display: flex; - justify-content: center; - align-items: center; - } -} diff --git a/src/pages/home/index.tsx b/src/pages/home/index.tsx index 01ee591..ff7a40f 100644 --- a/src/pages/home/index.tsx +++ b/src/pages/home/index.tsx @@ -14,6 +14,12 @@ export const HomePage = () => { > Create +