├── .dockerignore ├── .github └── workflows │ └── deploy.yml ├── .gitignore ├── .npmrc ├── Dockerfile ├── LICENSE ├── README.md ├── client-web ├── .eslintrc.cjs ├── .gitignore ├── README.md ├── index.html ├── package.json ├── preview.png ├── public │ ├── apple-touch-icon.png │ ├── favicon.ico │ ├── logo-square.svg │ ├── logo.png │ ├── logo.svg │ ├── no-image.png │ ├── pwa-192x192.png │ ├── pwa-512x512.png │ └── robots.txt ├── src │ ├── assets │ │ └── logo.svg │ ├── components │ │ ├── accounts │ │ │ ├── list.tsx │ │ │ ├── sattelite-cdn-account.tsx │ │ │ ├── sattelite-cdn-payment-modal.tsx │ │ │ ├── sattelite-cdn-payment.tsx │ │ │ └── sattelite-cdn.tsx │ │ ├── bottom-bar.tsx │ │ ├── connect-modal.tsx │ │ ├── create-event-form-modal.tsx │ │ ├── create-event-form.tsx │ │ ├── event.tsx │ │ ├── event │ │ │ ├── action-buttons.tsx │ │ │ ├── banner.tsx │ │ │ ├── card-footer.tsx │ │ │ ├── clickable-links.tsx │ │ │ ├── content.tsx │ │ │ ├── image-slideshow.tsx │ │ │ ├── info-modal.tsx │ │ │ ├── link-preview.tsx │ │ │ ├── nsfw-toggle.tsx │ │ │ ├── replies.tsx │ │ │ ├── user.tsx │ │ │ ├── zap-modal.tsx │ │ │ └── zap.tsx │ │ ├── events.tsx │ │ ├── feeds.tsx │ │ ├── file-input.tsx │ │ ├── file-upload.tsx │ │ ├── integration.tsx │ │ ├── list-assignment-modal.tsx │ │ ├── list-selection.tsx │ │ ├── loading.tsx │ │ ├── menu-item.tsx │ │ ├── on-demand-event.tsx │ │ ├── on-demand-username.tsx │ │ ├── popular-users.tsx │ │ ├── qrcode.tsx │ │ ├── queue-table.tsx │ │ ├── relay-selection.tsx │ │ ├── relays-table.tsx │ │ ├── subscriptions-table.tsx │ │ ├── user-icon.tsx │ │ ├── user-profile-form.tsx │ │ └── user.tsx │ ├── defaults.ts │ ├── index.css │ ├── layouts │ │ └── primary.tsx │ ├── lib │ │ ├── bytesToGB.ts │ │ ├── default-filters.ts │ │ ├── event-reactions-filter.ts │ │ ├── excerpt.ts │ │ ├── integrations.ts │ │ ├── kinds.ts │ │ ├── new-event-for-submission.ts │ │ ├── nkind.ts │ │ ├── note-spec.ts │ │ ├── object-of-relays-to-array.ts │ │ ├── pagination.ts │ │ ├── relative-time.ts │ │ ├── round.ts │ │ ├── toast.ts │ │ └── user-properties.ts │ ├── main.tsx │ ├── nostr.d.ts │ ├── routes │ │ ├── account.tsx │ │ ├── blocked.tsx │ │ ├── event.tsx │ │ ├── following.tsx │ │ ├── lists.tsx │ │ ├── notifications.tsx │ │ ├── profile.tsx │ │ ├── tag.tsx │ │ ├── user-profile.tsx │ │ └── welcome.tsx │ ├── state │ │ ├── base-types.ts │ │ ├── client-types.ts │ │ ├── client.ts │ │ ├── keystore.ts │ │ ├── pow-worker.ts │ │ └── worker.ts │ ├── theme.ts │ └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json ├── upload.sh └── vite.config.ts ├── client ├── .gitignore ├── README.md ├── package.json ├── src │ └── index.ts └── tsconfig.json ├── discovered-relays.json ├── docker-compose.yml ├── package.json ├── packages ├── README.md ├── common │ ├── .gitignore │ ├── README.md │ ├── build.js │ ├── jest.config.cjs │ ├── package.json │ ├── src │ │ ├── classes │ │ │ ├── event.test.ts │ │ │ ├── event.ts │ │ │ ├── filter.ts │ │ │ ├── identity-claim.ts │ │ │ ├── index.ts │ │ │ ├── relay-client.ts │ │ │ ├── relay-connection.ts │ │ │ ├── user-store.ts │ │ │ └── user.ts │ │ ├── index.ts │ │ ├── types │ │ │ ├── bech32.ts │ │ │ ├── client-message.ts │ │ │ ├── content.ts │ │ │ ├── event-contacts.ts │ │ │ ├── event-coordinates-tag.ts │ │ │ ├── event-event-tag.ts │ │ │ ├── event-kind.ts │ │ │ ├── event-with-user.ts │ │ │ ├── event.ts │ │ │ ├── filter.ts │ │ │ ├── identifier-well-known.ts │ │ │ ├── identity-claim.ts │ │ │ ├── index.ts │ │ │ ├── lnurl-invoice.ts │ │ │ ├── nips.ts │ │ │ ├── publishing.ts │ │ │ ├── relay-information.ts │ │ │ ├── relay-message.ts │ │ │ ├── relay.ts │ │ │ ├── report-types.ts │ │ │ ├── report.ts │ │ │ ├── subscriptions.ts │ │ │ ├── user-metadata.ts │ │ │ ├── user-store.ts │ │ │ ├── user.ts │ │ │ ├── websocket.ts │ │ │ └── zap.ts │ │ └── utils │ │ │ ├── bech32.test.ts │ │ │ ├── bech32.ts │ │ │ ├── bolt11.test.ts │ │ │ ├── bolt11.ts │ │ │ ├── event-amount.ts │ │ │ ├── event-contact.ts │ │ │ ├── event-content-warning.ts │ │ │ ├── event-content.test.ts │ │ │ ├── event-content.ts │ │ │ ├── event-coordinates.ts │ │ │ ├── event-even-tag.test.ts │ │ │ ├── event-event.ts │ │ │ ├── event-expiration.ts │ │ │ ├── event-identifier.ts │ │ │ ├── event-labels.ts │ │ │ ├── event-lnurl.ts │ │ │ ├── event-nonce.ts │ │ │ ├── event-publickey.test.ts │ │ │ ├── event-publickey.ts │ │ │ ├── event-recommendation.ts │ │ │ ├── event-relays.ts │ │ │ ├── event-reporting.test.ts │ │ │ ├── event-reporting.ts │ │ │ ├── event-subject.ts │ │ │ ├── event-tags.ts │ │ │ ├── event-zap.ts │ │ │ ├── event.nonce.test.ts │ │ │ ├── generate-keypair.ts │ │ │ ├── hash-event.ts │ │ │ ├── index.ts │ │ │ ├── lnurl-zap.test.ts │ │ │ ├── lnurl-zap.ts │ │ │ ├── lnurl.test.ts │ │ │ ├── lnurl.ts │ │ │ ├── lud16-to-url.ts │ │ │ ├── lud16.test.ts │ │ │ ├── nip05-to-url.ts │ │ │ ├── nostr-url.test.ts │ │ │ ├── nostr-url.ts │ │ │ ├── proof-of-work.test.ts │ │ │ ├── proof-of-work.ts │ │ │ ├── provider-names.ts │ │ │ ├── serialize-event.ts │ │ │ ├── sign-event.ts │ │ │ ├── user-metadata.ts │ │ │ ├── utf8-coder.ts │ │ │ ├── verify-event.ts │ │ │ └── websocket-url.ts │ ├── tsconfig.build.json │ └── tsconfig.json ├── node │ ├── .gitignore │ ├── README.md │ ├── build.js │ ├── jest.config.cjs │ ├── package.json │ ├── src │ │ ├── classes │ │ │ ├── index.ts │ │ │ ├── relay-client.test.ts │ │ │ ├── relay-client.ts │ │ │ ├── relay-discovery.ts │ │ │ └── user.ts │ │ ├── index.ts │ │ └── utils │ │ │ ├── index.ts │ │ │ ├── keypair.ts │ │ │ ├── make-request.ts │ │ │ ├── relay-information.ts │ │ │ └── ws.ts │ ├── tsconfig.build.json │ └── tsconfig.json ├── tsconfig.base.json └── web │ ├── .gitignore │ ├── README.md │ ├── build.js │ ├── jest.config.cjs │ ├── package.json │ ├── src │ ├── classes │ │ ├── index.ts │ │ ├── relay-client.ts │ │ ├── sattelite-cdn.test.ts │ │ ├── sattelite-cdn.ts │ │ └── user.ts │ ├── index.ts │ ├── utils │ │ ├── index.ts │ │ ├── keypair.ts │ │ ├── make-request.ts │ │ ├── relay-information.ts │ │ └── ws.ts │ └── worker │ │ ├── database-helper.ts │ │ ├── database-migration.ts │ │ ├── database.ts │ │ ├── index.ts │ │ ├── lists.ts │ │ ├── worker-extra.test.ts │ │ ├── worker-extra.ts │ │ ├── worker-queue.ts │ │ ├── worker.test.ts │ │ └── worker.ts │ ├── tsconfig.build.json │ └── tsconfig.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── relay-docker ├── .gitignore ├── Dockerfile.gnost-relay ├── Dockerfile.nginx ├── README.md ├── config.json ├── docker-compose.yml ├── entrypoint.sh └── nginx.conf └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | **/dist 3 | **/key 4 | **/key.pub 5 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy to GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - master # Trigger the deployment when changes are pushed to the main branch 7 | 8 | jobs: 9 | build-and-deploy: 10 | runs-on: ubuntu-latest 11 | env: 12 | VITE_CLIENT_WEB_BASE_URL: "/nostr-ts/" 13 | 14 | steps: 15 | - name: Checkout Repository 16 | uses: actions/checkout@v3 17 | 18 | - name: Set up Node.js 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: '18' 22 | 23 | - name: Install pnpm 24 | run: npm install -g pnpm 25 | 26 | - name: Install Dependencies 27 | run: pnpm install --force 28 | 29 | - name: Build packages 30 | run: pnpm run build 31 | 32 | - name: Build client-web 33 | run: | 34 | cd client-web 35 | pnpm run build 36 | 37 | - name: Deploy to GitHub Pages 38 | uses: peaceiris/actions-gh-pages@v3 39 | with: 40 | github_token: ${{ secrets.GITHUB_TOKEN }} 41 | publish_dir: ./client-web/dist 42 | publish_branch: gh-pages 43 | user_name: 'GitHub Actions' 44 | user_email: 'github-actions-bot@users.noreply.github.com' 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Node dependencies 2 | node_modules 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1: Build 2 | FROM node:18 AS build 3 | 4 | WORKDIR /app 5 | 6 | RUN npm install -g pnpm 7 | 8 | COPY . . 9 | 10 | RUN pnpm install --force 11 | 12 | # Build packages 13 | RUN pnpm run build 14 | 15 | # Build client-web 16 | RUN sh -c 'cd client-web && pnpm run build' 17 | 18 | # Stage 2: Serve 19 | FROM nginx:alpine 20 | 21 | # Copy built app from the 'build' stage to the nginx HTML directory 22 | COPY --from=build /app/client-web/dist /usr/share/nginx/html 23 | 24 | # Expose port 80 (default port for nginx) 25 | EXPOSE 80 26 | 27 | # The command to run when the container starts 28 | CMD ["nginx", "-g", "daemon off;"] 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Franz Geffke 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /client-web/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:react-hooks/recommended', 8 | ], 9 | ignorePatterns: ['dist', '.eslintrc.cjs'], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['react-refresh'], 12 | rules: { 13 | 'react-refresh/only-export-components': [ 14 | 'warn', 15 | { allowConstantExport: true }, 16 | ], 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /client-web/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /client-web/README.md: -------------------------------------------------------------------------------- 1 | # Nostr Client 2 | 3 | Nostr web client built with React. 4 | 5 | - Relies on IndexedDB and local storage for data and accounts 6 | - implements `@nostr-ts/common` and `@nostr-ts/web` 7 | 8 | Initial support for `nos2x` and any other extention following NIP-07 is available. 9 | 10 | ## Setup 11 | 12 | Install: 13 | 14 | ```bash 15 | pnpm install 16 | ``` 17 | 18 | Run: 19 | 20 | ```bash 21 | pnpm run dev 22 | ``` 23 | 24 | ### Production 25 | 26 | There's a Dockerfile and docker-compose.yml in the root of the repository. It builds all packages, then client-web, and uses nginx to serve. 27 | 28 | ```bash 29 | pnpm run build 30 | ``` 31 | 32 | Serve the `./dist` directory. 33 | 34 | ### Docker 35 | 36 | ```bash 37 | cd ../ 38 | docker-compose build 39 | docker-compose up 40 | ``` 41 | 42 | Open [http://127.0.0.1:4000/](vhttp://127.0.0.1:4000/) in your browser. 43 | 44 | ## Preview 45 | 46 | Here's an early preview. I'll update it occasionally. 47 | 48 | ![Preview](./preview.png) 49 | 50 | ## Licenses 51 | 52 | -`./public/no-image.png`: No Image by Ashwini Sukhdeve from Noun Project (CC BY 3.0) -------------------------------------------------------------------------------- /client-web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | NostrOP 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /client-web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client-web", 3 | "version": "0.0.5", 4 | "description": "Nostr react client", 5 | "author": "Franz Geffke ", 6 | "license": "MIT", 7 | "type": "module", 8 | "scripts": { 9 | "dev": "vite", 10 | "build": "tsc && vite build", 11 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 12 | "preview": "vite preview" 13 | }, 14 | "dependencies": { 15 | "@chakra-ui/react": "^2.10.6", 16 | "@emotion/react": "^11.14.0", 17 | "@emotion/styled": "^11.14.0", 18 | "@nostr-ts/common": "workspace:*", 19 | "@nostr-ts/web": "workspace:*", 20 | "boring-avatars": "^1.11.2", 21 | "comlink": "^4.4.2", 22 | "framer-motion": "^10.18.0", 23 | "idb": "^7.1.1", 24 | "mdi-react": "^9.4.0", 25 | "nanoid": "^3.3.8", 26 | "qrcode.react": "^3.2.0", 27 | "react": "^18.3.1", 28 | "react-dom": "^18.3.1", 29 | "react-inlinesvg": "^4.2.0", 30 | "react-player": "^2.16.0", 31 | "react-qr-reader": "3.0.0-beta-1", 32 | "react-router-dom": "^6.30.0", 33 | "react-slick": "^0.29.0", 34 | "react-virtuoso": "^4.12.5", 35 | "slick-carousel": "^1.8.1", 36 | "zustand": "^4.5.6" 37 | }, 38 | "devDependencies": { 39 | "@types/react": "^18.3.18", 40 | "@types/react-dom": "^18.3.5", 41 | "@types/react-slick": "^0.23.13", 42 | "@typescript-eslint/eslint-plugin": "^6.21.0", 43 | "@typescript-eslint/parser": "^6.21.0", 44 | "@vitejs/plugin-react-swc": "^3.8.0", 45 | "eslint": "^8.57.1", 46 | "eslint-plugin-react-hooks": "^4.6.2", 47 | "eslint-plugin-react-refresh": "^0.4.19", 48 | "typescript": "^5.8.2", 49 | "vite": "^4.5.9", 50 | "vite-plugin-pwa": "^0.16.7" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /client-web/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/franzos/nostr-ts/9e4407193e178aeef179c42b74221cc6b31fcf42/client-web/preview.png -------------------------------------------------------------------------------- /client-web/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/franzos/nostr-ts/9e4407193e178aeef179c42b74221cc6b31fcf42/client-web/public/apple-touch-icon.png -------------------------------------------------------------------------------- /client-web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/franzos/nostr-ts/9e4407193e178aeef179c42b74221cc6b31fcf42/client-web/public/favicon.ico -------------------------------------------------------------------------------- /client-web/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/franzos/nostr-ts/9e4407193e178aeef179c42b74221cc6b31fcf42/client-web/public/logo.png -------------------------------------------------------------------------------- /client-web/public/no-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/franzos/nostr-ts/9e4407193e178aeef179c42b74221cc6b31fcf42/client-web/public/no-image.png -------------------------------------------------------------------------------- /client-web/public/pwa-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/franzos/nostr-ts/9e4407193e178aeef179c42b74221cc6b31fcf42/client-web/public/pwa-192x192.png -------------------------------------------------------------------------------- /client-web/public/pwa-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/franzos/nostr-ts/9e4407193e178aeef179c42b74221cc6b31fcf42/client-web/public/pwa-512x512.png -------------------------------------------------------------------------------- /client-web/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / -------------------------------------------------------------------------------- /client-web/src/components/accounts/list.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Accordion, 3 | AccordionItem, 4 | AccordionButton, 5 | AccordionIcon, 6 | AccordionPanel, 7 | Box, 8 | Text, 9 | } from "@chakra-ui/react"; 10 | import { SatteliteCDN } from "./sattelite-cdn"; 11 | 12 | export function AccountsList() { 13 | return ( 14 | 15 | 16 |

17 | 18 | 19 | Sattelite CDN 20 | Pay as you go data storage 21 | 22 | 23 | 24 |

25 | 26 | 27 | 28 |
29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /client-web/src/components/accounts/sattelite-cdn-account.tsx: -------------------------------------------------------------------------------- 1 | import { List, ListItem, ListIcon, Text } from "@chakra-ui/react"; 2 | import { SCDNAccountInfo } from "@nostr-ts/web"; 3 | import AccountCreditCardOutlineIcon from "mdi-react/AccountCreditCardOutlineIcon"; 4 | import DatabaseIcon from "mdi-react/DatabaseIcon"; 5 | import TimerSandCompleteIcon from "mdi-react/TimerSandCompleteIcon"; 6 | import { bytesToGB } from "../../lib/bytesToGB"; 7 | import { roundToDecimal } from "../../lib/round"; 8 | 9 | interface SatteliteCDNAccountProps { 10 | account: SCDNAccountInfo; 11 | } 12 | 13 | export function SatteliteCDNAccount({ account }: SatteliteCDNAccountProps) { 14 | const hasCreditButNoFiles = 15 | account.creditTotal > 0 && account.usageTotal === 0; 16 | 17 | return ( 18 | 19 | {account.usageTotal > 0 ? ( 20 | 21 | 22 | Credit: {account.usageTotal.toFixed(10)} GB / {account.creditTotal} GB 23 | 24 | ) : ( 25 | 26 | 27 | Credit: {account.creditTotal} GB 28 | 29 | )} 30 | {hasCreditButNoFiles ? ( 31 | 32 | You haven't uploaded anything yet. 33 | 34 | ) : ( 35 | <> 36 | 37 | 38 | Usage: {roundToDecimal(bytesToGB(account.storageTotal), 5)} GB 39 | 40 | 41 | 42 | Expires: {new Date( 43 | account.paidThrough * 1000 44 | ).toLocaleDateString()}{" "} 45 | ({Math.round(account.timeRemaining)} days left) 46 | 47 | 48 | )} 49 | 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /client-web/src/components/accounts/sattelite-cdn-payment-modal.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Modal, 3 | ModalBody, 4 | ModalCloseButton, 5 | ModalContent, 6 | ModalHeader, 7 | ModalOverlay, 8 | } from "@chakra-ui/react"; 9 | import { 10 | SatteliteCDNAddCredit, 11 | SatteliteCDNAddCreditProps, 12 | } from "./sattelite-cdn-payment"; 13 | 14 | interface SatteliteCDNAddCreditModalProps extends SatteliteCDNAddCreditProps { 15 | isOpen: boolean; 16 | } 17 | 18 | export function SatteliteCDNAddCreditModal({ 19 | isOpen, 20 | onCancel, 21 | onComplete, 22 | }: SatteliteCDNAddCreditModalProps) { 23 | return ( 24 | 25 | 26 | 27 | Add Credit 28 | 29 | 30 | 31 | 32 | 33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /client-web/src/components/create-event-form-modal.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useDisclosure, 3 | Modal, 4 | ModalOverlay, 5 | ModalContent, 6 | ModalHeader, 7 | ModalCloseButton, 8 | ModalBody, 9 | ModalFooter, 10 | Button, 11 | Icon, 12 | } from "@chakra-ui/react"; 13 | import { CreateEventForm } from "./create-event-form"; 14 | import SendIcon from "mdi-react/SendIcon"; 15 | 16 | interface EventFormModalProps { 17 | buttonSize: string; 18 | } 19 | 20 | export function EventFormModal(props?: EventFormModalProps) { 21 | const { isOpen, onOpen, onClose } = useDisclosure(); 22 | 23 | return ( 24 | <> 25 | 32 | 33 | 34 | 35 | Broadcast to the Network 36 | 37 | 38 | 39 | 40 | 41 | 42 | 45 | 46 | 47 | 48 | 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /client-web/src/components/event/action-buttons.tsx: -------------------------------------------------------------------------------- 1 | import { HStack, Button, Icon } from "@chakra-ui/react"; 2 | import { ReactionsCount } from "@nostr-ts/common"; 3 | import CurrencyBtcIcon from "mdi-react/CurrencyBtcIcon"; 4 | import RepeatIcon from "mdi-react/RepeatIcon"; 5 | import ReplyIcon from "mdi-react/ReplyIcon"; 6 | import ThumbDownIcon from "mdi-react/ThumbDownIcon"; 7 | import ThumbUpIcon from "mdi-react/ThumbUpIcon"; 8 | 9 | export interface EventActionButtonsProps { 10 | isReady: boolean; 11 | level: number; 12 | 13 | repliesCount: number; 14 | reactionsCount: ReactionsCount; 15 | repostCount: number; 16 | zapReceiptCount: number; 17 | zapReceiptAmount: number; 18 | 19 | isReplyOpen: boolean; 20 | onReplyOpen: () => void; 21 | onReplyClose: () => void; 22 | onAction: (type: "quote" | "reaction" | "zap", reaction?: string) => void; 23 | } 24 | 25 | export function EventActionButtons({ 26 | isReady, 27 | level, 28 | repliesCount, 29 | reactionsCount, 30 | repostCount, 31 | zapReceiptCount, 32 | zapReceiptAmount, 33 | isReplyOpen, 34 | onReplyOpen, 35 | onReplyClose, 36 | onAction, 37 | }: EventActionButtonsProps) { 38 | return ( 39 | 40 | 51 | 62 | 73 | 84 | 95 | 96 | ); 97 | } 98 | -------------------------------------------------------------------------------- /client-web/src/components/event/banner.tsx: -------------------------------------------------------------------------------- 1 | import ReactPlayer from "react-player"; 2 | import { Button, Box, Image, Text, AspectRatio } from "@chakra-ui/react"; 3 | import { Slideshow } from "./image-slideshow"; 4 | import { useState } from "react"; 5 | 6 | interface ImageProps { 7 | src: string; 8 | alt?: string; 9 | } 10 | 11 | const EventImage = ({ src, alt }: ImageProps) => { 12 | return ( 13 | 18 | Image failed to load. 19 | 20 | } 21 | fallbackStrategy="onError" 22 | alt={alt} 23 | /> 24 | ); 25 | }; 26 | 27 | interface ImagesProps { 28 | images: string[]; 29 | } 30 | 31 | const Images = ({ images }: ImagesProps) => { 32 | return ( 33 | <> 34 | {images?.length === 1 ? ( 35 | 36 | ) : ( 37 | 38 | )} 39 | 40 | ); 41 | }; 42 | 43 | interface VideoProps { 44 | url: string; 45 | } 46 | 47 | const EventVideo = (props: VideoProps) => { 48 | const [loadVideo, setLoadVideo] = useState(false); 49 | const domain = new URL(props.url).hostname; 50 | const canPlay = ReactPlayer.canPlay(props.url); 51 | 52 | return ( 53 | <> 54 | {loadVideo ? ( 55 | 56 | 63 | Video failed to load. 64 | 65 | } 66 | fallbackStrategy="onError" 67 | /> 68 | 69 | ) : ( 70 | 80 | )} 81 | 82 | ); 83 | }; 84 | 85 | interface VideosProps { 86 | videos: string[]; 87 | } 88 | 89 | const Videos = ({ videos }: VideosProps) => { 90 | return ( 91 | <> 92 | {videos.map((url, index) => ( 93 | 94 | ))} 95 | 96 | ); 97 | }; 98 | 99 | interface EventBannerProps { 100 | images: string[] | undefined; 101 | videos: string[] | undefined; 102 | } 103 | 104 | export function EventBanner({ images, videos }: EventBannerProps) { 105 | return ( 106 | <> 107 | {images && } 108 | {videos && } 109 | 110 | ); 111 | } 112 | -------------------------------------------------------------------------------- /client-web/src/components/event/clickable-links.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "@chakra-ui/react"; 2 | import { Link as RouterLink } from "react-router-dom"; 3 | import { OnDemandEvent } from "../on-demand-event"; 4 | import { OnDemandUsername } from "../on-demand-username"; 5 | import { LinkPreview } from "./link-preview"; 6 | 7 | interface EventContentWithLinksProps { 8 | text: string; 9 | linkPreviewProxyUrl?: string; 10 | } 11 | 12 | export function EventContentWithLinks({ 13 | text, 14 | linkPreviewProxyUrl, 15 | }: EventContentWithLinksProps) { 16 | if (!text) return null; 17 | 18 | const urlRegex = /(https?:\/\/[^\s]+)/g; 19 | const noteRegex = /(?:nostr:)?(note[0-9a-zA-Z]+|nevent[0-9a-zA-Z]+)/g; 20 | const profileRegex = /(?:nostr:)?(npub[0-9a-zA-Z]+|nprofile[0-9a-zA-Z]+)/g; 21 | const tagsRegex = /#[a-zA-Z0-9]+/g; 22 | 23 | const tokens = text.split( 24 | /(https?:\/\/[^\s]+|(?:nostr:)?note[0-9a-zA-Z]+|(?:nostr:)?npub[0-9a-zA-Z]+|(?:nostr:)?nprofile[0-9a-zA-Z]+|(?:nostr:)?nevent[0-9a-zA-Z]+|#[a-zA-Z0-9]+)/g 25 | ); 26 | 27 | return ( 28 | <> 29 | {tokens.map((token, index) => { 30 | if (urlRegex.test(token)) { 31 | return ( 32 | 37 | ); 38 | } 39 | if (noteRegex.test(token)) { 40 | const noteId = token.split(":").pop(); 41 | return ( 42 | noteId && 43 | ); 44 | } 45 | if (profileRegex.test(token)) { 46 | const profileId = token.split(":").pop(); 47 | return ; 48 | } 49 | if (tagsRegex.test(token)) { 50 | return ( 51 | 57 | {token} 58 | 59 | ); 60 | } 61 | return token; 62 | })} 63 | 64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /client-web/src/components/event/content.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from "@chakra-ui/react"; 2 | import { EventContentWithLinks } from "./clickable-links"; 3 | 4 | interface EventContentProps { 5 | content: string | undefined; 6 | linkPreviewProxyUrl?: string; 7 | } 8 | 9 | export function EventContent({ 10 | content, 11 | linkPreviewProxyUrl, 12 | }: EventContentProps) { 13 | return ( 14 | <> 15 | {content && content !== "" && ( 16 | 27 | 31 | 32 | )} 33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /client-web/src/components/event/image-slideshow.tsx: -------------------------------------------------------------------------------- 1 | import Slider from "react-slick"; 2 | import { Image, Box } from "@chakra-ui/react"; 3 | 4 | import "slick-carousel/slick/slick.css"; 5 | import "slick-carousel/slick/slick-theme.css"; 6 | 7 | interface SlideshowProps { 8 | images: string[]; 9 | } 10 | 11 | export function Slideshow({ images }: SlideshowProps) { 12 | const settings = { 13 | dots: true, 14 | infinite: true, 15 | speed: 500, 16 | slidesToShow: 1, 17 | slidesToScroll: 1, 18 | adaptiveHeight: true, 19 | }; 20 | return ( 21 | 22 | {images.map((image, index) => ( 23 | 24 | 25 | 26 | ))} 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /client-web/src/components/event/info-modal.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Modal, 3 | ModalOverlay, 4 | ModalContent, 5 | ModalHeader, 6 | ModalCloseButton, 7 | ModalBody, 8 | Box, 9 | Text, 10 | Link, 11 | } from "@chakra-ui/react"; 12 | import { LightProcessedEvent } from "@nostr-ts/common"; 13 | import { Link as RouterLink } from "react-router-dom"; 14 | 15 | interface EventInfoModalProps { 16 | data: LightProcessedEvent; 17 | nEventString: string; 18 | isOpen: boolean; 19 | onClose: () => void; 20 | } 21 | 22 | export function EventInfoModal({ 23 | data, 24 | nEventString, 25 | isOpen, 26 | onClose, 27 | }: EventInfoModalProps) { 28 | const eventLink = `${window.location.origin}/e/${nEventString}`; 29 | 30 | const relay = data.eventRelayUrls 31 | ? data.eventRelayUrls[0] 32 | : "Unknown (from local DB)"; 33 | 34 | return ( 35 | 36 | 37 | 38 | Event 39 | 40 | 41 | 42 | Link:{" "} 43 | 44 | {eventLink} 45 | 46 | 47 | Relay: {relay} 48 | 49 |
50 |               {JSON.stringify(data.event, null, 2)}
51 |             
52 |
53 |
54 |
55 |
56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /client-web/src/components/event/link-preview.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Heading, 3 | Text, 4 | Image, 5 | Box, 6 | LinkBox, 7 | LinkOverlay, 8 | Flex, 9 | Link, 10 | } from "@chakra-ui/react"; 11 | import { useEffect, useState } from "react"; 12 | import { excerpt } from "../../lib/excerpt"; 13 | 14 | const fetchProxyData = async (url: string, proxyUrl?: string) => { 15 | if (proxyUrl === undefined) { 16 | throw new Error("Proxy URL is not defined"); 17 | } 18 | 19 | const response = await fetch(`${proxyUrl}${url}`); 20 | 21 | if (!response.ok) { 22 | throw new Error("Failed to fetch data"); 23 | } 24 | 25 | const data = await response.json(); 26 | 27 | if (data.error) { 28 | throw new Error(data.error); 29 | } 30 | 31 | return { 32 | title: data.title || null, 33 | description: data.description || null, 34 | image: data.image || null, 35 | siteName: data.siteName || null, 36 | hostname: data.hostname || null, 37 | }; 38 | }; 39 | 40 | interface LinkPreviewProps { 41 | url: string; 42 | proxyUrl?: string; 43 | } 44 | 45 | export function LinkPreview({ url, proxyUrl }: LinkPreviewProps) { 46 | const [isLoading, setIsLoading] = useState(true); 47 | const [hasError, setHasError] = useState(false); 48 | const [data, setData] = useState<{ 49 | title: string | null; 50 | description: string | null; 51 | image: string | null; 52 | siteName: string | null; 53 | hostname: string | null; 54 | } | null>(null); 55 | 56 | const fetchData = async () => { 57 | if (proxyUrl === undefined) { 58 | return; 59 | } 60 | setIsLoading(true); 61 | try { 62 | const result = await fetchProxyData(url, proxyUrl); 63 | setData(result); 64 | } catch (err) { 65 | setHasError(true); 66 | } 67 | setIsLoading(false); 68 | }; 69 | 70 | useEffect(() => { 71 | fetchData(); 72 | }, []); 73 | 74 | return ( 75 | <> 76 | {!isLoading && !hasError && data ? ( 77 | 78 | 79 | {data.image && ( 80 | 81 | 82 | 83 | )} 84 | 85 | 86 | {data.title} 87 | 88 | {data.description && ( 89 | {excerpt(data.description, 160)} 90 | )} 91 | 92 | {data.siteName && `${data.siteName}, `} 93 | {data.hostname} 94 | 95 | 96 | 97 | 98 | ) : ( 99 | {url} 100 | )} 101 | 102 | ); 103 | } 104 | -------------------------------------------------------------------------------- /client-web/src/components/event/nsfw-toggle.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@chakra-ui/react"; 2 | 3 | interface NSFWContentToggleProps { 4 | contentWarning: string | undefined; 5 | setShowNSFWContent: (show: boolean) => void; 6 | } 7 | 8 | export const NSFWContentToggle = ({ 9 | contentWarning, 10 | setShowNSFWContent, 11 | }: NSFWContentToggleProps) => { 12 | return ( 13 | 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /client-web/src/components/event/user.tsx: -------------------------------------------------------------------------------- 1 | import { LightProcessedEvent } from "@nostr-ts/common"; 2 | import { User } from "../user"; 3 | 4 | interface EventUserProps { 5 | data: LightProcessedEvent; 6 | } 7 | 8 | export function EventUser({ data }: EventUserProps) { 9 | return ( 10 | <> 11 | {data.user && data.user.pubkey ? ( 12 | 20 | ) : ( 21 | 31 | )} 32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /client-web/src/components/event/zap-modal.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | Modal, 4 | ModalBody, 5 | ModalCloseButton, 6 | ModalContent, 7 | ModalFooter, 8 | ModalHeader, 9 | ModalOverlay, 10 | } from "@chakra-ui/react"; 11 | import { Zap } from "./zap"; 12 | import { UserBase, EventBaseSigned } from "@nostr-ts/common"; 13 | 14 | interface ZapModalProps { 15 | user: UserBase; 16 | relatedEvent?: EventBaseSigned; 17 | isOpen: boolean; 18 | onClose: () => void; 19 | } 20 | 21 | export const ZapModal = ({ 22 | user, 23 | relatedEvent, 24 | isOpen, 25 | onClose, 26 | }: ZapModalProps) => { 27 | return ( 28 | 29 | 30 | 31 | Send sats (WIP) 32 | 33 | 34 | 39 | 40 | 41 | 44 | 45 | 46 | 47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /client-web/src/components/events.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | import { Box, Button } from "@chakra-ui/react"; 3 | import { useNClient } from "../state/client"; 4 | import { Event } from "../components/event"; 5 | import { Virtuoso } from "react-virtuoso"; 6 | 7 | interface EventsProps { 8 | view: string; 9 | changingView?: boolean; 10 | } 11 | 12 | export function Events({ view, changingView }: EventsProps) { 13 | const [events, eventsNewerCount] = useNClient((state) => [ 14 | state.events[view] || [], 15 | state.eventsNewer[view]?.length || 0, 16 | ]); 17 | const throttleTimestamp = useRef(Date.now()); 18 | const linkPreviewProxyUrl = 19 | localStorage.getItem("linkPreviewProxyUrl") || undefined; 20 | 21 | const loadEvents = async () => { 22 | if ( 23 | throttleTimestamp.current > Date.now() - 2000 || 24 | changingView === true 25 | ) { 26 | return; 27 | } 28 | throttleTimestamp.current = Date.now(); 29 | 30 | const nextQuery = useNClient.getState().nextQuery; 31 | if ( 32 | nextQuery && 33 | nextQuery.next && 34 | nextQuery.next.reqCount && 35 | nextQuery.next.reqCount > 2 && 36 | useNClient.getState().events[view] && 37 | useNClient.getState().events[view].length < 10 38 | ) { 39 | await useNClient.getState().getEvents( 40 | { 41 | token: nextQuery.token, 42 | query: { 43 | ...nextQuery.next, 44 | filters: { 45 | ...nextQuery.next.filters, 46 | until: Math.round(Date.now() / 1000), 47 | since: Math.round(Date.now() / 1000) - 30 * 24 * 60 * 60, 48 | }, 49 | }, 50 | }, 51 | "replace" 52 | ); 53 | } else { 54 | await useNClient.getState().getEvents(); 55 | } 56 | }; 57 | 58 | useEffect(() => { 59 | if (useNClient.getState().nextQuery) { 60 | loadEvents(); 61 | } 62 | }, []); 63 | 64 | const mergeNewerEvents = () => { 65 | useNClient.getState().mergeNewerEvents(view); 66 | }; 67 | 68 | return ( 69 | <> 70 | {eventsNewerCount > 0 && ( 71 | 81 | )} 82 | ( 86 | 87 | 93 | 94 | )} 95 | endReached={() => { 96 | loadEvents(); 97 | }} 98 | /> 99 | 100 | ); 101 | } 102 | -------------------------------------------------------------------------------- /client-web/src/components/file-input.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Input, 4 | useMultiStyleConfig, 5 | InputProps, 6 | IconButton, 7 | Icon, 8 | Tooltip, 9 | } from "@chakra-ui/react"; 10 | import FileUploadIcon from "mdi-react/FileUploadIcon"; 11 | 12 | interface FileInputProps extends InputProps { 13 | onSelection: (files: FileList) => void; 14 | } 15 | 16 | export const FileInput = (props: FileInputProps) => { 17 | const styles = useMultiStyleConfig("Button", { variant: "outline" }); 18 | 19 | const handleFileChange = (e: React.ChangeEvent) => { 20 | if (e.target.files) { 21 | props.onSelection(e.target.files); 22 | } 23 | }; 24 | 25 | return ( 26 | <> 27 | {props.isDisabled ? ( 28 | 32 | } 34 | variant="outline" 35 | zIndex="0" 36 | {...styles} 37 | aria-label="File upload" 38 | isDisabled={true} 39 | /> 40 | 41 | ) : ( 42 | 43 | 57 | } 59 | variant="outline" 60 | zIndex="0" 61 | {...styles} 62 | aria-label="File upload" 63 | /> 64 | 65 | )} 66 | 67 | ); 68 | }; 69 | -------------------------------------------------------------------------------- /client-web/src/components/file-upload.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | HStack, 3 | Icon, 4 | IconButton, 5 | Spacer, 6 | Text, 7 | useToast, 8 | } from "@chakra-ui/react"; 9 | import { sCDNAccountUploadRequest, sCDNUploadFile } from "@nostr-ts/web"; 10 | import { useNClient } from "../state/client"; 11 | import { useState } from "react"; 12 | import { toastErrorContent, toastSuccessContent } from "../lib/toast"; 13 | import TrashCanIcon from "mdi-react/TrashCanIcon"; 14 | import UploadIcon from "mdi-react/UploadIcon"; 15 | import FileOutlineIcon from "mdi-react/FileOutlineIcon"; 16 | import CheckCircleOutlineIcon from "mdi-react/CheckCircleOutlineIcon"; 17 | import CircleOutlineIcon from "mdi-react/CircleOutlineIcon"; 18 | 19 | interface FilUploadProps { 20 | file: File; 21 | onUploadDone: (result: { url: string; nip94?: string[][] }) => void; 22 | onRemove: (file: File) => void; 23 | } 24 | 25 | export function FileUpload({ file, onUploadDone, onRemove }: FilUploadProps) { 26 | const [isLoading, setIsLoading] = useState(false); 27 | const [isUploadDone, setIsUploadDone] = useState(false); 28 | const toast = useToast(); 29 | 30 | const upload = async () => { 31 | setIsLoading(true); 32 | try { 33 | const req = sCDNAccountUploadRequest(file.name); 34 | const signedReq = await useNClient.getState().signEvent(req); 35 | 36 | const result = await sCDNUploadFile(signedReq, file); 37 | if (result) { 38 | setIsUploadDone(true); 39 | onUploadDone(result); 40 | toast(toastSuccessContent(`Uploaded ${file.name}`)); 41 | } 42 | } catch (err) { 43 | console.error(err); 44 | toast(toastErrorContent(err as Error)); 45 | } 46 | setIsLoading(false); 47 | }; 48 | return ( 49 | 50 | {isUploadDone ? ( 51 | 52 | ) : ( 53 | 54 | )} 55 | 56 | {file.name} 57 | 58 | } 60 | onClick={upload} 61 | isLoading={isLoading} 62 | isDisabled={isUploadDone} 63 | aria-label="Upload" 64 | size="sm" 65 | > 66 | Upload 67 | 68 | } 70 | onClick={() => onRemove(file)} 71 | isLoading={isLoading} 72 | aria-label="Remove" 73 | size="sm" 74 | /> 75 | 76 | ); 77 | } 78 | -------------------------------------------------------------------------------- /client-web/src/components/integration.tsx: -------------------------------------------------------------------------------- 1 | import { StorageIntegrationProvider } from "../lib/integrations"; 2 | import { Text, Icon, HStack } from "@chakra-ui/react"; 3 | import CheckCircleIcon from "mdi-react/CheckCircleIcon"; 4 | 5 | interface IntegrationProps { 6 | integration: StorageIntegrationProvider; 7 | } 8 | 9 | export function Integration({ integration }: IntegrationProps) { 10 | return ( 11 | 12 | 13 | Sattelite CDN: {integration.credit}GB 14 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /client-web/src/components/list-selection.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { useNClient } from "../state/client"; 3 | import { RadioGroup, Stack, Radio } from "@chakra-ui/react"; 4 | 5 | export interface ListsProps { 6 | showFollowing: boolean; 7 | showMentions: boolean; 8 | changeFeed: (value: string) => void; 9 | } 10 | 11 | export function ListSelection({ 12 | showFollowing, 13 | showMentions, 14 | changeFeed, 15 | }: ListsProps) { 16 | const [lists, setLists] = useState< 17 | { 18 | id: string; 19 | title: string; 20 | }[] 21 | >([]); 22 | 23 | const [activeListId, setActiveListId] = useState("global"); 24 | 25 | const onMount = async () => { 26 | const lists = await useNClient.getState().getAllLists(); 27 | if (lists) { 28 | setLists( 29 | lists.map((item) => ({ 30 | id: item.id, 31 | title: item.title, 32 | })) 33 | ); 34 | } 35 | }; 36 | 37 | const onUnmount = () => { 38 | setLists([]); 39 | }; 40 | 41 | const onChange = (activeListId: string) => { 42 | setActiveListId(activeListId); 43 | changeFeed(activeListId); 44 | }; 45 | 46 | useEffect(() => { 47 | setTimeout(() => { 48 | onMount(); 49 | }, 100); 50 | return onUnmount; 51 | }, []); 52 | 53 | return ( 54 | 55 | 56 | Global 57 | {showFollowing && Following} 58 | {showMentions && Mentions} 59 | {lists && 60 | lists.length > 0 && 61 | lists.map((list) => ( 62 | 63 | {list.title} 64 | 65 | ))} 66 | 67 | 68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /client-web/src/components/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Spinner, Box, Text } from "@chakra-ui/react"; 2 | 3 | interface LoadingProps { 4 | text?: string; 5 | } 6 | 7 | export const Loading = ({ text }: LoadingProps) => { 8 | const defaultText = text ? text : "Just a sec ... Searching the Matrix."; 9 | return ( 10 | 11 | {defaultText} 12 | 13 | 14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /client-web/src/components/menu-item.tsx: -------------------------------------------------------------------------------- 1 | import { HStack, Text, Link as ChakraLink } from "@chakra-ui/react"; 2 | import { ReactElement } from "react"; 3 | import { NavLink as ReactRouterLink } from "react-router-dom"; 4 | 5 | export function MenuItem({ 6 | label, 7 | value, 8 | to, 9 | leftIcon, 10 | }: { 11 | label: string; 12 | value?: string | number; 13 | to: string; 14 | leftIcon?: ReactElement; 15 | }) { 16 | return ( 17 | 33 | 34 | {leftIcon} 35 | 36 | {label} 37 | 38 | {value && {value}} 39 | 40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /client-web/src/components/on-demand-event.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react"; 2 | import { useNClient } from "../state/client"; 3 | import { decodeBech32 } from "@nostr-ts/common"; 4 | import { Box, Text } from "@chakra-ui/react"; 5 | import { Event } from "./event"; 6 | 7 | interface OnDemandEventProps { 8 | note: string; 9 | index: number; 10 | } 11 | 12 | export function OnDemandEvent({ note, index }: OnDemandEventProps) { 13 | const MAX_RETRIES = 20; 14 | const RETRY_INTERVAL = 1000; 15 | 16 | const eventId = useRef(""); 17 | const relayUrls = useRef([]); 18 | 19 | const [connected] = useNClient((state) => [state.connected]); 20 | const [eventData] = useNClient((state) => [ 21 | state.events[note] ? state.events[note][0] : null, 22 | ]); 23 | const [hasTimeout, setHasTimeout] = useState(false); 24 | 25 | const fetchEvent = async (retryCount = 0) => { 26 | if (retryCount > MAX_RETRIES) { 27 | setHasTimeout(true); 28 | return; 29 | } 30 | 31 | const event = await useNClient.getState().getEvent(eventId.current, { 32 | view: note, 33 | retryCount, 34 | relayUrls: relayUrls.current, 35 | }); 36 | 37 | if (!event) { 38 | setTimeout(() => fetchEvent(retryCount + 1), RETRY_INTERVAL); 39 | } 40 | }; 41 | 42 | useEffect(() => { 43 | // Decode note 44 | try { 45 | const decoded = decodeBech32(note); 46 | for (const item of decoded.tlvItems) { 47 | if (item.type === 0) { 48 | eventId.current = item.value as string; 49 | } else if (item.type === 1) { 50 | relayUrls.current.push(item.value as string); 51 | } 52 | } 53 | } catch (e) { 54 | console.error(e); 55 | return; 56 | } 57 | 58 | if (note && connected && (!index || index <= 2)) { 59 | fetchEvent(); 60 | } 61 | 62 | // return () => { 63 | // useNClient.getState().unsubscribeByToken(note); 64 | // }; 65 | }, [note, index, connected]); 66 | 67 | return ( 68 | 69 | {eventData ? ( 70 | 71 | ) : hasTimeout ? ( 72 | 73 | Couldn't find {note}. 74 | 75 | ) : ( 76 | 77 | Loading ... {note} 78 | 79 | )} 80 | 81 | ); 82 | } 83 | -------------------------------------------------------------------------------- /client-web/src/components/on-demand-username.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "@chakra-ui/react"; 2 | import { NavLink } from "react-router-dom"; 3 | import { useNClient } from "../state/client"; 4 | import { UserRecord, decodeBech32 } from "@nostr-ts/common"; 5 | import { useEffect, useRef, useState } from "react"; 6 | import { excerpt } from "../lib/excerpt"; 7 | 8 | interface OnDemandUsernameProps { 9 | npub?: string; 10 | } 11 | 12 | export function OnDemandUsername({ npub }: OnDemandUsernameProps) { 13 | const [status] = useNClient((state) => [state.status]); 14 | 15 | const pubkey = useRef(null); 16 | const [username, setUsername] = useState(""); 17 | const [profileLink, setProfileLink] = useState(""); 18 | const [isLoadingUser, setIsLoadingUser] = useState(false); 19 | 20 | const usernameFromUser = (record: UserRecord) => { 21 | const data = record.user.data; 22 | return data && data.display_name ? `@${data.display_name}` : ""; 23 | }; 24 | 25 | const fetchUser = async (pk: string, retryCount: number = 0) => { 26 | if (retryCount > 20) { 27 | setIsLoadingUser(false); 28 | return; 29 | } 30 | 31 | const user = await useNClient.getState().getUser(pk); 32 | 33 | if (user) { 34 | setUsername(usernameFromUser(user)); 35 | setIsLoadingUser(false); 36 | } else { 37 | if (retryCount === 2) { 38 | await useNClient.getState().requestInformation( 39 | { 40 | idsOrKeys: [pk], 41 | source: "users", 42 | }, 43 | { timeoutIn: 10000 } 44 | ); 45 | } 46 | 47 | setTimeout(() => fetchUser(pk, retryCount + 1), 1000); 48 | } 49 | }; 50 | 51 | useEffect(() => { 52 | if (!npub || !["online", "offline"].includes(status)) return; 53 | 54 | try { 55 | const decoded = decodeBech32(npub); 56 | const pkItem = decoded.tlvItems.find((item) => item.type === 0); 57 | 58 | if (pkItem) { 59 | pubkey.current = pkItem.value as string; 60 | setProfileLink(`/p/${npub}`); 61 | setIsLoadingUser(true); 62 | fetchUser(pubkey.current); 63 | } 64 | } catch (e) { 65 | console.error("Error decoding bech32:", e); 66 | } 67 | }, [status, npub]); 68 | 69 | return ( 70 | <> 71 | {npub ? ( 72 | 73 | {username === "" || isLoadingUser ? excerpt(npub, 10) : username} 74 | 75 | ) : ( 76 | <>... 77 | )} 78 | 79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /client-web/src/components/qrcode.tsx: -------------------------------------------------------------------------------- 1 | import { BECH32_PREFIX, encodeBech32 } from "@nostr-ts/common"; 2 | import { 3 | Icon, 4 | IconButton, 5 | Input, 6 | Modal, 7 | ModalBody, 8 | ModalCloseButton, 9 | ModalContent, 10 | ModalHeader, 11 | ModalOverlay, 12 | useDisclosure, 13 | Box, 14 | } from "@chakra-ui/react"; 15 | import { QRCodeSVG } from "qrcode.react"; 16 | import QrcodeIcon from "mdi-react/QrcodeIcon"; 17 | 18 | interface QRCodeProps { 19 | kind: 20 | | BECH32_PREFIX.PublicKeys 21 | | BECH32_PREFIX.PrivateKeys 22 | | BECH32_PREFIX.Profile; 23 | value: string; 24 | size?: "xs" | "sm" | "md" | "lg" | "xl"; 25 | } 26 | 27 | const labels = [ 28 | { 29 | kind: BECH32_PREFIX.PublicKeys, 30 | label: "Public Key", 31 | }, 32 | { 33 | kind: BECH32_PREFIX.PrivateKeys, 34 | label: "Private Key", 35 | }, 36 | { 37 | kind: BECH32_PREFIX.Profile, 38 | label: "Profile", 39 | }, 40 | ]; 41 | 42 | export function QRCodeModal({ kind, value, size }: QRCodeProps) { 43 | (""); 44 | const { isOpen, onOpen, onClose } = useDisclosure(); 45 | 46 | const label = labels.find((l) => l.kind === kind)?.label || "Unknown"; 47 | const tlvItems = [ 48 | { 49 | type: 0, 50 | value, 51 | }, 52 | ]; 53 | const encoded = encodeBech32(kind, tlvItems); 54 | 55 | return ( 56 | <> 57 | } 59 | onClick={isOpen ? onClose : onOpen} 60 | aria-label="QR" 61 | size={size || "md"} 62 | /> 63 | 64 | 65 | 66 | 67 | {label} 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | ); 79 | } 80 | -------------------------------------------------------------------------------- /client-web/src/components/relay-selection.tsx: -------------------------------------------------------------------------------- 1 | import { HStack, Text, Spacer, Checkbox, Box, Icon } from "@chakra-ui/react"; 2 | import CircleOutlineIcon from "mdi-react/CircleOutlineIcon"; 3 | import CircleSlice8Icon from "mdi-react/CircleSlice8Icon"; 4 | import SendLockIcon from "mdi-react/SendLockIcon"; 5 | import SendCheckIcon from "mdi-react/SendCheckIcon"; 6 | 7 | import { WebSocketClientInfo } from "@nostr-ts/common"; 8 | 9 | export interface RelaySelectionProps { 10 | relays: { 11 | data: WebSocketClientInfo; 12 | isAssigned: boolean; 13 | }[]; 14 | onChange: (url: string, action: "add" | "remove") => void; 15 | } 16 | 17 | export function RelaySelection({ relays, onChange }: RelaySelectionProps) { 18 | const toggleSelection = (listId: string, isAssigned: boolean) => { 19 | onChange(listId, isAssigned ? "remove" : "add"); 20 | }; 21 | 22 | return ( 23 | 24 | {relays.length === 0 ? ( 25 | No relays found. 26 | ) : ( 27 | relays.map((item) => ( 28 | 29 | 33 | {item.data.url} 34 | 35 | 39 | toggleSelection(item.data.url, item.isAssigned)} 42 | isDisabled={item.data.write === false} 43 | > 44 | {/* */} 49 | 50 | )) 51 | )} 52 | 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /client-web/src/components/user-icon.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Avatar, 3 | Box, 4 | Text, 5 | Popover, 6 | PopoverArrow, 7 | PopoverBody, 8 | PopoverCloseButton, 9 | PopoverContent, 10 | PopoverHeader, 11 | PopoverTrigger, 12 | } from "@chakra-ui/react"; 13 | import { UserBase } from "@nostr-ts/common"; 14 | import { UserOptions } from "../lib/user-properties"; 15 | import { User } from "./user"; 16 | 17 | export function UserIcon({ 18 | user, 19 | opts: { 20 | showAbout, 21 | showBanner, 22 | showFollowing, 23 | relayUrls, 24 | title, 25 | reaction, 26 | avatarSize, 27 | }, 28 | }: { 29 | user: UserBase; 30 | opts: UserOptions; 31 | }) { 32 | const picture = user.data && user.data.picture ? user.data.picture : ""; 33 | 34 | return ( 35 | <> 36 | 37 | 38 | {reaction ? ( 39 | 40 | 41 | 50 | 61 | {reaction} 62 | 63 | 64 | ) : ( 65 | 66 | )} 67 | 68 | 69 | 70 | 71 | {title} 72 | 73 | 85 | 86 | 87 | 88 | 89 | ); 90 | } 91 | -------------------------------------------------------------------------------- /client-web/src/defaults.ts: -------------------------------------------------------------------------------- 1 | export const MAX_EVENTS = 200; 2 | export const EVENTS_PER_PAGE = 25; 3 | export const DEFAULT_RELAYS = { 4 | "wss://relay.shitforce.one": { 5 | read: true, 6 | write: true, 7 | }, 8 | "wss://relay.nostr.band": { 9 | read: true, 10 | write: true, 11 | }, 12 | "wss://offchain.pub": { 13 | read: true, 14 | write: false, 15 | }, 16 | "wss://nos.lol": { 17 | read: true, 18 | write: false, 19 | }, 20 | "wss://relay.snort.social": { 21 | read: true, 22 | write: false, 23 | }, 24 | "wss://relay.damus.io": { 25 | read: true, 26 | write: true, 27 | }, 28 | "wss://soloco.nl": { 29 | read: true, 30 | write: false, 31 | }, 32 | "wss://eden.nostr.land": { 33 | read: true, 34 | write: true, 35 | }, 36 | "wss://nostr.gruntwerk.org": { 37 | read: true, 38 | write: false, 39 | }, 40 | "wss://relay.nostr.bg": { 41 | read: true, 42 | write: false, 43 | }, 44 | "wss://relay.nostr.ro": { 45 | read: true, 46 | write: false, 47 | }, 48 | "wss://nostr.oxtr.dev": { 49 | read: true, 50 | write: false, 51 | }, 52 | "wss://nostr.noones.com": { 53 | read: true, 54 | write: false, 55 | }, 56 | "wss://nostr.vulpem.com": { 57 | read: true, 58 | write: false, 59 | }, 60 | }; 61 | -------------------------------------------------------------------------------- /client-web/src/index.css: -------------------------------------------------------------------------------- 1 | 2 | 3 | a.is-inline { 4 | text-decoration: underline 5 | } 6 | 7 | @media screen and (max-width: 600px) { 8 | .responsive-stack { 9 | flex-direction: column !important; 10 | } 11 | } 12 | @media screen and (min-width: 600px) { 13 | .hide-on-mobile { 14 | flex-direction: column !important; 15 | } 16 | } -------------------------------------------------------------------------------- /client-web/src/lib/bytesToGB.ts: -------------------------------------------------------------------------------- 1 | export function bytesToGB(bytes: number) { 2 | return bytes / Math.pow(2, 30); 3 | } 4 | -------------------------------------------------------------------------------- /client-web/src/lib/default-filters.ts: -------------------------------------------------------------------------------- 1 | import { NFilters, NEVENT_KIND } from "@nostr-ts/common"; 2 | 3 | export function filterDefault(limit?: number) { 4 | const filters = new NFilters({ 5 | kinds: [ 6 | NEVENT_KIND.SHORT_TEXT_NOTE, 7 | NEVENT_KIND.LONG_FORM_CONTENT, 8 | // NEVENT_KIND.REPOST, 9 | ], 10 | until: Math.round(Date.now() / 1000), 11 | since: Math.round((Date.now() - 2 * 24 * 60 * 60 * 1000) / 1000), 12 | limit: limit ? limit : 15, 13 | }); 14 | return filters; 15 | } 16 | 17 | export function filterByAuthor(pubKeys: string[], limit?: number) { 18 | const filters = new NFilters({ 19 | kinds: [NEVENT_KIND.SHORT_TEXT_NOTE, NEVENT_KIND.LONG_FORM_CONTENT], 20 | authors: pubKeys, 21 | until: Math.round(Date.now() / 1000), 22 | since: Math.round((Date.now() - 7 * 24 * 60 * 60 * 1000) / 1000), 23 | limit: limit ? limit : 15, 24 | }); 25 | if (limit) { 26 | filters.limit = limit; 27 | } 28 | return filters; 29 | } 30 | 31 | export function filterByTags(tags: string[], limit?: number) { 32 | const filters = new NFilters({ 33 | kinds: [NEVENT_KIND.SHORT_TEXT_NOTE, NEVENT_KIND.LONG_FORM_CONTENT], 34 | ["#t"]: tags, 35 | until: Math.round(Date.now() / 1000), 36 | since: Math.round((Date.now() - 7 * 24 * 60 * 60 * 1000) / 1000), 37 | limit: limit ? limit : 15, 38 | }); 39 | if (limit) { 40 | filters.limit = limit; 41 | } 42 | return filters; 43 | } 44 | 45 | export function filterByMentions(pubKeys: string[], limit?: number) { 46 | const filters = new NFilters({ 47 | kinds: [ 48 | NEVENT_KIND.SHORT_TEXT_NOTE, 49 | NEVENT_KIND.LONG_FORM_CONTENT, 50 | NEVENT_KIND.REPOST, 51 | ], 52 | "#p": pubKeys, 53 | }); 54 | if (limit) { 55 | filters.limit = limit; 56 | } 57 | return filters; 58 | } 59 | 60 | export function filterByRelatedEvents(id: string, limit?: number) { 61 | const filters = new NFilters({ 62 | kinds: [ 63 | NEVENT_KIND.SHORT_TEXT_NOTE, 64 | NEVENT_KIND.REPOST, 65 | NEVENT_KIND.REACTION, 66 | NEVENT_KIND.ZAP_RECEIPT, 67 | ], 68 | "#e": [id], 69 | }); 70 | if (limit) { 71 | filters.limit = limit; 72 | } 73 | return filters; 74 | } 75 | -------------------------------------------------------------------------------- /client-web/src/lib/event-reactions-filter.ts: -------------------------------------------------------------------------------- 1 | import { ReactionsCount } from "@nostr-ts/common"; 2 | 3 | export function filterReactions(obj?: ReactionsCount) { 4 | if (!obj) return {}; 5 | return Object.keys(obj) 6 | .filter((key) => key !== "+" && key !== "-") 7 | .reduce((newObj: ReactionsCount, key) => { 8 | newObj[key] = obj[key]; 9 | return newObj; 10 | }, {}); 11 | } 12 | -------------------------------------------------------------------------------- /client-web/src/lib/excerpt.ts: -------------------------------------------------------------------------------- 1 | export function excerpt(text: string, length: number) { 2 | if (text.length <= length) return text; 3 | return text ? text.substring(0, length) + "..." : "..."; 4 | } 5 | -------------------------------------------------------------------------------- /client-web/src/lib/integrations.ts: -------------------------------------------------------------------------------- 1 | export enum INTEGRATION_PROVIDER { 2 | SATTELITE_CDN = "sattelite_cdn", 3 | } 4 | 5 | export interface Integration { 6 | kind: INTEGRATION_PROVIDER; 7 | } 8 | 9 | export interface StorageIntegrationProvider extends Integration { 10 | expiresOn: number; 11 | credit: number; 12 | storageTotal: number; 13 | storageRemaining: number; 14 | } 15 | 16 | export interface SatteliteCDNIntegration extends StorageIntegrationProvider { 17 | kind: INTEGRATION_PROVIDER.SATTELITE_CDN; 18 | } 19 | -------------------------------------------------------------------------------- /client-web/src/lib/kinds.ts: -------------------------------------------------------------------------------- 1 | import { NEVENT_KIND } from "@nostr-ts/common"; 2 | 3 | export const nEventKindArray = Object.keys(NEVENT_KIND).map((k) => { 4 | return { 5 | name: k, 6 | value: NEVENT_KIND[k as keyof typeof NEVENT_KIND], 7 | }; 8 | }); 9 | 10 | const kindToName = (kind: NEVENT_KIND) => { 11 | const kindObj = nEventKindArray.find((k) => k.value === kind); 12 | if (kindObj) { 13 | return kindObj.name; 14 | } 15 | return "Unknown"; 16 | }; 17 | 18 | export const nEventKindToName = (kinds: NEVENT_KIND[]) => { 19 | return kinds.map(kindToName).join(", "); 20 | }; 21 | -------------------------------------------------------------------------------- /client-web/src/lib/new-event-for-submission.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EventBaseSigned, 3 | NEvent, 4 | NewLongFormContent, 5 | NewQuoteRepost, 6 | NewRecommendRelay, 7 | NewShortTextNote, 8 | NewShortTextNoteResponse, 9 | } from "@nostr-ts/common"; 10 | 11 | export function createNewEventForSubmission( 12 | eventKind: string, 13 | eventContent: string, 14 | relayUrl: string, 15 | props: { 16 | inResponseTo?: EventBaseSigned; 17 | } 18 | ): { 19 | event?: NEvent; 20 | error?: string; 21 | } { 22 | switch (eventKind) { 23 | case "NewShortTextNote": 24 | return { 25 | event: NewShortTextNote({ 26 | text: eventContent, 27 | }), 28 | }; 29 | case "NewLongFormContent": 30 | return { 31 | event: NewLongFormContent({ 32 | text: eventContent, 33 | }), 34 | }; 35 | case "NewShortTextNoteResponse": 36 | if (!props.inResponseTo) { 37 | return { 38 | error: "Response requires inResponseTo", 39 | }; 40 | } 41 | return { 42 | event: NewShortTextNoteResponse({ 43 | text: eventContent, 44 | inResponseTo: props.inResponseTo, 45 | }), 46 | }; 47 | case "NewRecommendRelay": 48 | return { 49 | event: NewRecommendRelay({ 50 | relayUrl: relayUrl, 51 | }), 52 | }; 53 | case "NewQuoteRepost": 54 | if (!props.inResponseTo) { 55 | return { 56 | error: "Quote repost requires inResponseTo", 57 | }; 58 | } 59 | return { 60 | event: NewQuoteRepost({ 61 | inResponseTo: props.inResponseTo, 62 | relayUrl: relayUrl, 63 | }), 64 | }; 65 | default: 66 | return { 67 | error: "Invalid event type", 68 | }; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /client-web/src/lib/nkind.ts: -------------------------------------------------------------------------------- 1 | export type NKIND = 2 | | "NewShortTextNote" 3 | | "NewLongFormContent" 4 | | "NewShortTextNoteResponse" 5 | | "NewRecommendRelay" 6 | | "NewQuoteRepost"; 7 | -------------------------------------------------------------------------------- /client-web/src/lib/note-spec.ts: -------------------------------------------------------------------------------- 1 | import { NEVENT_KIND } from "@nostr-ts/common"; 2 | 3 | /** 4 | * - Required fields are marked with `required: true` 5 | * - Optional fields are marked with `required: false` 6 | * - Fields the user should input are marked with `userInput: true` 7 | */ 8 | export const NoteSpec = [ 9 | { 10 | name: "NewShortTextNote", 11 | kind: NEVENT_KIND.SHORT_TEXT_NOTE, 12 | spec: [ 13 | { 14 | content: { 15 | required: true, 16 | kind: "Root", 17 | userInput: true, 18 | }, 19 | tags: [ 20 | { 21 | required: false, 22 | kind: "SubjectTag", 23 | userInput: true, 24 | }, 25 | ], 26 | }, 27 | ], 28 | }, 29 | { 30 | name: "NewRecommendRelay", 31 | kind: NEVENT_KIND.RECOMMEND_RELAY, 32 | spec: [ 33 | { 34 | content: { 35 | required: true, 36 | kind: "Root", 37 | userInput: true, 38 | help: "Enter the URL of the relay you want to recommend", 39 | }, 40 | tags: [ 41 | { 42 | required: false, 43 | kind: "NonceTag", 44 | userInput: true, 45 | }, 46 | ], 47 | }, 48 | ], 49 | }, 50 | { 51 | name: "NewReaction", 52 | kind: NEVENT_KIND.REACTION, 53 | spec: [ 54 | { 55 | content: { 56 | required: true, 57 | kind: "Root", 58 | userInput: true, 59 | help: "+ / -", 60 | }, 61 | }, 62 | ], 63 | }, 64 | { 65 | name: "NewLongFormContent", 66 | kind: NEVENT_KIND.LONG_FORM_CONTENT, 67 | spec: [ 68 | { 69 | content: { 70 | required: true, 71 | kind: "Root", 72 | userInput: true, 73 | }, 74 | tags: [ 75 | { 76 | required: false, 77 | kind: "EventTag", 78 | userInput: true, 79 | }, 80 | ], 81 | }, 82 | ], 83 | }, 84 | { 85 | name: "NewShortTextNoteResponse", 86 | kind: NEVENT_KIND.SHORT_TEXT_NOTE, 87 | spec: [ 88 | { 89 | content: { 90 | required: true, 91 | kind: "Root", 92 | userInput: true, 93 | }, 94 | tags: [ 95 | { 96 | required: false, 97 | kind: "SubjectTag", 98 | userInput: false, 99 | }, 100 | { 101 | required: true, 102 | kind: "EventTag", 103 | userInput: false, 104 | }, 105 | { 106 | required: true, 107 | kind: "PublicKeyTag", 108 | userInput: false, 109 | }, 110 | ], 111 | }, 112 | ], 113 | }, 114 | ]; 115 | -------------------------------------------------------------------------------- /client-web/src/lib/object-of-relays-to-array.ts: -------------------------------------------------------------------------------- 1 | import { Relay } from "@nostr-ts/common"; 2 | 3 | export type RelayProps = { 4 | read: boolean; 5 | write: boolean; 6 | }; 7 | 8 | export type Relays = { 9 | [key: string]: RelayProps; 10 | }; 11 | 12 | export function objectOfRelaysToArray(relays: Relays): Relay[] { 13 | return Object.entries(relays).map(([url, { read, write }]) => ({ 14 | url, 15 | read, 16 | write, 17 | })); 18 | } 19 | -------------------------------------------------------------------------------- /client-web/src/lib/pagination.ts: -------------------------------------------------------------------------------- 1 | export interface Pagination { 2 | limit: number; 3 | offset: number; 4 | } 5 | -------------------------------------------------------------------------------- /client-web/src/lib/relative-time.ts: -------------------------------------------------------------------------------- 1 | export const unixTimeToRelative = (time: number) => { 2 | const now = new Date(); 3 | const then = new Date(time * 1000); 4 | const diff = now.getTime() - then.getTime(); 5 | const seconds = Math.floor(diff / 1000); 6 | const minutes = Math.floor(seconds / 60); 7 | const hours = Math.floor(minutes / 60); 8 | 9 | if (seconds < 60) { 10 | return `${seconds} seconds ago`; 11 | } else if (minutes < 60) { 12 | return `${minutes} minutes ago`; 13 | } else if (hours < 24) { 14 | return `${hours} hours ago`; 15 | } else { 16 | return `${then.toLocaleDateString()} ${then.toLocaleTimeString()}`; 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /client-web/src/lib/round.ts: -------------------------------------------------------------------------------- 1 | export function roundToDecimal(value: number, decimalPlaces: number) { 2 | const multiplier = Math.pow(10, decimalPlaces); 3 | return Math.round(value * multiplier) / multiplier; 4 | } 5 | -------------------------------------------------------------------------------- /client-web/src/lib/toast.ts: -------------------------------------------------------------------------------- 1 | import { UseToastOptions } from "@chakra-ui/react"; 2 | 3 | export function toastErrorContent( 4 | err: Error, 5 | message?: string 6 | ): UseToastOptions { 7 | return { 8 | title: message || "Error", 9 | description: err.message, 10 | status: "error", 11 | duration: 9000, 12 | isClosable: true, 13 | }; 14 | } 15 | 16 | export function toastSuccessContent(message: string): UseToastOptions { 17 | return { 18 | title: message, 19 | status: "success", 20 | duration: 9000, 21 | isClosable: true, 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /client-web/src/lib/user-properties.ts: -------------------------------------------------------------------------------- 1 | import { UserBase } from "@nostr-ts/common"; 2 | 3 | export interface UserOptions { 4 | showAbout?: boolean; 5 | showBanner?: boolean; 6 | showFollowing?: boolean; 7 | /** 8 | * Show block button 9 | */ 10 | showBlock?: boolean; 11 | showLud?: boolean; 12 | relayUrls: string[]; 13 | isBlocked?: boolean; 14 | lists?: { 15 | id: string; 16 | title: string; 17 | }[]; 18 | 19 | /** 20 | * For pop-over 21 | */ 22 | title?: string; 23 | /** 24 | * For reactions 25 | */ 26 | reaction?: string; 27 | avatarSize?: "sm" | "md" | "lg" | "xl" | "2xl" | "2xs" | "xs" | "full"; 28 | } 29 | 30 | export interface UserInfoProps { 31 | user: UserBase; 32 | opts: UserOptions; 33 | } 34 | -------------------------------------------------------------------------------- /client-web/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ChakraProvider, ColorModeScript } from "@chakra-ui/react"; 3 | import ReactDOM from "react-dom/client"; 4 | import { Route, Routes, HashRouter } from "react-router-dom"; 5 | import { useNClient } from "./state/client.ts"; 6 | import { AccountRoute } from "./routes/account.tsx"; 7 | import { PrimaryLayout } from "./layouts/primary.tsx"; 8 | import { WelcomeRoute } from "./routes/welcome.js"; 9 | import { FollowingUsersRoute } from "./routes/following.tsx"; 10 | import { ProfileRoute } from "./routes/profile.tsx"; 11 | import { MAX_EVENTS } from "./defaults.ts"; 12 | import { UserProfileRoute } from "./routes/user-profile.tsx"; 13 | import { BlockedUsersRoute } from "./routes/blocked.tsx"; 14 | import { ListsRoute } from "./routes/lists.tsx"; 15 | import { EventRoute } from "./routes/event.tsx"; 16 | import { TagRoute } from "./routes/tag.tsx"; 17 | import "./index.css"; 18 | import theme from "./theme.ts"; 19 | 20 | const init = async () => { 21 | await useNClient.getState().init({ 22 | maxEvents: MAX_EVENTS, 23 | }); 24 | }; 25 | 26 | init(); 27 | 28 | ReactDOM.createRoot(document.getElementById("root")!).render( 29 | 30 | 31 | 32 | 33 | 34 | }> 35 | } /> 36 | } /> 37 | {/* } /> */} 38 | } /> 39 | } /> 40 | } /> 41 | } /> 42 | } /> 43 | } /> 44 | } /> 45 | 46 | 47 | 48 | 49 | 50 | ); 51 | -------------------------------------------------------------------------------- /client-web/src/nostr.d.ts: -------------------------------------------------------------------------------- 1 | interface Event { 2 | id?: string; 3 | pubkey?: string; 4 | sig?: string; 5 | // ... other properties 6 | } 7 | 8 | interface RelayPolicy { 9 | read: boolean; 10 | write: boolean; 11 | } 12 | 13 | interface Nip04 { 14 | encrypt(pubkey: string, plaintext: string): Promise; 15 | decrypt(pubkey: string, ciphertext: string): Promise; 16 | } 17 | 18 | interface Nostr { 19 | getPublicKey(): Promise; 20 | signEvent(event: Event): Promise; 21 | getRelays?(): Promise<{ [url: string]: RelayPolicy }>; 22 | nip04?: Nip04; 23 | } 24 | 25 | // The 'export {}' makes this file a module 26 | export {}; 27 | 28 | declare global { 29 | interface Window { 30 | nostr: Nostr; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /client-web/src/routes/blocked.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Heading, Text } from "@chakra-ui/react"; 2 | import { useNClient } from "../state/client"; 3 | import { useEffect, useState } from "react"; 4 | import { UserRecord } from "@nostr-ts/common"; 5 | import { User } from "../components/user"; 6 | 7 | export function BlockedUsersRoute() { 8 | const [blockedUsers, setBlockedUsers] = useState([]); 9 | 10 | const update = async () => { 11 | await useNClient 12 | .getState() 13 | .getAllUsersBlocked() 14 | .then((following) => { 15 | if (following) { 16 | setBlockedUsers(following); 17 | } 18 | }); 19 | }; 20 | 21 | useEffect(() => { 22 | update(); 23 | const updateInterval = setInterval(update, 2000); 24 | 25 | return () => clearInterval(updateInterval); 26 | }, []); 27 | 28 | return ( 29 | 30 | Blocked 31 | {blockedUsers.length > 0 ? ( 32 | <> 33 | {blockedUsers.map((item) => ( 34 | 35 | 45 | 46 | ))} 47 | 48 | ) : ( 49 | <> 50 | 51 | Block users to ignore their events. The information is stored in 52 | your browser and not shared with relays. 53 | 54 | You have not blocked anyone. 55 | 56 | )} 57 | 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /client-web/src/routes/notifications.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Grid } from "@chakra-ui/react"; 2 | import { useEffect } from "react"; 3 | import { useNClient } from "../state/client"; 4 | import { Events } from "../components/events"; 5 | import { filterByMentions } from "../lib/default-filters"; 6 | 7 | export function NotificationsRoute() { 8 | const [status, pubKey] = useNClient((state) => [ 9 | state.status, 10 | state.keypair ? state.keypair.publicKey : undefined, 11 | ]); 12 | 13 | const view = `notifications-${pubKey}`; 14 | 15 | const loadEvents = async (pubKey: string) => { 16 | await useNClient.getState().getEvents({ 17 | token: view, 18 | query: { 19 | direction: "OLDER", 20 | filters: filterByMentions([pubKey]), 21 | stickyInterval: true, 22 | isLive: true, 23 | }, 24 | }); 25 | }; 26 | 27 | useEffect(() => { 28 | return () => { 29 | useNClient.getState().unsubscribeByToken(view); 30 | }; 31 | }, []); 32 | 33 | useEffect(() => { 34 | if ( 35 | ["online", "offline"].includes(useNClient.getState().status) && 36 | pubKey 37 | ) { 38 | loadEvents(pubKey); 39 | } 40 | }, [status, pubKey]); 41 | 42 | return ( 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /client-web/src/routes/tag.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Grid } from "@chakra-ui/react"; 2 | import { useEffect, useRef } from "react"; 3 | import { useNClient } from "../state/client"; 4 | import { useParams } from "react-router-dom"; 5 | import { Events } from "../components/events"; 6 | import { filterByTags } from "../lib/default-filters"; 7 | 8 | export function TagRoute() { 9 | const [status] = useNClient((state) => [state.status]); 10 | 11 | const loadedEventsForRef = useRef(undefined); 12 | 13 | const userLoadTimeout = useRef(null); 14 | 15 | // URL params 16 | const { tag } = useParams(); 17 | 18 | const view = `view-${tag}`; 19 | 20 | const loadedEventsRefMatchesNpub = () => { 21 | if (loadedEventsForRef.current === tag) { 22 | return true; 23 | } else { 24 | return false; 25 | } 26 | }; 27 | 28 | const loadEvents = async (t: string) => { 29 | loadedEventsForRef.current = tag; 30 | await useNClient.getState().getEvents({ 31 | token: view, 32 | query: { 33 | direction: "OLDER", 34 | filters: filterByTags([t]), 35 | stickyInterval: true, 36 | isLive: true, 37 | }, 38 | }); 39 | }; 40 | 41 | useEffect(() => { 42 | return () => { 43 | if (userLoadTimeout.current) { 44 | clearTimeout(userLoadTimeout.current); 45 | } 46 | useNClient.getState().unsubscribeByToken(view); 47 | }; 48 | }, []); 49 | 50 | useEffect(() => { 51 | if ( 52 | ["online", "offline"].includes(useNClient.getState().status) && 53 | tag && 54 | !loadedEventsRefMatchesNpub() 55 | ) { 56 | loadEvents(tag); 57 | } 58 | }, [status, tag]); 59 | 60 | return ( 61 | 62 | 63 | 64 | 65 | 66 | 67 | {/* 68 | Broadcast to the Network 69 | 70 | */} 71 | 72 | 73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /client-web/src/routes/user-profile.tsx: -------------------------------------------------------------------------------- 1 | import { UserProfileForm } from "../components/user-profile-form"; 2 | import { useNClient } from "../state/client"; 3 | import { useEffect } from "react"; 4 | import { User } from "../components/user"; 5 | import { Box, Text, Button, useToast, Heading } from "@chakra-ui/react"; 6 | 7 | export function UserProfileRoute() { 8 | const [pubkey, isOnline, activeUser] = useNClient((state) => [ 9 | state.keypair?.publicKey || "", 10 | state.connected, 11 | state.activeUser, 12 | ]); 13 | 14 | const toast = useToast(); 15 | 16 | const init = async () => { 17 | await useNClient.getState().getAndSetActiveUser({ 18 | retry: true, 19 | }); 20 | }; 21 | 22 | const refreshData = async () => { 23 | await useNClient.getState().getAndSetActiveUser({ 24 | retry: false, 25 | updateFromRelays: true, 26 | }); 27 | toast({ 28 | title: "Refreshing ...", 29 | description: "Requesting information from the network.", 30 | status: "info", 31 | duration: 5000, 32 | isClosable: true, 33 | }); 34 | }; 35 | 36 | useEffect(() => { 37 | const timeout = setInterval(() => { 38 | init(); 39 | }, 1000); 40 | 41 | return () => { 42 | clearInterval(timeout); 43 | }; 44 | }, []); 45 | 46 | return ( 47 | 48 | {activeUser ? ( 49 | 55 | ) : ( 56 | <> 57 | 58 | "No user data found yet. Did you publish your profile to one of the 59 | connected relays?". 60 | 61 | {isOnline && } 62 | 63 | )} 64 | 65 | 66 | Update your profile 67 | 68 | 74 | 75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /client-web/src/routes/welcome.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Image, Grid } from "@chakra-ui/react"; 2 | import { useNClient } from "../state/client"; 3 | import { EventsFeeds } from "../components/feeds"; 4 | import Logo from "../assets/logo.svg"; 5 | import { PopularUsersList } from "../components/popular-users"; 6 | 7 | export function WelcomeRoute() { 8 | const [connected] = useNClient((state) => [state.connected]); 9 | 10 | return ( 11 | 12 | 13 | 14 | 15 | {!connected && } 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /client-web/src/state/keystore.ts: -------------------------------------------------------------------------------- 1 | interface NClientKeystore { 2 | keystore: "none" | "localstore" | "nos2x" | "download"; 3 | publicKey?: string; 4 | privateKey?: string; 5 | } 6 | 7 | export interface NClientNoStore extends NClientKeystore { 8 | keystore: "none"; 9 | } 10 | 11 | export interface NClientLocalStore extends NClientKeystore { 12 | keystore: "localstore"; 13 | publicKey: string; 14 | } 15 | 16 | export interface NClientNos2xStore extends NClientKeystore { 17 | keystore: "nos2x"; 18 | publicKey: string; 19 | } 20 | 21 | export function loadKeyStoreConfig(): 22 | | NClientLocalStore 23 | | NClientNos2xStore 24 | | NClientNoStore { 25 | const keystore = localStorage.getItem("nostr-client:keystore:keystore"); 26 | if (keystore) { 27 | if (keystore === "localstore") { 28 | const publicKey = localStorage.getItem( 29 | "nostr-client:keystore:public-key" 30 | ); 31 | const privateKey = localStorage.getItem( 32 | "nostr-client:keystore:private-key" 33 | ); 34 | if (publicKey && privateKey) { 35 | return { 36 | keystore: "localstore", 37 | publicKey, 38 | privateKey, 39 | }; 40 | } 41 | } else if (keystore === "nos2x") { 42 | const publicKey = localStorage.getItem( 43 | "nostr-client:keystore:public-key" 44 | ); 45 | if (publicKey) { 46 | return { 47 | keystore: "nos2x", 48 | publicKey: publicKey, 49 | privateKey: undefined, 50 | }; 51 | } 52 | } 53 | } 54 | return { 55 | keystore: "none", 56 | publicKey: undefined, 57 | privateKey: undefined, 58 | }; 59 | } 60 | 61 | export function saveKeyStoreConfig(config: NClientKeystore) { 62 | localStorage.setItem("nostr-client:keystore:keystore", config.keystore); 63 | if ( 64 | config.keystore === "localstore" && 65 | config.publicKey && 66 | config.privateKey 67 | ) { 68 | localStorage.setItem("nostr-client:keystore:public-key", config.publicKey); 69 | localStorage.setItem( 70 | "nostr-client:keystore:private-key", 71 | config.privateKey 72 | ); 73 | } else if (config.keystore === "nos2x") { 74 | // TODO: Implement 75 | } else if (config.keystore === "download") { 76 | // TODO: Implement 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /client-web/src/state/pow-worker.ts: -------------------------------------------------------------------------------- 1 | import { proofOfWork } from "@nostr-ts/common"; 2 | 3 | self.onmessage = function (e) { 4 | const data = e.data; 5 | const result = proofOfWork(data.event, data.bits); 6 | self.postMessage({ result }); 7 | }; 8 | -------------------------------------------------------------------------------- /client-web/src/state/worker.ts: -------------------------------------------------------------------------------- 1 | import { expose } from "comlink"; 2 | import { NWorker } from "@nostr-ts/web"; 3 | 4 | const worker = new NWorker({ 5 | isInWebWorker: true, 6 | saveAllEvents: false, 7 | }); 8 | expose(worker); 9 | -------------------------------------------------------------------------------- /client-web/src/theme.ts: -------------------------------------------------------------------------------- 1 | import { extendTheme, type ThemeConfig } from "@chakra-ui/react"; 2 | 3 | const config: ThemeConfig = { 4 | initialColorMode: "dark", 5 | useSystemColorMode: true, 6 | }; 7 | 8 | const theme = extendTheme({ 9 | config, 10 | breakpoints: { 11 | sm: "500px", 12 | }, 13 | }); 14 | 15 | export default theme; 16 | -------------------------------------------------------------------------------- /client-web/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare const APP_VERSION: string; 4 | -------------------------------------------------------------------------------- /client-web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /client-web/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /client-web/upload.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | pnpm run build || exit 1 4 | 5 | # Prompt the user which folder to upload 6 | FOLDER_NAME="./dist" 7 | AWS_BUCKET_URL="s3://nostrop.com" 8 | PROFILE_NAME="nostrop" 9 | CLOUDFRONT_ID="E3QIFOMG099G2H" 10 | 11 | echo "Using AWS profile: $PROFILE_NAME" 12 | echo "Uploading folder: $FOLDER_NAME" 13 | echo "Destination: $AWS_BUCKET_URL" 14 | 15 | # Upload the folder to S3 using AWS CLI 16 | aws s3 sync $FOLDER_NAME $AWS_BUCKET_URL --profile $PROFILE_NAME 17 | 18 | # Invalidate CloudFront (Uncomment if you need this) 19 | aws cloudfront create-invalidation --distribution-id $CLOUDFRONT_ID --paths "/*" --profile $PROFILE_NAME 20 | -------------------------------------------------------------------------------- /client-web/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react-swc"; 3 | import { VitePWA } from "vite-plugin-pwa"; 4 | 5 | const BASE_URL = process.env.VITE_CLIENT_WEB_BASE_URL || "/"; 6 | 7 | export default defineConfig({ 8 | base: BASE_URL, 9 | plugins: [ 10 | react(), 11 | VitePWA({ 12 | registerType: "autoUpdate", 13 | includeAssets: ["favicon.ico", "apple-touch-icon.png"], 14 | manifest: { 15 | name: "Nostr Client", 16 | short_name: "NostrClient", 17 | description: 18 | "Client to read and display data from Nostr-compatible APIs", 19 | theme_color: "#ffffff", 20 | icons: [ 21 | { 22 | src: "pwa-192x192.png", 23 | sizes: "192x192", 24 | type: "image/png", 25 | }, 26 | { 27 | src: "pwa-512x512.png", 28 | sizes: "512x512", 29 | type: "image/png", 30 | }, 31 | ], 32 | }, 33 | }), 34 | ], 35 | define: { 36 | APP_VERSION: JSON.stringify(process.env.npm_package_version), 37 | }, 38 | }); 39 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | *.d.ts 3 | node_modules 4 | !build.js 5 | key 6 | key.pub 7 | dist 8 | discovered-relays.json 9 | discovered-relays-error.json -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # Nostr Node.js client playground 2 | 3 | Open up `./src/index.ts` and explore ... 4 | 5 | Install: 6 | 7 | ```bash 8 | pnpm install 9 | ``` 10 | 11 | Build: 12 | 13 | ```bash 14 | pnpm run build 15 | ``` 16 | 17 | Run: 18 | 19 | ```bash 20 | pnpm run start 21 | ``` -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "0.0.5", 4 | "description": "Nostr node playground", 5 | "author": "Franz Geffke ", 6 | "license": "MIT", 7 | "main": "dist/index.js", 8 | "scripts": { 9 | "prebuild": "rimraf dist", 10 | "tsc": "pnpm run build", 11 | "build": "pnpm run prebuild && tsc -p tsconfig.json", 12 | "start": "node dist/index.js" 13 | }, 14 | "dependencies": { 15 | "@noble/secp256k1": "^2.2.3", 16 | "@nostr-ts/common": "workspace:*", 17 | "@nostr-ts/node": "workspace:*" 18 | }, 19 | "devDependencies": { 20 | "@types/node": "^20.17.23", 21 | "@types/ws": "^8.18.0", 22 | "rimraf": "^3.0.2", 23 | "tslib": "2.5.0", 24 | "typescript": "5.1.6" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /client/src/index.ts: -------------------------------------------------------------------------------- 1 | import { NFilters, logRelayMessage } from "@nostr-ts/common"; 2 | import { loadOrCreateKeypair, RelayClient } from "@nostr-ts/node"; 3 | 4 | const main = async () => { 5 | const keypair = await loadOrCreateKeypair(); 6 | 7 | let client = new RelayClient([ 8 | "wss://nostr.rocks", 9 | "wss://nostr.wine", 10 | "wss://nostr.lu.ke", 11 | ]); 12 | 13 | const filters = new NFilters(); 14 | // filters.addAuthor(keypair.pub) 15 | // filters.addKind(6) 16 | 17 | console.log(filters.toJson()); 18 | 19 | client.subscribe({ 20 | filters, 21 | }); 22 | 23 | client.listen((payload) => { 24 | console.log(payload.meta.id, payload.meta.url); 25 | logRelayMessage(payload.data); 26 | }); 27 | 28 | // // SEND A MESSAGE 29 | // const note = NewShortTextNote({ 30 | // text: "Hello nostr #3!", 31 | // }); 32 | // note.signAndGenerateId(keypair); 33 | // client.sendEvent(note); 34 | 35 | // // SEND REACTION 36 | // const reaction = NewReaction({ 37 | // text: "+", 38 | // inResponseTo: { 39 | // id: "e21921600ecbcbea699a9f76c8156886bef112b71c4f79ce1b894386b5413466", 40 | // pubkey: 41 | // "5276ac499c9c6a353634d3d2cb6f4ada5167c3b886108ab4ddeb8ddf7b0fff70", 42 | // }, 43 | // }); 44 | 45 | // reaction.signAndGenerateId(keypair); 46 | // const verified = verifyEvent(reaction); 47 | // console.log(`Verified: ${verified}`); 48 | // client.sendEvent(reaction); 49 | 50 | // // SEND REPOST 51 | // const repost = NewQuoteRepost({ 52 | // relayUrl: "https://nostr.rocks", 53 | // inResponseTo: { 54 | // id: "e21921600ecbcbea699a9f76c8156886bef112b71c4f79ce1b894386b5413466", 55 | // pubkey: 56 | // "5276ac499c9c6a353634d3d2cb6f4ada5167c3b886108ab4ddeb8ddf7b0fff70", 57 | // created_at: 1690881792, 58 | // kind: 1, 59 | // tags: [], 60 | // content: 61 | // "Hello everyone! I am working on a new ts library for nostr. This is just a test.", 62 | // sig: "6cee8c1d11ca5f8c7a0bd9839d0af5d3af3cc6a5de754fc449d34188c0066eee3e5b5b4e567cd77a2e0369f8c9525d60e064db175acd02d9c5374c3c0e912969", 63 | // }, 64 | // }); 65 | // repost.signAndGenerateId(keypair); 66 | // client.sendEvent(repost); 67 | 68 | // delay by 10800 seconds client.closeConnection() 69 | await new Promise((resolve) => setTimeout(resolve, 1 * 60 * 1000)).then( 70 | () => { 71 | client.disconnect(); 72 | } 73 | ); 74 | }; 75 | 76 | main(); 77 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2019", 4 | "module": "ESNext", 5 | "declaration": true, 6 | "strict": true, 7 | "moduleResolution": "node", 8 | "resolveJsonModule": true, 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "baseUrl": ".", 13 | "outDir": "dist/", 14 | }, 15 | "include": ["src/**/*.ts"], 16 | "exclude": ["node_modules"] 17 | } 18 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | services: 3 | web: 4 | build: 5 | context: . 6 | dockerfile: Dockerfile 7 | network: host 8 | ports: 9 | - "4000:80" 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nostr-ts", 3 | "version": "0.0.5", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "build": "pnpm --filter './packages/**' build", 9 | "tests": "pnpm --filter './packages/**' tests" 10 | }, 11 | "keywords": [], 12 | "author": "Franz Geffke ", 13 | "license": "MIT", 14 | "devDependencies": { 15 | "ts-node": "^10.9.2" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/README.md: -------------------------------------------------------------------------------- 1 | # Packages 2 | 3 | - `./packages/common`: common types and functions 4 | - `./packages/node`: client for usage with node `ws` library 5 | - `./packages/web`: client for usage with browser `WebSocket` API (TODO) -------------------------------------------------------------------------------- /packages/common/.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | *.d.ts 3 | node_modules 4 | !build.js 5 | dist -------------------------------------------------------------------------------- /packages/common/README.md: -------------------------------------------------------------------------------- 1 | # nostr-ts Common 2 | 3 | > Nostr: A simple, open protocol that enables a truly censorship-resistant and global social network. 4 | 5 | This package is part of [nostr-ts](https://github.com/franzos/nostr-ts). 6 | 7 | - `@nostr-ts/common`: common types and functions 8 | - `@nostr-ts/node`: client for usage with node `ws` library 9 | - `@nostr-ts/web`: client for usage with browser `WebSocket` API 10 | 11 | Checkout the [documentation](https://github.com/franzos/nostr-ts) for more information. 12 | 13 | ## Installation 14 | 15 | ```bash 16 | pnpm add @nostr-ts/common 17 | ``` 18 | 19 | ## Get started 20 | 21 | This is mostly shared stuff, so checkout either 22 | 23 | - `@nostr-ts/node` for node usage 24 | - `@nostr-ts/web` for browser usage 25 | 26 | for ex. 27 | 28 | On Node.js use: 29 | 30 | ```js 31 | import { NewShortTextNote, NFilters } from '@nostr-ts/common' 32 | import { RelayClient, RelayDiscovery, loadOrCreateKeypair, NUser } from '@nostr-ts/node' 33 | ``` 34 | 35 | In the browser use: 36 | 37 | ```js 38 | import { NewShortTextNote, NFilters } from '@nostr-ts/common' 39 | import { RelayClient, loadOrCreateKeypair, NUser } from '@nostr-ts/web' 40 | ``` -------------------------------------------------------------------------------- /packages/common/build.js: -------------------------------------------------------------------------------- 1 | import { build } from 'esbuild'; 2 | import { nodeExternalsPlugin } from 'esbuild-node-externals'; 3 | import { esbuildDecorators } from '@anatine/esbuild-decorators'; 4 | 5 | const shared = { 6 | entryPoints: ['./src/index.ts'], 7 | bundle: true, 8 | treeShaking: true, 9 | platform: 'node', 10 | target: 'node18', 11 | plugins: [nodeExternalsPlugin()], 12 | } 13 | 14 | build({ 15 | ...shared, 16 | outfile: 'dist/index.cjs', 17 | format: 'cjs', 18 | plugins: [ 19 | esbuildDecorators({ 20 | tsconfig: 'tsconfig.build.json', 21 | cwd: process.cwd(), 22 | }), 23 | ...shared.plugins, 24 | ] 25 | }).catch((err) => { 26 | console.error(err); 27 | process.exit(1); 28 | }); 29 | 30 | build({ 31 | ...shared, 32 | outfile: 'dist/index.esm.js', 33 | format: 'esm', 34 | }).catch((err) => { 35 | console.error(err); 36 | process.exit(1); 37 | }); -------------------------------------------------------------------------------- /packages/common/jest.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "roots": [ 3 | "/src", 4 | "/tests" 5 | ], 6 | "transform": { 7 | "^.+\\.tsx?$": "ts-jest" 8 | }, 9 | "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$", 10 | "moduleFileExtensions": [ 11 | "ts", 12 | "tsx", 13 | "js", 14 | "jsx", 15 | "json", 16 | "node" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /packages/common/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@nostr-ts/common", 3 | "version": "0.0.6", 4 | "description": "Commonly used nostr types and utils", 5 | "author": "Franz Geffke ", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/franzos/nostr" 10 | }, 11 | "keywords": [ 12 | "nostr", 13 | "blockchain", 14 | "crypto" 15 | ], 16 | "type": "module", 17 | "scripts": { 18 | "prebuild": "rimraf dist", 19 | "tsc": "pnpm run build", 20 | "build": "pnpm run prebuild && node build.js && tsc -p tsconfig.json --emitDeclarationOnly", 21 | "check": "pnpm dlx madge --extensions ts --circular src/", 22 | "tests": "jest" 23 | }, 24 | "dependencies": { 25 | "@noble/curves": "^1.8.1", 26 | "@noble/hashes": "^1.7.1", 27 | "bech32": "^2.0.0", 28 | "light-bolt11-decoder": "^3.2.0", 29 | "nanoid": "^3.3.8" 30 | }, 31 | "devDependencies": { 32 | "@anatine/esbuild-decorators": "^0.2.19", 33 | "@types/jest": "^29.5.14", 34 | "crypto-browserify": "^3.12.1", 35 | "esbuild": "^0.17.19", 36 | "esbuild-node-externals": "^1.18.0", 37 | "jest": "^29.7.0", 38 | "rimraf": "^3.0.2", 39 | "ts-jest": "^29.2.6", 40 | "tslib": "2.6.2", 41 | "typescript": "5.2.2" 42 | }, 43 | "main": "dist/index.cjs", 44 | "module": "dist/index.esm.js", 45 | "typings": "dist/index.d.ts", 46 | "files": [ 47 | "dist/*" 48 | ], 49 | "gitHead": "67e2e9767eb307a0ef494800638d8d163db8cb6a" 50 | } 51 | -------------------------------------------------------------------------------- /packages/common/src/classes/filter.ts: -------------------------------------------------------------------------------- 1 | import { FiltersBase, NEVENT_KIND } from "../types"; 2 | 3 | export class NFilters implements FiltersBase { 4 | ids?: string[] | undefined; 5 | authors?: string[] | undefined; 6 | kinds?: number[] | undefined; 7 | "#e"?: string[] | undefined; 8 | "#p"?: string[] | undefined; 9 | "#t"?: string[] | undefined; 10 | since?: number | undefined; 11 | until?: number | undefined; 12 | limit?: number | undefined; 13 | 14 | constructor(data?: FiltersBase) { 15 | this.ids = data?.ids; 16 | this.authors = data?.authors; 17 | this.kinds = data?.kinds; 18 | this["#e"] = data?.["#e"]; 19 | this["#p"] = data?.["#p"]; 20 | this["#t"] = data?.["#t"]; 21 | 22 | this.since = data?.since; 23 | this.until = data?.until; 24 | this.limit = data?.limit; 25 | } 26 | 27 | public addId(id: string) { 28 | if (!this.ids) { 29 | this.ids = []; 30 | } 31 | this.ids.push(id); 32 | } 33 | 34 | public addAuthor(author: string) { 35 | if (!this.authors) { 36 | this.authors = []; 37 | } 38 | this.authors.push(author); 39 | } 40 | 41 | public addKind(kind: NEVENT_KIND | number) { 42 | if (!this.kinds) { 43 | this.kinds = []; 44 | } 45 | this.kinds.push(kind); 46 | } 47 | 48 | public updateLimit(limit: number) { 49 | this.limit = limit; 50 | } 51 | 52 | public toObj(): FiltersBase { 53 | return JSON.parse(JSON.stringify(this)); 54 | } 55 | } 56 | 57 | // TODO: #e 58 | export function NewZAPReceiptFilter() { 59 | return new NFilters({ 60 | kinds: [NEVENT_KIND.ZAP_RECEIPT], 61 | }); 62 | } 63 | 64 | export function NewMetadataFilter() { 65 | return new NFilters({ 66 | kinds: [NEVENT_KIND.METADATA], 67 | }); 68 | } 69 | 70 | export function NewUserMetadataFilter(pubkey: string) { 71 | return new NFilters({ 72 | kinds: [NEVENT_KIND.METADATA], 73 | authors: [pubkey], 74 | limit: 3, 75 | }); 76 | } 77 | -------------------------------------------------------------------------------- /packages/common/src/classes/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./event"; 2 | export * from "./filter"; 3 | export * from "./identity-claim"; 4 | export * from "./relay-client"; 5 | export * from "./relay-connection"; 6 | export * from "./user-store"; 7 | export * from "./user"; 8 | -------------------------------------------------------------------------------- /packages/common/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./classes/index"; 2 | export * from "./types/index"; 3 | export * from "./utils/index"; 4 | -------------------------------------------------------------------------------- /packages/common/src/types/bech32.ts: -------------------------------------------------------------------------------- 1 | export enum BECH32_PREFIX { 2 | PublicKeys = "npub", 3 | PrivateKeys = "nsec", 4 | NoteIDs = "note", 5 | LNURL = "lnurl", 6 | // TLV 7 | Profile = "nprofile", 8 | // TLV 9 | Event = "nevent", 10 | // TLV 11 | Relay = "nrelay", 12 | // TLV 13 | EventCoordinate = "naddr", 14 | } 15 | -------------------------------------------------------------------------------- /packages/common/src/types/client-message.ts: -------------------------------------------------------------------------------- 1 | import { NEvent } from "../classes/index"; 2 | import { FiltersBase } from "./filter"; 3 | 4 | /** 5 | * Client message to relay 6 | * https://github.com/nostr-protocol/nips/blob/master/README.md#client-to-relay 7 | */ 8 | export enum CLIENT_MESSAGE_TYPE { 9 | AUTH = "AUTH", 10 | CLOSE = "CLOSE", 11 | COUNT = "COUNT", 12 | EVENT = "EVENT", 13 | REQ = "REQ", 14 | } 15 | 16 | interface ClientMessageBase { 17 | type: CLIENT_MESSAGE_TYPE; 18 | } 19 | 20 | /** 21 | * Client close 22 | * Used to unsubscribe from a stream 23 | */ 24 | export interface ClientClose extends ClientMessageBase { 25 | type: CLIENT_MESSAGE_TYPE.CLOSE; 26 | subscriptionId: string; 27 | } 28 | 29 | /** 30 | * Client count 31 | * Used to get the number of events 32 | */ 33 | export interface ClientCount extends ClientMessageBase { 34 | type: CLIENT_MESSAGE_TYPE.COUNT; 35 | subscriptionId: string; 36 | filters: FiltersBase; 37 | } 38 | 39 | /** 40 | * Client event 41 | * Used to assemble a new event 42 | */ 43 | export interface ClientEvent extends ClientMessageBase { 44 | type: CLIENT_MESSAGE_TYPE.EVENT; 45 | data: NEvent; 46 | } 47 | 48 | /** 49 | * Client request 50 | * Used to subscribe to a stream 51 | */ 52 | export interface ClientRequest extends ClientMessageBase { 53 | type: CLIENT_MESSAGE_TYPE.REQ; 54 | subscriptionId: string; 55 | filters: FiltersBase; 56 | } 57 | 58 | export interface ClientAuth extends ClientMessageBase { 59 | type: CLIENT_MESSAGE_TYPE.AUTH; 60 | signedEvent: string; 61 | } 62 | -------------------------------------------------------------------------------- /packages/common/src/types/content.ts: -------------------------------------------------------------------------------- 1 | import { NOSTR_URL_PREFIX } from "../utils"; 2 | 3 | export interface NEventContent { 4 | images: string[] | undefined; 5 | videos: string[] | undefined; 6 | nurls: 7 | | { 8 | type: NOSTR_URL_PREFIX; 9 | data: string; 10 | }[] 11 | | undefined; 12 | tags: string[] | undefined; 13 | text: string | undefined; 14 | } 15 | -------------------------------------------------------------------------------- /packages/common/src/types/event-contacts.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Event contact 3 | * part of Contact List 4 | * 5 | * https://github.com/nostr-protocol/nips/blob/master/02.md 6 | */ 7 | export interface NEventContact { 8 | key: string; 9 | relayUrl?: string; 10 | petname?: string; 11 | } 12 | -------------------------------------------------------------------------------- /packages/common/src/types/event-coordinates-tag.ts: -------------------------------------------------------------------------------- 1 | import { NEVENT_KIND } from "./event-kind"; 2 | 3 | export interface EventCoordinatesTag { 4 | kind: NEVENT_KIND; 5 | pubkey: string; 6 | identifier: string; 7 | relay?: string; 8 | } 9 | -------------------------------------------------------------------------------- /packages/common/src/types/event-event-tag.ts: -------------------------------------------------------------------------------- 1 | export interface EventEventTag { 2 | eventId: string; 3 | relayUrl?: string; 4 | marker?: "reply" | "root" | "mention"; 5 | } 6 | -------------------------------------------------------------------------------- /packages/common/src/types/filter.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Filters for the search 3 | * 4 | */ 5 | export interface FiltersBase { 6 | /** 7 | * a list of event ids or prefixes 8 | */ 9 | ids?: string[]; 10 | /** 11 | * a list of pubkeys or prefixes, the pubkey of an event must be one of these 12 | */ 13 | authors?: string[]; 14 | /** 15 | * a list of a kind numbers 16 | */ 17 | kinds?: number[]; 18 | /** 19 | * a list of event ids that are referenced in an "e" tag 20 | */ 21 | "#e"?: string[]; 22 | /** 23 | * a list of pubkeys that are referenced in a "p" tag 24 | */ 25 | "#p"?: string[]; 26 | /** 27 | * a list of tags that are referenced in a "t" tag 28 | */ 29 | "#t"?: string[]; 30 | /** 31 | * an integer unix timestamp in seconds, events must be newer than this to pass 32 | */ 33 | since?: number; 34 | /** 35 | * an integer unix timestamp in seconds, events must be older than this to pass 36 | */ 37 | until?: number; 38 | /** 39 | * maximum number of events to be returned in the initial query 40 | */ 41 | limit?: number; 42 | } 43 | -------------------------------------------------------------------------------- /packages/common/src/types/identifier-well-known.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Identifier Well-Known Response 3 | * 4 | * https:///.well-known/nostr.json?name= 5 | * https://github.com/nostr-protocol/nips/blob/master/05.md 6 | */ 7 | export interface IdentifierWellKnownResponse { 8 | names: { [name: string]: string }; 9 | relays?: { [pubKey: string]: string[] }; 10 | } 11 | -------------------------------------------------------------------------------- /packages/common/src/types/identity-claim.ts: -------------------------------------------------------------------------------- 1 | export enum IDENTITY_CLAIM_TYPE { 2 | GITHUB = "github", 3 | TWITTER = "twitter", 4 | MASTODON = "mastodon", 5 | TELEGRAM = "telegram", 6 | } 7 | 8 | /** 9 | * For more details see 10 | * https://github.com/nostr-protocol/nips/blob/master/39.md#claim-types 11 | */ 12 | export interface ExternalIdentityClaimBase { 13 | type: IDENTITY_CLAIM_TYPE; 14 | /** 15 | * Usually the username 16 | * - github: username 17 | * - twitter: username 18 | * - mastodon: domain.com/@username 19 | * - telegram: user ID 20 | */ 21 | identity: string; 22 | /** 23 | * Proof of ownership of the identity 24 | * - github: A GitHub Gist ID 25 | * - twitter: A tweet ID 26 | * - mastodon: A Mastodon post ID 27 | * - telegram: Channel / Message ID (ref/id) 28 | */ 29 | proof: string; 30 | } 31 | -------------------------------------------------------------------------------- /packages/common/src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./bech32"; 2 | export * from "./client-message"; 3 | export * from "./content"; 4 | export * from "./event-contacts"; 5 | export * from "./event-coordinates-tag"; 6 | export * from "./event-event-tag"; 7 | export * from "./event-kind"; 8 | export * from "./event-with-user"; 9 | export * from "./event"; 10 | export * from "./filter"; 11 | export * from "./identifier-well-known"; 12 | export * from "./identity-claim"; 13 | export * from "./lnurl-invoice"; 14 | export * from "./nips"; 15 | export * from "./publishing"; 16 | export * from "./relay-information"; 17 | export * from "./relay-message"; 18 | export * from "./relay"; 19 | export * from "./report-types"; 20 | export * from "./report"; 21 | export * from "./subscriptions"; 22 | export * from "./user-metadata"; 23 | export * from "./user-store"; 24 | export * from "./user"; 25 | export * from "./websocket"; 26 | export * from "./zap"; 27 | -------------------------------------------------------------------------------- /packages/common/src/types/lnurl-invoice.ts: -------------------------------------------------------------------------------- 1 | import { NEvent } from "../classes"; 2 | 3 | /** 4 | * Lnurl response 5 | * 6 | * This is the first hit on Lud16 / Lud06 URL 7 | * https://github.com/lnurl/luds/blob/luds/06.md#pay-to-static-qrnfclink 8 | */ 9 | export interface LnurlEndpointResponse { 10 | /** 11 | * The URL from LN SERVICE which will accept the pay request parameters 12 | */ 13 | callback: string; 14 | 15 | /** 16 | * Max millisatoshi amount LN SERVICE is willing to receive 17 | */ 18 | maxSendable: number; 19 | 20 | /** 21 | * Min millisatoshi amount LN SERVICE is willing to receive, can not be less than 1 or more than `maxSendable` 22 | */ 23 | minSendable: number; 24 | 25 | /** 26 | * Metadata json which must be presented as raw string here, this is required to pass signature verification at a later step 27 | */ 28 | metadata: string; 29 | 30 | /** 31 | * Type of LNURL 32 | */ 33 | tag: "payRequest"; 34 | 35 | /** 36 | * Whether the service accepts Nostr payments 37 | */ 38 | allowsNostr: boolean; 39 | 40 | /** 41 | * BIP 340 public key in hex 42 | */ 43 | nostrPubkey: string; 44 | 45 | // ?? 46 | disposable?: boolean | undefined; 47 | 48 | payerData?: { 49 | name: { 50 | mandatory: boolean; 51 | }; 52 | identifier: { 53 | mandatory: boolean; 54 | }; 55 | }; 56 | } 57 | 58 | /** 59 | * Lnurl invoice response 60 | * 61 | * This is the 2nd hit on new callback URL with invoice params 62 | */ 63 | export interface LnurlInvoiceResponse { 64 | /** 65 | * bech32-serialized lightning invoice 66 | */ 67 | pr: string; 68 | 69 | /** 70 | * an empty array 71 | */ 72 | relays?: string[]; 73 | 74 | // TODO: Check if this is correct (from actual response) 75 | routes?: string[]; 76 | 77 | // ? 78 | disposable?: boolean | undefined; 79 | 80 | status?: string; 81 | error?: string; 82 | successAction?: { 83 | tag: string; 84 | message: string; 85 | }; 86 | } 87 | 88 | /** 89 | * Function result when calling makeZapRequest on user 90 | */ 91 | export interface UserZapRequestResponse extends LnurlInvoiceResponse { 92 | event: NEvent; 93 | } 94 | -------------------------------------------------------------------------------- /packages/common/src/types/publishing.ts: -------------------------------------------------------------------------------- 1 | import { NEvent } from "../classes"; 2 | 3 | export interface PublishingRequest { 4 | id?: string; 5 | event: NEvent; 6 | /** 7 | * Manually set the POW value 8 | */ 9 | pow?: number; 10 | relayUrls?: string[]; 11 | } 12 | 13 | export interface PublishingQueueItem 14 | extends Omit { 15 | id: string; 16 | relayUrl: string; 17 | send: boolean; 18 | accepted?: boolean; 19 | powStart?: number; 20 | powDone?: number; 21 | error?: string; 22 | } 23 | -------------------------------------------------------------------------------- /packages/common/src/types/relay-information.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Server limitations 3 | * https://github.com/nostr-protocol/nips/blob/master/11.md#extra-fields 4 | */ 5 | export interface ServerLimitations { 6 | /** 7 | * this is the maximum number of bytes for incoming JSON that the relay will attempt to decode and act upon 8 | */ 9 | max_message_length: number; 10 | /** 11 | * total number of subscriptions that may be active on a single websocket connection to this relay 12 | */ 13 | max_subscriptions: number; 14 | /** 15 | * maximum number of filter values in each subscription. Must be one or higher. 16 | */ 17 | max_filters: number; 18 | /** 19 | * max_limit: the relay server will clamp each filter's limit value to this number. 20 | */ 21 | max_limit: number; 22 | /** 23 | * maximum length of subscription id as a string. 24 | */ 25 | max_subid_length: number; 26 | /** 27 | * for authors and ids filters which are to match against a hex prefix, you must provide at least this many hex digits in the prefix. 28 | */ 29 | min_prefix: number; 30 | /** 31 | * in any event, this is the maximum number of elements in the tags list. 32 | */ 33 | max_event_tags: number; 34 | /** 35 | * maximum number of characters in the content field of any event. 36 | */ 37 | max_content_length: number; 38 | /** 39 | * new events will require at least this difficulty of PoW, based on NIP-13, or they will be rejected by this server. 40 | */ 41 | min_pow_difficulty: number; 42 | /** 43 | * this relay requires NIP-42 authentication to happen before a new connection may perform any other action 44 | */ 45 | auth_required: boolean; 46 | /** 47 | * this relay requires payment before a new connection may perform any action. 48 | */ 49 | payment_required: boolean; 50 | } 51 | 52 | export interface Fee { 53 | amount: number; 54 | unit: string; 55 | } 56 | 57 | export interface Fees { 58 | admission: Fee[]; 59 | } 60 | 61 | /** 62 | * Relay information document 63 | * https://github.com/nostr-protocol/nips/blob/master/11.md 64 | */ 65 | export interface RelayInformationDocument { 66 | name?: string; 67 | description?: string; 68 | fees?: Fees; 69 | pubkey?: string; 70 | contact?: string; 71 | supported_nips?: number[]; 72 | software?: string; 73 | version?: string; 74 | limitations?: ServerLimitations; 75 | payments_url?: string; 76 | } 77 | 78 | // TODO: Implement extra fields 79 | -------------------------------------------------------------------------------- /packages/common/src/types/relay.ts: -------------------------------------------------------------------------------- 1 | import { RelayInformationDocument } from "../"; 2 | 3 | export interface Relay { 4 | url: string; 5 | read: boolean; 6 | write: boolean; 7 | } 8 | 9 | export interface DiscoveredRelay extends Relay { 10 | info: RelayInformationDocument; 11 | } 12 | 13 | export interface DiscoveredRelayWithError extends Relay { 14 | error?: string; 15 | } 16 | -------------------------------------------------------------------------------- /packages/common/src/types/report-types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Report type 3 | * https://github.com/nostr-protocol/nips/blob/master/56.md#tags 4 | */ 5 | export enum NREPORT_KIND { 6 | NUDITY = "nudity", 7 | PROFANITY = "profanity", 8 | ILLEGAL = "illegal", 9 | SPAM = "spam", 10 | IMPERSONATION = "impersonation", 11 | } 12 | -------------------------------------------------------------------------------- /packages/common/src/types/report.ts: -------------------------------------------------------------------------------- 1 | import { NREPORT_KIND } from "./report-types"; 2 | 3 | /** 4 | * Reporting 5 | * https://github.com/nostr-protocol/nips/blob/master/56.md 6 | */ 7 | export interface EventReport { 8 | /** 9 | * If reporting a note, an e tag MUST also be included referencing the note id. 10 | */ 11 | eventId?: string; 12 | /** 13 | * A report type string MUST be included as the 3rd entry to the e or p tag being reported, 14 | */ 15 | kind: NREPORT_KIND; 16 | /** 17 | * The report event MUST include a p tag referencing the pubkey of the user you are reporting. 18 | */ 19 | publicKey: string; 20 | content?: string; 21 | } 22 | -------------------------------------------------------------------------------- /packages/common/src/types/subscriptions.ts: -------------------------------------------------------------------------------- 1 | import { CLIENT_MESSAGE_TYPE } from "./client-message"; 2 | import { FiltersBase } from "./filter"; 3 | 4 | export interface SubscriptionOptions { 5 | /** 6 | * Timeout in ms 7 | */ 8 | timeoutIn: number; 9 | /** 10 | * Set when the subscription is supposed to timeout 11 | * Set 0 to disable 12 | */ 13 | timeoutAt?: number; 14 | /** 15 | * Set an associated timeout 16 | * SET the result of setTimeout 17 | */ 18 | timeout?: number | any; 19 | /** 20 | * If the subscription is associated with a view 21 | * for ex. `welcome` or `profile: 22 | */ 23 | view?: string; 24 | // TODO 25 | unsubscribeOnEose?: boolean; 26 | /** 27 | * Doesn't save to DB 28 | */ 29 | isLive?: boolean; 30 | } 31 | 32 | /** 33 | * Request a new subscription 34 | */ 35 | export interface SubscriptionRequest { 36 | type: CLIENT_MESSAGE_TYPE; 37 | options?: SubscriptionOptions; 38 | relayUrls?: string[]; 39 | } 40 | 41 | export interface EventsRequest extends SubscriptionRequest { 42 | type: CLIENT_MESSAGE_TYPE.REQ; 43 | filters: FiltersBase; 44 | options: SubscriptionOptions; 45 | } 46 | 47 | export interface CountRequest extends SubscriptionRequest { 48 | type: CLIENT_MESSAGE_TYPE.COUNT; 49 | filters: FiltersBase; 50 | } 51 | 52 | export interface AuthRequest extends SubscriptionRequest { 53 | type: CLIENT_MESSAGE_TYPE.AUTH; 54 | signedEvent: string; 55 | } 56 | 57 | export interface CloseRequest extends SubscriptionRequest { 58 | type: CLIENT_MESSAGE_TYPE.CLOSE; 59 | subscriptionId: string; 60 | } 61 | 62 | /** 63 | * Active subscription 64 | */ 65 | export interface Subscription extends Omit { 66 | id: string; 67 | type: CLIENT_MESSAGE_TYPE; 68 | filters?: FiltersBase; 69 | options?: SubscriptionOptions; 70 | created: number; 71 | eose?: boolean; 72 | isActive: boolean; 73 | result?: string | number; 74 | error?: string; 75 | } 76 | 77 | export interface RelaySubscription extends Subscription { 78 | relayUrl: string; 79 | } 80 | -------------------------------------------------------------------------------- /packages/common/src/types/user-metadata.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * User metadata for event content field 3 | * 4 | * Some of these are official 5 | * other's I've seen in the wild 6 | * 7 | * https://github.com/nostr-protocol/nips/blob/master/01.md#basic-event-kinds 8 | * > stringified JSON object {name: , about: , picture: } 9 | * > describing the user who created the event 10 | */ 11 | export interface UserMetadata { 12 | name?: string; 13 | display_name?: string; 14 | picture?: string; 15 | banner?: string; 16 | /** 17 | * Static internet identifiers. 18 | * @ 19 | * 20 | * https://github.com/nostr-protocol/nips/blob/master/05.md 21 | */ 22 | nip05?: string; 23 | website?: string; 24 | about?: string; 25 | image?: string; 26 | 27 | npub?: string; 28 | 29 | /** 30 | * Static internet identifiers. 31 | * @ 32 | * 33 | * https://github.com/lnurl/luds/blob/luds/16.md 34 | */ 35 | lud16?: string; 36 | 37 | /** 38 | * bech32 encoded lnurl with the prefix `lnurl` 39 | * ex:. lnurl.... 40 | */ 41 | lud06?: string; 42 | } 43 | -------------------------------------------------------------------------------- /packages/common/src/types/user-store.ts: -------------------------------------------------------------------------------- 1 | import { NUserBase } from "../classes/user"; 2 | import { EventBase } from "./event"; 3 | 4 | /** 5 | * User store skeleton 6 | * Base for all implementations 7 | */ 8 | export interface UserStoreBase { 9 | /** 10 | * in-memory only 11 | */ 12 | users?: NUserBase[]; 13 | 14 | /** 15 | * Not all stores are available on all platforms. 16 | */ 17 | store: "memory" | "indexeddb" | "sqlite"; 18 | 19 | byPubkey( 20 | pubkey: string 21 | ): NUserBase | undefined | Promise; 22 | add(user: NUserBase): void; 23 | addFromEvent(event: EventBase): void; 24 | update(user: NUserBase): void; 25 | remove(pubkey: string): void; 26 | } 27 | -------------------------------------------------------------------------------- /packages/common/src/types/user.ts: -------------------------------------------------------------------------------- 1 | import { ExternalIdentityClaim, NFilters } from "../classes"; 2 | import { EventBase, iNewZAPRequest } from "./event"; 3 | import { IdentifierWellKnownResponse } from "./identifier-well-known"; 4 | import { LnurlEndpointResponse, UserZapRequestResponse } from "./lnurl-invoice"; 5 | import { UserMetadata } from "./user-metadata"; 6 | 7 | export interface UserBase { 8 | pubkey: string; 9 | claims?: ExternalIdentityClaim[]; 10 | data?: UserMetadata; 11 | 12 | /** 13 | * Set from successfull makeZapRequest 14 | */ 15 | lightningZapInfo?: LnurlEndpointResponse; 16 | lastUpdated?: number; 17 | 18 | /** 19 | * Set after checking https:///.well-known/nostr.json?name= 20 | * https://github.com/nostr-protocol/nips/blob/master/05.md 21 | */ 22 | nip05isValid?: boolean; 23 | 24 | loaded?(): boolean; 25 | hasZapInfo?(): boolean; 26 | 27 | fromPublicKey?: (pubkey: string) => UserBase; 28 | 29 | /** 30 | * 31 | * @param event 32 | * @returns undefined if invalid 33 | */ 34 | fromEvent?: (event: EventBase) => UserBase; 35 | 36 | /** 37 | * Url to check user identifier 38 | * https:///.well-known/nostr.json?name= 39 | * @returns 40 | */ 41 | getNip05Url?: () => string | undefined; 42 | 43 | /** 44 | * Pass response from https:///.well-known/nostr.json?name= 45 | * to validate nip05 46 | * @param payload 47 | * @returns 48 | */ 49 | validateWellKnown?: (payload: IdentifierWellKnownResponse) => boolean; 50 | 51 | /** 52 | * Url to send payment request 53 | * http:///.well-known/lnurlp/ 54 | * @returns 55 | */ 56 | getLud16Url?: () => string | undefined; 57 | 58 | /** 59 | * Url to send payment request 60 | * http:///.well-known/lnurlp/ 61 | * @returns 62 | */ 63 | getLud06Url?: () => string | undefined; 64 | 65 | /** 66 | * Shortcut; returns lud16 first if found 67 | * @returns 68 | */ 69 | getLud16Or06Url?: () => 70 | | { 71 | type: "lud16" | "lud06"; 72 | url: string; 73 | } 74 | | undefined; 75 | 76 | /** 77 | * A signed zap request event is not published, 78 | * but is instead sent using a HTTP GET request to the recipient's callback url, which was provided by the recipient's lnurl pay endpoint. 79 | * 80 | * https://github.com/nostr-protocol/nips/blob/master/57.md#appendix-b-zap-request-http-request 81 | * @param opts 82 | * @returns 83 | */ 84 | makeZapRequest?: ( 85 | opts: iNewZAPRequest, 86 | keypair 87 | ) => Promise; 88 | 89 | makeNIP05Request?: () => Promise; 90 | 91 | /** 92 | * - Author: user publiv key 93 | * - Kind: 0 94 | * @returns 95 | */ 96 | getMetadataFilter?: () => NFilters; 97 | } 98 | -------------------------------------------------------------------------------- /packages/common/src/types/websocket.ts: -------------------------------------------------------------------------------- 1 | import { Relay } from "./relay"; 2 | import { RelayInformationDocument } from "./relay-information"; 3 | import { 4 | RelayAuth, 5 | RelayCount, 6 | RelayEose, 7 | RelayEvent, 8 | RelayNotice, 9 | RelayOK, 10 | } from "./relay-message"; 11 | 12 | export interface WebSocketEvent { 13 | data: RelayAuth | RelayCount | RelayEose | RelayEvent | RelayNotice | RelayOK; 14 | meta: WebSocketClientConfig; 15 | } 16 | 17 | export interface WebSocketClientBase { 18 | /** 19 | * Websocket 20 | * Plugin from WebSocketClient 21 | * Init from RelayClient 22 | */ 23 | connection: any; 24 | error: { 25 | error: any; 26 | message: string; 27 | type: string; 28 | } | null; 29 | 30 | /** 31 | * Actually make the connection 32 | */ 33 | connect: ( 34 | url: string, 35 | options?: { 36 | rejectUnauthorized?: boolean; 37 | } 38 | ) => void; 39 | 40 | /** 41 | * Check if WS is connected 42 | * This should query the actual connection status 43 | * connection.readyState === connection.OPEN 44 | */ 45 | isConnected: () => boolean; 46 | 47 | sendMessage: ( 48 | data: string, 49 | options?: { 50 | retries: number; 51 | retryTimeout: number; 52 | retryCount: number; 53 | } 54 | ) => void; 55 | listen: ( 56 | onMessage: ( 57 | data: 58 | | RelayAuth 59 | | RelayCount 60 | | RelayEose 61 | | RelayEvent 62 | | RelayNotice 63 | | RelayOK 64 | ) => void 65 | ) => void; 66 | disconnect: () => void; 67 | } 68 | 69 | export interface WebSocketClientConfig extends Relay { 70 | url: string; 71 | read: boolean; 72 | write: boolean; 73 | info?: RelayInformationDocument; 74 | } 75 | 76 | export interface WebSocketClientInfo extends WebSocketClientConfig { 77 | url: string; 78 | /** 79 | * Undefined or 0 means no PoW required 80 | * This is a manual overwrite if the relay has a POW requirement but doesn't publish it 81 | */ 82 | powRequired?: number; 83 | isReady: boolean; 84 | error: string | undefined; 85 | } 86 | 87 | export interface WebSocketClientConnection extends WebSocketClientConfig { 88 | url: string; 89 | ws?: WebSocketClientBase; 90 | 91 | isEnabled: boolean; 92 | } 93 | -------------------------------------------------------------------------------- /packages/common/src/types/zap.ts: -------------------------------------------------------------------------------- 1 | export interface ZapTag { 2 | receiverPubKey: string; 3 | relayUrlForUserMeta: string; 4 | weight: number; 5 | } 6 | -------------------------------------------------------------------------------- /packages/common/src/utils/bolt11.ts: -------------------------------------------------------------------------------- 1 | import { decode, DecodedInvoice } from "light-bolt11-decoder"; 2 | 3 | /** 4 | * Decodes a lightning pay request (BOLT11) 5 | * @param req 6 | * @returns 7 | */ 8 | export function decodeLightnightPayRequest(req: string): DecodedInvoice { 9 | return decode(req); 10 | } 11 | 12 | export interface SecionWithAmount { 13 | name: "amount"; 14 | letters: string; 15 | value: string 16 | } 17 | 18 | // TODO: Matching wrong secion: Property 'value' does not exist on type 'Section' 19 | export function findLightningPayRequestSectionWithAmount(invoice: DecodedInvoice): SecionWithAmount | undefined { 20 | const sMatch = invoice.sections.find((s) => s.name === "amount") as SecionWithAmount; 21 | if (sMatch && sMatch.value) { 22 | return sMatch; 23 | } 24 | } -------------------------------------------------------------------------------- /packages/common/src/utils/event-amount.ts: -------------------------------------------------------------------------------- 1 | import { EventBase } from "../types"; 2 | 3 | /** 4 | * Get event amount(s) or undefined 5 | * Spec: https://github.com/nostr-protocol/nips/blob/master/57.md 6 | * 7 | * @param event 8 | * @returns 9 | */ 10 | export function eventHasAmountTags(event: EventBase): string[] { 11 | const tags = event.tags.filter((tag) => tag[0] === "amount"); 12 | if (tags.length === 0) { 13 | return; 14 | } 15 | const amounts = []; 16 | for (const tag of tags) { 17 | if (tag.length === 2) { 18 | amounts.push(tag[1]); 19 | } 20 | } 21 | return amounts && amounts.length > 0 ? amounts : undefined; 22 | } 23 | 24 | /** 25 | * Create amount tag 26 | * Spec: https://github.com/nostr-protocol/nips/blob/master/57.md 27 | * 28 | * @param amount millisatoshis 29 | */ 30 | export function makeEventAmountTag(amount: string): string[] { 31 | return ["amount", amount]; 32 | } 33 | -------------------------------------------------------------------------------- /packages/common/src/utils/event-contact.ts: -------------------------------------------------------------------------------- 1 | import { EventBase, NEVENT_KIND } from "../types"; 2 | import { NEventContact } from "../types/event-contacts"; 3 | 4 | export function eventHasContacts( 5 | event: EventBase 6 | ): NEventContact[] | undefined { 7 | if (event.kind !== NEVENT_KIND.CONTACTS) { 8 | return; 9 | } 10 | const tags = event.tags.filter((tag) => tag[0] === "p"); 11 | if (tags.length === 0) { 12 | return; 13 | } 14 | return tags.map((tag) => { 15 | let contact: NEventContact; 16 | if (tag.length === 2) { 17 | contact = { 18 | key: tag[1], 19 | }; 20 | return contact; 21 | } else if (tag.length === 3) { 22 | contact = { 23 | key: tag[1], 24 | relayUrl: tag[2], 25 | }; 26 | return contact; 27 | } else if (tag.length === 4) { 28 | contact = { 29 | key: tag[1], 30 | relayUrl: tag[2], 31 | petname: tag[3], 32 | }; 33 | return contact; 34 | } 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /packages/common/src/utils/event-content-warning.ts: -------------------------------------------------------------------------------- 1 | import { EventBase } from "../types"; 2 | 3 | /** 4 | * Get event content warning reason (may be "") or undefined 5 | * Spec: https://github.com/nostr-protocol/nips/blob/master/36.md 6 | * 7 | * @returns reason for content warning or undefined 8 | */ 9 | export function eventHasContentWarning(event: EventBase): string | undefined { 10 | if (!event.tags) return undefined; 11 | 12 | let hasContentWarning = false; 13 | let contentWarningReason = ""; 14 | 15 | for (const tag of event.tags) { 16 | // Support warning for ["L", "content-warning"] and ["l", "reason", "content-warning"] formats 17 | const warning = tag.find((t) => t === "content-warning"); 18 | if (warning) { 19 | hasContentWarning = true; 20 | // Support ["content-warning", "reason"] format 21 | if (tag.length === 2 && tag[0] === "content-warning") { 22 | contentWarningReason = tag[1]; 23 | } 24 | 25 | // Support ["l", "reason", "content-warning"] format 26 | else if ( 27 | tag.length === 3 && 28 | tag[2] === "content-warning" && 29 | tag[0] === "l" 30 | ) { 31 | contentWarningReason = tag[1]; 32 | } else { 33 | contentWarningReason = "N/A"; 34 | } 35 | } 36 | } 37 | 38 | return hasContentWarning ? contentWarningReason : undefined; 39 | } 40 | -------------------------------------------------------------------------------- /packages/common/src/utils/event-coordinates.ts: -------------------------------------------------------------------------------- 1 | import { EventBase, EventCoordinatesTag } from "../types"; 2 | 3 | /** 4 | * Extract event coordinates from tags 5 | * 6 | * ["a", "::"] 7 | * ["a", "::", ""] 8 | */ 9 | function eventCoordinatesFromTags( 10 | tags: any[] 11 | ): EventCoordinatesTag[] | undefined { 12 | if (!tags) { 13 | return; 14 | } 15 | // Define result array 16 | let result: EventCoordinatesTag[] = []; 17 | 18 | // Loop through each tag 19 | for (let tag of tags) { 20 | // Check that the tag is an array and has the correct length 21 | if (!Array.isArray(tag) || tag.length < 2 || tag.length > 3) { 22 | continue; 23 | } 24 | 25 | // Check that the first element of the tag array is the string "a" 26 | if (tag[0] !== "a") { 27 | continue; 28 | } 29 | 30 | // Split the second element of the tag array on ":" to get kind, pubkey, and identifier 31 | let parts = tag[1].split(":"); 32 | 33 | // Check that the split operation produced exactly 3 parts 34 | if (parts.length !== 3) { 35 | continue; 36 | } 37 | 38 | // Extract kind, pubkey, and identifier from parts 39 | let kind = parts[0]; 40 | let pubkey = parts[1]; 41 | let identifier = parts[2]; 42 | 43 | // Initialize relay as undefined 44 | let relay; 45 | 46 | // If there is a third element in the tag array, set relay to that element 47 | if (tag.length === 3) { 48 | relay = tag[2]; 49 | } 50 | 51 | // Add this tag to the result array 52 | result.push({ 53 | kind: kind, 54 | pubkey: pubkey, 55 | identifier: identifier, 56 | relay: relay, 57 | }); 58 | } 59 | 60 | if (result.length === 0) { 61 | return; 62 | } 63 | 64 | // Return result array 65 | return result; 66 | } 67 | 68 | /** 69 | * Get event coordinates or undefined 70 | */ 71 | export function eventHasEventCoordinatesTags( 72 | event: EventBase 73 | ): EventCoordinatesTag[] | undefined { 74 | const coordinates = eventCoordinatesFromTags(event.tags); 75 | if (!coordinates) { 76 | return; 77 | } 78 | return coordinates; 79 | } 80 | 81 | /** 82 | * Make event coordinates tag 83 | * @param opts 84 | * @returns 85 | */ 86 | export function makeEventCoordinatesTag(opts: EventCoordinatesTag) { 87 | const { kind, pubkey, identifier, relay } = opts; 88 | if (relay) { 89 | return [`a:${kind}:${pubkey}:${identifier}, ${relay}`]; 90 | } else { 91 | return [`a:${kind}:${pubkey}:${identifier}`]; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /packages/common/src/utils/event-event.ts: -------------------------------------------------------------------------------- 1 | import { EventBase } from "../types/event"; 2 | import { EventEventTag } from "../types/event-event-tag"; 3 | 4 | /** 5 | * Get marked event "e" tags (PREFERRED) 6 | * 7 | * ["e", ] 8 | * ["e", , ] 9 | * ['e', 'eventId', 'relayUrl', 'marker'] 10 | * @param event 11 | */ 12 | export function eventHasEventTags( 13 | event: EventBase 14 | ): EventEventTag[] | undefined { 15 | const tags = event.tags.filter((tag) => tag[0] === "e"); 16 | if (tags.length === 0) return undefined; 17 | const evTags: EventEventTag[] = []; 18 | for (const tag of tags) { 19 | if (tag.length === 2) { 20 | evTags.push({ 21 | eventId: tag[1], 22 | }); 23 | } else if (tag.length === 3) { 24 | evTags.push({ 25 | eventId: tag[1], 26 | relayUrl: tag[2], 27 | }); 28 | } else if (tag.length === 4) { 29 | evTags.push({ 30 | eventId: tag[1], 31 | relayUrl: tag[2], 32 | marker: tag[3] as "reply" | "root" | "mention", 33 | }); 34 | } 35 | } 36 | return evTags && evTags.length > 0 ? evTags : undefined; 37 | } 38 | 39 | /** 40 | * Get positional event "e" tags (DEPRECATED) 41 | */ 42 | export function eventHasPositionalEventTags(event: EventBase) { 43 | const tags = event.tags.filter((tag) => tag[0] === "e"); 44 | if (tags.length === 0) return undefined; 45 | const evTags: EventEventTag[] = []; 46 | 47 | for (let i = 0; i < tags.length; i++) { 48 | if (i === 0) { 49 | evTags.push({ 50 | eventId: tags[i][1], 51 | relayUrl: "", 52 | marker: "root", 53 | }); 54 | } 55 | 56 | if (tags.length === 2) { 57 | if (i === 1) { 58 | evTags.push({ 59 | eventId: tags[i][1], 60 | relayUrl: "", 61 | marker: "reply", 62 | }); 63 | } 64 | } else if (tags.length > 2) { 65 | if (i === 1) { 66 | evTags.push({ 67 | eventId: tags[i][1], 68 | relayUrl: "", 69 | marker: "mention", 70 | }); 71 | } else if (i > 1) { 72 | evTags.push({ 73 | eventId: tags[i][1], 74 | relayUrl: "", 75 | marker: "reply", 76 | }); 77 | } 78 | } 79 | } 80 | 81 | return evTags && evTags.length > 0 ? evTags : undefined; 82 | } 83 | 84 | /** 85 | * Positional "e" tags (DEPRECATED) 86 | * Basically checks whether the event is using the deprecated format 87 | * 88 | * This is a boolean check that doesn't return the tags 89 | * I use this primarily to differentiate between the formats 90 | */ 91 | export function eventHasPositionalEventTag(event: EventBase): boolean { 92 | if (!event.tags || event.tags.length === 0) return false; 93 | const tags = event.tags.filter((tag) => tag[0] === "e" && tag.length > 2); 94 | // If none have been found, 95 | if (tags.length === 0) return true; 96 | return false; 97 | } 98 | -------------------------------------------------------------------------------- /packages/common/src/utils/event-expiration.ts: -------------------------------------------------------------------------------- 1 | import { EventBase } from "../types"; 2 | 3 | /** 4 | * Check array of tags if any are NIP-40 5 | * @returns expiration time or undefined 6 | */ 7 | export function eventHasExpiration(event: EventBase): number | undefined { 8 | const expirationTags = event.tags.filter((tag) => tag[0] === "expiration"); 9 | if (expirationTags.length === 0) { 10 | return; 11 | } 12 | return parseInt(expirationTags[0][1]); 13 | } 14 | -------------------------------------------------------------------------------- /packages/common/src/utils/event-identifier.ts: -------------------------------------------------------------------------------- 1 | import { EventBase } from "../types"; 2 | 3 | export function eventHasIdentifierTags(event: EventBase): string[] | undefined { 4 | const identifiers: string[] = []; 5 | const identifierTags = event.tags.filter((tag) => tag[0] === "d"); 6 | 7 | if (identifierTags.length === 0) { 8 | return undefined; 9 | } else { 10 | for (const tag of identifierTags) { 11 | let identifierValue = tag[1] || ""; // If value is not defined, use an empty string 12 | 13 | // If this identifier value is not already in the array, add it 14 | if (!identifiers.includes(identifierValue)) { 15 | identifiers.push(identifierValue); 16 | } 17 | } 18 | } 19 | 20 | return identifiers && identifiers.length > 0 ? identifiers : undefined; 21 | } 22 | 23 | export function makeEventIdentifierTag(identifier: string) { 24 | return ["d", identifier]; 25 | } 26 | -------------------------------------------------------------------------------- /packages/common/src/utils/event-labels.ts: -------------------------------------------------------------------------------- 1 | import { EventBase } from "../types"; 2 | 3 | /** 4 | * Check array of tags if any are NIP-32 5 | * https://github.com/nostr-protocol/nips/blob/master/32.md 6 | * 7 | * @returns label tags or undefined 8 | */ 9 | export function eventHasLabels(event: EventBase): 10 | | { 11 | labelTags: string[][]; 12 | labelNamespace: string[][]; 13 | } 14 | | undefined { 15 | const labelTags = event.tags.filter((tag) => tag[0] === "l"); 16 | const labelNamespace = event.tags.filter((tag) => tag[0] === "L"); 17 | if (labelTags.length === 0 && labelNamespace.length === 0) { 18 | return; 19 | } 20 | return { 21 | labelTags, 22 | labelNamespace, 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /packages/common/src/utils/event-lnurl.ts: -------------------------------------------------------------------------------- 1 | import { EventBase } from "../types"; 2 | 3 | export function eventHasLnurlTags(event: EventBase): string[] | undefined { 4 | const tags = event.tags.filter((tag) => tag[0] === "lnurl"); 5 | if (tags.length === 0) { 6 | return; 7 | } 8 | const lnurls = []; 9 | for (const tag of tags) { 10 | if (tag.length > 0) { 11 | lnurls.push(tag[1]); 12 | } 13 | } 14 | return lnurls && lnurls.length > 0 ? lnurls : undefined; 15 | } 16 | 17 | export function makeEventLnurlTag(lnurl: string) { 18 | return ["lnurl", lnurl]; 19 | } 20 | -------------------------------------------------------------------------------- /packages/common/src/utils/event-nonce.ts: -------------------------------------------------------------------------------- 1 | import { NEvent } from "../classes/event"; 2 | import { EventBase } from "../types/event"; 3 | 4 | /** 5 | * Check array of tags if any nonce tags are present 6 | * https://github.com/nostr-protocol/nips/blob/master/13.md 7 | */ 8 | export function eventHasNonce(event: EventBase): [number, number] | undefined { 9 | const nonceTags = event.tags.filter((tag) => tag[0] === "nonce"); 10 | if (nonceTags.length === 0) { 11 | return; 12 | } 13 | return [parseInt(nonceTags[0][1]), parseInt(nonceTags[0][2])]; 14 | } 15 | 16 | /** 17 | * Add nonce tag to event 18 | */ 19 | export function eventAddNonceTag(event: NEvent, nonce: number[]) { 20 | if (event.hasNonceTag()) { 21 | throw new Error("Event already has a nonce."); 22 | } 23 | if (nonce.length !== 2) { 24 | throw new Error( 25 | "Nonce must be an array of 2 numbers: [miningResult, difficulty]" 26 | ); 27 | } 28 | const miningResult = nonce[0].toString(); 29 | const difficulty = nonce[1].toString(); 30 | 31 | event.addTag(["nonce", miningResult, difficulty]); 32 | 33 | return event; 34 | } 35 | 36 | /** 37 | * Replace nonce tag on event 38 | */ 39 | export function eventReplaceNonceTag(event: NEvent, nonce: number[]) { 40 | event.tags = event.tags.filter((tag) => tag[0] !== "nonce"); 41 | event.addNonceTag(nonce); 42 | return event; 43 | } 44 | -------------------------------------------------------------------------------- /packages/common/src/utils/event-publickey.test.ts: -------------------------------------------------------------------------------- 1 | import { NEvent } from "../classes"; 2 | 3 | test("event-publickey", () => { 4 | const ev = new NEvent({ 5 | tags: [ 6 | ["p", "123"], 7 | ["p", "123", "456"], 8 | ], 9 | }); 10 | 11 | const tags = ev.hasPublicKeyTags(); 12 | expect(tags).toEqual([["123"], ["123", "456"]]); 13 | }); 14 | -------------------------------------------------------------------------------- /packages/common/src/utils/event-publickey.ts: -------------------------------------------------------------------------------- 1 | import { EventBase } from "../types"; 2 | 3 | /** 4 | * 5 | * Tag may be either 6 | * - [p, ] 7 | * - [p, , relayUrl] 8 | */ 9 | export function eventHasPublicKeyTags( 10 | event: EventBase 11 | ): [string][] | [string, string][] | undefined { 12 | const pubkeyTags = event.tags.filter((tag) => tag[0] === "p"); 13 | if (pubkeyTags.length === 0) { 14 | return; 15 | } 16 | const tags = []; 17 | for (const tag of pubkeyTags) { 18 | if (tag.length === 2) { 19 | tags.push([tag[1]]); 20 | } else if (tag.length === 3) { 21 | tags.push([tag[1], tag[2]]); 22 | } 23 | } 24 | return tags; 25 | } 26 | -------------------------------------------------------------------------------- /packages/common/src/utils/event-recommendation.ts: -------------------------------------------------------------------------------- 1 | import { NEVENT_KIND, EventBase } from "../types"; 2 | import { isValidWebSocketUrl } from "./websocket-url"; 3 | 4 | /** 5 | * Check if the event is / has a relay recommentation 6 | * 7 | */ 8 | export function eventHasRelayRecommendation( 9 | event: EventBase 10 | ): string | undefined { 11 | if (event.kind !== NEVENT_KIND.RECOMMEND_RELAY) { 12 | return; 13 | } 14 | 15 | return isValidWebSocketUrl(event.content) ? event.content : undefined; 16 | } 17 | -------------------------------------------------------------------------------- /packages/common/src/utils/event-relays.ts: -------------------------------------------------------------------------------- 1 | import { EventBase } from "../types"; 2 | 3 | export interface EventRelayTag { 4 | url: string; 5 | read?: boolean; 6 | write?: boolean; 7 | } 8 | 9 | /** 10 | * Get event relay tags or undefined 11 | * usually used on kind:10002 12 | * 13 | * Spec: https://github.com/nostr-protocol/nips/blob/master/65.md#relay-list-metadata 14 | */ 15 | export function eventHasRelaysTag( 16 | event: EventBase 17 | ): EventRelayTag[] | undefined { 18 | const relayTags = event.tags.filter((tag) => tag[0] === "relays"); 19 | if (relayTags.length === 0) return; 20 | 21 | const tags: EventRelayTag[] = []; 22 | for (const tag of relayTags) { 23 | if (tag.length === 2) { 24 | tags.push({ 25 | url: tag[1], 26 | read: true, 27 | write: true, 28 | }); 29 | } else if (tag.length === 3) { 30 | tags.push({ 31 | url: tag[1], 32 | read: tag[2] === "read", 33 | write: tag[2] === "write", 34 | }); 35 | } 36 | } 37 | return tags.length > 0 ? tags : undefined; 38 | } 39 | -------------------------------------------------------------------------------- /packages/common/src/utils/event-reporting.test.ts: -------------------------------------------------------------------------------- 1 | import { EventReport, NREPORT_KIND } from "../types"; 2 | import { NEvent } from "../classes"; 3 | import { eventHasReport, generateReportTags } from "./event-reporting"; 4 | 5 | test("eventHasReport", () => { 6 | const ev = new NEvent({ 7 | kind: 1984, 8 | content: "Broke local law", 9 | tags: [ 10 | [ 11 | "e", 12 | "1234567890123456789012345678901234567890123456789012345678901234", 13 | "illegal", 14 | ], 15 | ["p", "1234567890123456789012345678901234567890123456789012345678901234"], 16 | ], 17 | }); 18 | const hasReport = eventHasReport(ev); 19 | expect(hasReport).toEqual({ 20 | eventId: "1234567890123456789012345678901234567890123456789012345678901234", 21 | kind: "illegal", 22 | publicKey: 23 | "1234567890123456789012345678901234567890123456789012345678901234", 24 | content: "Broke local law", 25 | }); 26 | expect(hasReport).toEqual(ev.hasReportTags()); 27 | }); 28 | 29 | test("eventHasReport: impersonation", () => { 30 | const ev = new NEvent({ 31 | kind: 1984, 32 | content: 33 | "Profile is impersonating nostr:2234567890123456789012345678901234567890123456789012345678901234", 34 | tags: [ 35 | [ 36 | "p", 37 | "1234567890123456789012345678901234567890123456789012345678901234", 38 | "impersonation", 39 | ], 40 | ], 41 | }); 42 | const hasReport = eventHasReport(ev); 43 | expect(hasReport).toEqual({ 44 | kind: "impersonation", 45 | publicKey: 46 | "1234567890123456789012345678901234567890123456789012345678901234", 47 | content: 48 | "Profile is impersonating nostr:2234567890123456789012345678901234567890123456789012345678901234", 49 | }); 50 | expect(hasReport).toEqual(ev.hasReportTags()); 51 | }); 52 | 53 | test("generateReportTags: impersonation", () => { 54 | const report: EventReport = { 55 | kind: NREPORT_KIND.IMPERSONATION, 56 | publicKey: 57 | "1234567890123456789012345678901234567890123456789012345678901234", 58 | }; 59 | const tags = generateReportTags(report); 60 | expect(tags).toEqual([["p", report.publicKey, report.kind]]); 61 | }); 62 | 63 | test("generateReportTags: event", () => { 64 | const report: EventReport = { 65 | kind: NREPORT_KIND.ILLEGAL, 66 | publicKey: 67 | "1234567890123456789012345678901234567890123456789012345678901234", 68 | eventId: "1234567890123456789012345678901234567890123456789012345678901234", 69 | }; 70 | const tags = generateReportTags(report); 71 | expect(tags).toEqual([ 72 | ["e", report.eventId, report.kind], 73 | ["p", report.publicKey], 74 | ]); 75 | }); 76 | -------------------------------------------------------------------------------- /packages/common/src/utils/event-reporting.ts: -------------------------------------------------------------------------------- 1 | import { EventBase, NEVENT_KIND, EventReport, NREPORT_KIND } from "../types"; 2 | 3 | /** 4 | * Extracts a report from an event 5 | * This will throw an error if event is not a report 6 | * 7 | * https://github.com/nostr-protocol/nips/blob/master/56.md 8 | * @param event 9 | * @returns 10 | */ 11 | export function eventHasReport(event: EventBase): EventReport | undefined { 12 | if (event.kind !== NEVENT_KIND.REPORTING) { 13 | throw new Error( 14 | `Event is not a report: ${event.kind}. Expected ${NEVENT_KIND.REPORTING}.` 15 | ); 16 | } 17 | 18 | const reportTag = event.tags.filter((tag) => tag[0] === "p"); 19 | if (!reportTag || reportTag.length === 0) { 20 | return undefined; 21 | } 22 | 23 | // Try to find the event id 24 | // ["e", , "illegal"], 25 | let eventId: string = undefined; 26 | const eventTag = event.tags.filter((tag) => tag[0] === "e"); 27 | if (eventTag.length > 0 && eventTag[0].length > 0) { 28 | eventId = eventTag[0][1]; 29 | } 30 | 31 | // Determne report tyoe from report tag 32 | // ["p", , "nudity"], 33 | // if that fails, try the event tag 34 | // ["e", , "illegal"], 35 | let kind: NREPORT_KIND = undefined; 36 | if (reportTag[0].length === 3) { 37 | kind = reportTag[0][2] as NREPORT_KIND; 38 | } else if (eventTag.length > 0 && eventTag[0].length === 3) { 39 | kind = eventTag[0][2] as NREPORT_KIND; 40 | } 41 | 42 | // if kind is undefined, try the eventTag instead if that's at least 3 long 43 | // ["p", ] 44 | let publicKey: string = undefined; 45 | if (reportTag[0].length > 0) { 46 | publicKey = reportTag[0][1]; 47 | } 48 | 49 | if (!kind || !publicKey) { 50 | return undefined; 51 | } 52 | 53 | const report: EventReport = { 54 | eventId, 55 | kind, 56 | publicKey, 57 | content: event.content && event.content !== "" ? event.content : undefined, 58 | }; 59 | 60 | return report; 61 | } 62 | 63 | /** 64 | * Generate report tags 65 | * @param report 66 | * @returns 67 | */ 68 | export function generateReportTags(report: EventReport): string[][] { 69 | const { eventId, kind, publicKey } = report; 70 | if (!kind) { 71 | throw new Error("Report must have a kind."); 72 | } 73 | if (!publicKey) { 74 | throw new Error("Report must mention a public key."); 75 | } 76 | if (kind === NREPORT_KIND.IMPERSONATION && eventId) { 77 | throw new Error( 78 | "Impersonation reports should refer to a person, not an event." 79 | ); 80 | } 81 | const tags: string[][] = []; 82 | if (eventId) { 83 | tags.push(["e", eventId, kind]); 84 | if (publicKey) { 85 | tags.push(["p", publicKey]); 86 | } 87 | } else { 88 | if (publicKey) { 89 | tags.push(["p", publicKey, kind]); 90 | } 91 | } 92 | 93 | return tags; 94 | } 95 | -------------------------------------------------------------------------------- /packages/common/src/utils/event-subject.ts: -------------------------------------------------------------------------------- 1 | import { EventBase } from "../types"; 2 | 3 | /** 4 | * Check array of tags if any are NIP-14 5 | */ 6 | export function eventHasSubject(event: EventBase): string | undefined { 7 | const subjectTags = event.tags.filter((tag) => tag[0] === "subject"); 8 | if (subjectTags.length === 0) { 9 | return; 10 | } 11 | return subjectTags[0][1]; 12 | } 13 | 14 | /** 15 | * Check if given subject is a response (starts with "Re:") 16 | * 17 | * @returns true if subject is a response 18 | */ 19 | export function subjectIsRespone(subject?: string): boolean { 20 | if (!subject) { 21 | return false; 22 | } 23 | return subject.startsWith("Re:"); 24 | } 25 | 26 | /** 27 | * Make a subject response 28 | */ 29 | export function makeSubjectResponse(subject: string): string { 30 | if (subjectIsRespone(subject)) { 31 | return subject; 32 | } 33 | return `Re: ${subject}`; 34 | } 35 | -------------------------------------------------------------------------------- /packages/common/src/utils/event-tags.ts: -------------------------------------------------------------------------------- 1 | import { EventBase } from "src/types"; 2 | 3 | export function eventHasTags(event: EventBase) { 4 | const tags = event.tags ? event.tags.filter((tag) => tag[0] === "t") : []; 5 | return tags.length > 0 ? tags.map((t) => t[1]) : undefined; 6 | } 7 | -------------------------------------------------------------------------------- /packages/common/src/utils/event-zap.ts: -------------------------------------------------------------------------------- 1 | import { EventBase, ZapTag } from "../types"; 2 | 3 | /** 4 | * Check if event has zap tags 5 | * https://github.com/nostr-protocol/nips/blob/master/57.md#appendix-g-zap-tag-on-other-events 6 | */ 7 | export function eventHasZapTags(event: EventBase): ZapTag[] | undefined { 8 | if (!event.tags) { 9 | return undefined; 10 | } 11 | const zapTags: ZapTag[] = []; 12 | for (const tag of event.tags) { 13 | if (tag[0] === "zap") { 14 | zapTags.push({ 15 | receiverPubKey: tag[1], 16 | relayUrlForUserMeta: tag[2], 17 | weight: parseInt(tag[3], 10), 18 | }); 19 | } 20 | } 21 | if (zapTags.length === 0) { 22 | return undefined; 23 | } 24 | return zapTags; 25 | } 26 | 27 | /** 28 | * Generate a new zap tag 29 | */ 30 | export function createEventZapTag(opts: ZapTag) { 31 | return [ 32 | "zap", 33 | opts.receiverPubKey, 34 | opts.relayUrlForUserMeta, 35 | opts.weight.toString(), 36 | ]; 37 | } 38 | -------------------------------------------------------------------------------- /packages/common/src/utils/event.nonce.test.ts: -------------------------------------------------------------------------------- 1 | import { NEvent, NewShortTextNote } from "../classes/event"; 2 | import { eventHasNonce } from "./event-nonce"; 3 | 4 | test("eventHasNonce", () => { 5 | const ev = new NEvent({ 6 | kind: 1, 7 | content: "Hello", 8 | tags: [["nonce", "64", "2"]], 9 | }); 10 | const hasNonce = eventHasNonce(ev); 11 | expect(hasNonce).toEqual([64, 2]); 12 | expect(hasNonce).toEqual(ev.hasNonceTag()); 13 | }); 14 | 15 | test("eventAddNonce", () => { 16 | const ev = NewShortTextNote({ 17 | text: "Hello", 18 | }); 19 | const nonce = [64, 2]; 20 | ev.addNonceTag(nonce); 21 | expect(ev.hasNonceTag()).toEqual(nonce); 22 | }); 23 | 24 | test("eventAddNonce: already has nonce", () => { 25 | const ev = NewShortTextNote({ 26 | text: "Hello", 27 | }); 28 | const nonce = [64, 2]; 29 | ev.addNonceTag(nonce); 30 | expect(() => ev.addNonceTag(nonce)).toThrowError( 31 | "Event already has a nonce." 32 | ); 33 | }); 34 | -------------------------------------------------------------------------------- /packages/common/src/utils/generate-keypair.ts: -------------------------------------------------------------------------------- 1 | import { schnorr } from "@noble/curves/secp256k1"; 2 | import { bytesToHex } from "@noble/curves/abstract/utils"; 3 | 4 | function hexToUint8Array(hex: string): Uint8Array { 5 | const length = hex.length / 2; 6 | const result = new Uint8Array(length); 7 | for (let i = 0, j = 0; i < length; i++, j += 2) { 8 | result[i] = parseInt(hex.substr(j, 2), 16); 9 | } 10 | return result; 11 | } 12 | 13 | /** 14 | * Generate keypair 15 | * @returns { 16 | * privateKey: hex string 17 | * pub: hex string 18 | * } 19 | */ 20 | export function generateClientKeys(): { 21 | privateKey: string; 22 | publicKey: string; 23 | } { 24 | const privateKey = schnorr.utils.randomPrivateKey(); 25 | const publicKey = schnorr.getPublicKey(privateKey); 26 | return { 27 | privateKey: bytesToHex(privateKey), 28 | publicKey: bytesToHex(publicKey), 29 | }; 30 | } 31 | 32 | export function publicKeyFromPrivateKey(privateKey: string): string { 33 | return bytesToHex(schnorr.getPublicKey(hexToUint8Array(privateKey))); 34 | } 35 | -------------------------------------------------------------------------------- /packages/common/src/utils/hash-event.ts: -------------------------------------------------------------------------------- 1 | import { sha256 } from "@noble/hashes/sha256"; 2 | import { bytesToHex } from "@noble/curves/abstract/utils"; 3 | import { serializeEvent } from "./serialize-event"; 4 | import { EventBase } from "../types"; 5 | import { Utf8Encoder } from "./utf8-coder"; 6 | 7 | /** 8 | * Hash a string and return hex string 9 | * Use this if your event has already been serialized 10 | * @param content 11 | * @returns 12 | */ 13 | export function hash(serializedEvent: string) { 14 | const hash = sha256(Utf8Encoder.encode(serializedEvent)); 15 | return bytesToHex(hash); 16 | } 17 | 18 | /** 19 | * Hash an event and return hex string 20 | * To obtain the event.id, we sha256 the serialized event. 21 | * The serialization is done over the UTF-8 JSON-serialized string (with no white space or line breaks) 22 | * @param event 23 | * @returns event ID 24 | */ 25 | export function hashEvent(event: EventBase) { 26 | return hash(serializeEvent(event)); 27 | } 28 | -------------------------------------------------------------------------------- /packages/common/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./bech32"; 2 | export * from "./bolt11"; 3 | export * from "./event-amount"; 4 | export * from "./event-contact"; 5 | export * from "./event-content-warning"; 6 | export * from "./event-content"; 7 | export * from "./event-coordinates"; 8 | export * from "./event-event"; 9 | export * from "./event-expiration"; 10 | export * from "./event-identifier"; 11 | export * from "./event-labels"; 12 | export * from "./event-lnurl"; 13 | export * from "./event-nonce"; 14 | export * from "./event-publickey"; 15 | export * from "./event-recommendation"; 16 | export * from "./event-relays"; 17 | export * from "./event-reporting"; 18 | export * from "./event-subject"; 19 | export * from "./event-tags"; 20 | export * from "./event-zap"; 21 | export * from "./generate-keypair"; 22 | export * from "./hash-event"; 23 | export * from "./lnurl-zap"; 24 | export * from "./lnurl"; 25 | export * from "./lud16-to-url"; 26 | export * from "./nip05-to-url"; 27 | export * from "./nostr-url"; 28 | export * from "./proof-of-work"; 29 | export * from "./provider-names"; 30 | export * from "./serialize-event"; 31 | export * from "./sign-event"; 32 | export * from "./user-metadata"; 33 | export * from "./websocket-url"; 34 | export * from "./utf8-coder"; 35 | export * from "./verify-event"; 36 | -------------------------------------------------------------------------------- /packages/common/src/utils/lnurl-zap.test.ts: -------------------------------------------------------------------------------- 1 | import { isValidLnurlInvoiceResponse } from ".."; 2 | 3 | test("isValidLnurlInvoiceResponse", () => { 4 | const req = { 5 | relayUrls: ["https://stacker.news"], 6 | amount: 1000, 7 | recipientPubkey: 8 | "88d1163b882c0830bcca9319ffc0b3a7de2a13b173dd56d9dfff2b88aec4d3a5", 9 | lnurl: "lnurl1wpex7mt9w35x2atnwpshgun0dehkvurjdanxjazq0f3xgtn8vunf6sce", 10 | }; 11 | const res = { 12 | pr: "lnbc10n1pjvmrrnpp5gs8f0lc0k4afrsdfn7uarfuyf3cr7csnegn6mwkk6s8uzkq9ldfqhp54338y02c9yjh6yqvadwvtsanpwhher2eecgs78nap9sl8hcgqx8qcqzzsxqzfvsp5knh27v599nmnuzgdkvgd67860n8hylacksd0hux888j00pevg0cq9qyyssqnmsh88k3ftnf5e745e4m206rt0n9knuawuthc8jwyf7cpqkaye4n0v9wgh2cgdn5m3ma6dqwfsys2mq9c22qy7jwc009xzqcmadqmdqp0ln2ge", 13 | disposable: null, 14 | routes: [], 15 | successAction: { tag: "message", message: "Thank you!" }, 16 | }; 17 | const isValid = isValidLnurlInvoiceResponse(req, res); 18 | expect(isValid).toBe(true); 19 | }); 20 | -------------------------------------------------------------------------------- /packages/common/src/utils/lnurl-zap.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Make Lnurl Invoice URL 3 | * https://github.com/nostr-protocol/nips/blob/master/57.md#appendix-b-zap-request-http-request 4 | * @returns 5 | */ 6 | 7 | import { NEvent } from "../classes"; 8 | import { 9 | EventBase, 10 | LnurlEndpointResponse, 11 | LnurlInvoiceResponse, 12 | NEVENT_KIND, 13 | iNewZAPRequest, 14 | } from "../types"; 15 | import { decodeLightnightPayRequest, findLightningPayRequestSectionWithAmount } from "./bolt11"; 16 | 17 | export function makeLnurlZapRequestUrl({ 18 | callback, 19 | amount, 20 | event, 21 | lnurl, 22 | }: { 23 | callback: string; 24 | amount: number; 25 | event: string; 26 | lnurl: string; 27 | }) { 28 | return `${callback}?amount=${amount}&nostr=${event}&lnurl=${lnurl}`; 29 | } 30 | 31 | export function isValidLnurlEndpointResponse(info: LnurlEndpointResponse) { 32 | if (info.allowsNostr && info.nostrPubkey) { 33 | // TODO: Check if key is valid (valid BIP 340 public key in hex) 34 | return true; 35 | } 36 | } 37 | 38 | export function isValidLnurlInvoiceResponse( 39 | request: iNewZAPRequest, 40 | response: LnurlInvoiceResponse 41 | ) { 42 | const reqAmount = request.amount ? request.amount : undefined; 43 | const reqLnurl = request.lnurl ? request.lnurl : undefined; 44 | 45 | // If the user didn't specify an amount or lnurl, we can't validate 46 | if (!reqAmount || !reqLnurl) { 47 | return true; 48 | } 49 | 50 | const invoice = decodeLightnightPayRequest(response.pr); 51 | const sectionWithAmount = findLightningPayRequestSectionWithAmount(invoice); 52 | 53 | let amount = undefined; 54 | if (sectionWithAmount) { 55 | amount = sectionWithAmount.value; 56 | } else { 57 | return false; 58 | } 59 | 60 | if (reqAmount && reqAmount.toString() !== amount) { 61 | return false; 62 | } 63 | 64 | // TODO: Check if lnurl is valid (invoice.tags) 65 | 66 | return true; 67 | } 68 | 69 | export function makeZapReceiptDescription(event: EventBase) { 70 | const ev = new NEvent(event); 71 | const pubkeys = ev.hasPublicKeyTags(); 72 | if (!pubkeys) { 73 | throw new Error("No pubkey tags found"); 74 | } 75 | 76 | if (event.kind !== NEVENT_KIND.ZAP_REQUEST) { 77 | throw new Error("Event is not a zap request"); 78 | } 79 | 80 | const pubkey = pubkeys[0]; 81 | const relays = ev.hasRelaysTag(); 82 | 83 | const description = { 84 | pubkey, 85 | content: "", 86 | id: ev.id, 87 | sig: ev.sig, 88 | kind: ev.kind, 89 | tags: ev.tags, 90 | relays, 91 | }; 92 | 93 | return JSON.stringify(description); 94 | } 95 | -------------------------------------------------------------------------------- /packages/common/src/utils/lnurl.test.ts: -------------------------------------------------------------------------------- 1 | import { decodeLnurl, encodeLnurl } from ".."; 2 | 3 | test("decodeLnurl", () => { 4 | const lnurl = 5 | "lnurl1dp68gurn8ghj7um5v93kketj9ehx2amn9uh8wetvdskkkmn0wahz7mrww4excup0dajx2mrv92x9xp"; 6 | const decoded = decodeLnurl(lnurl); 7 | expect(decoded).toEqual("https://stacker.news/.well-known/lnurlp/odell"); 8 | }); 9 | 10 | test("decodeLnurl #2", () => { 11 | const lnurl = 12 | "lnurl1dp68gurn8ghj7ampd3kx2ar0veekzar0wd5xjtnrdakj7tnhv4kxctttdehhwm30d3h82unvwqhkxctjd9hxwmrpw35x2d3hts4ju9"; 13 | const decoded = decodeLnurl(lnurl); 14 | expect(decoded).toEqual( 15 | "https://walletofsatoshi.com/.well-known/lnurlp/caringlathe67" 16 | ); 17 | }); 18 | 19 | test("encodeLnurl", () => { 20 | const decoded = "https://stacker.news/.well-known/lnurlp/odell"; 21 | const encoded = encodeLnurl(decoded); 22 | expect(encoded).toEqual( 23 | "lnurl1dp68gurn8ghj7um5v93kketj9ehx2amn9uh8wetvdskkkmn0wahz7mrww4excup0dajx2mrv92x9xp" 24 | ); 25 | }); 26 | -------------------------------------------------------------------------------- /packages/common/src/utils/lnurl.ts: -------------------------------------------------------------------------------- 1 | import { bech32 } from "bech32"; 2 | import { Utf8Decoder, Utf8Encoder } from "./utf8-coder"; 3 | 4 | function findlnurl(bodyOfText: string): string | null { 5 | let res = /,*?((lnurl)([0-9]{1,}[a-z0-9]+){1})/.exec( 6 | bodyOfText.toLowerCase() 7 | ); 8 | if (res) { 9 | return res[1]; 10 | } 11 | return null; 12 | } 13 | 14 | /** 15 | * Decode lnurl 16 | * 17 | * example: 18 | * - source: lnurl1dp68gurn8ghj7um5v93kketj9ehx2amn9uh8wetvdskkkmn0wahz7mrww4excup0dajx2mrv92x9xp 19 | * - result: https://stacker.news/.well-known/lnurlp/odell 20 | * 21 | * Adapted from https://github.com/nbd-wtf/js-lnurl/blob/master/src/helpers/decodelnurl.ts 22 | * @param lnurl 23 | * @returns 24 | */ 25 | export function decodeLnurl(lnurl: string): string { 26 | lnurl = lnurl.trim(); 27 | 28 | if (lnurl.toLowerCase().slice(0, 6) === "lnurl1") { 29 | const { words } = bech32.decode(lnurl, 20000); 30 | const decodedData = new Uint8Array(bech32.fromWords(words)); 31 | 32 | return Utf8Decoder.decode(decodedData); 33 | } else if ( 34 | lnurl.slice(0, 9) === "lnurlc://" || 35 | lnurl.slice(0, 9) === "lnurlw://" || 36 | lnurl.slice(0, 9) === "lnurlp://" || 37 | lnurl.slice(0, 10) === "keyauth://" 38 | ) { 39 | let [_, post] = lnurl.split("://"); 40 | let pre = post.match(/\.onion($|\W)/) ? "http" : "https"; 41 | return pre + "://" + post; 42 | } else if (lnurl.slice(0, 8) === "https://") { 43 | let bech32lnurl = findlnurl(lnurl); 44 | if (bech32lnurl) { 45 | const { words } = bech32.decode(bech32lnurl, 20000); 46 | const decodedData = new Uint8Array(bech32.fromWords(words)); 47 | 48 | return Utf8Decoder.decode(decodedData); 49 | } 50 | 51 | return lnurl; 52 | } 53 | 54 | throw new Error(`invalid url ${lnurl}`); 55 | } 56 | 57 | /** 58 | * Encode url to lnurl 59 | * 60 | * example: 61 | * - source: https://stacker.news/.well-known/lnurlp/odell 62 | * - result: lnurl1dp68gurn8ghj7um5v93kketj9ehx2amn9uh8wetvdskkkmn0wahz7mrww4excup0dajx2mrv92x9xp 63 | * 64 | * @param str 65 | * @returns 66 | */ 67 | export function encodeLnurl(str: string): string { 68 | const data = Utf8Encoder.encode(str); 69 | 70 | const words = bech32.toWords(new Uint8Array(data)); 71 | return bech32.encode("lnurl", words, 20000); 72 | } 73 | -------------------------------------------------------------------------------- /packages/common/src/utils/lud16-to-url.ts: -------------------------------------------------------------------------------- 1 | export function isTorOnionAddress(address: string) { 2 | return address.endsWith(".onion"); 3 | } 4 | 5 | /** 6 | * Lud16 to URL 7 | * 8 | * > Upon seeing such an address, 9 | * > WALLET makes a GET request to https:///.well-known/lnurlp/ endpoint 10 | * > if domain is clearnet or http:///.well-known/lnurlp/ if domain is onion. 11 | * > For example, if the address is satoshi@bitcoin.org, 12 | * > the request is to be made to https://bitcoin.org/.well-known/lnurlp/satoshi. 13 | * @param lud16 @ 14 | */ 15 | export function lud16ToUrl(lud16: string) { 16 | const [username, domain] = lud16.split("@"); 17 | const isOnion = isTorOnionAddress(domain); 18 | const protocol = isOnion ? "http" : "https"; 19 | const url = `${protocol}://${domain}/.well-known/lnurlp/${username}`; 20 | return url; 21 | } 22 | -------------------------------------------------------------------------------- /packages/common/src/utils/lud16.test.ts: -------------------------------------------------------------------------------- 1 | import { lud16ToUrl } from ".."; 2 | 3 | test("lud16ToUrl", () => { 4 | const lud16 = "satoshi@bitcoin.org"; 5 | const url = lud16ToUrl(lud16); 6 | expect(url).toEqual("https://bitcoin.org/.well-known/lnurlp/satoshi"); 7 | }); 8 | 9 | test("lud16ToUrl onion", () => { 10 | const lud16 = "satoshi@woodcubabitenem2.onion"; 11 | const url = lud16ToUrl(lud16); 12 | expect(url).toEqual( 13 | "http://woodcubabitenem2.onion/.well-known/lnurlp/satoshi" 14 | ); 15 | }); 16 | -------------------------------------------------------------------------------- /packages/common/src/utils/nip05-to-url.ts: -------------------------------------------------------------------------------- 1 | import { isTorOnionAddress } from "./lud16-to-url"; 2 | 3 | /** 4 | * Nip05 to URL 5 | * 6 | * > Upon seeing that, 7 | * > the client splits the identifier into and and use these values to 8 | * > make a GET request to https:///.well-known/nostr.json?name=. 9 | * 10 | * https://github.com/nostr-protocol/nips/blob/master/05.md 11 | * 12 | * @param nip05 13 | * @returns 14 | */ 15 | export function nip05ToUrl(nip05: string) { 16 | const [username, domain] = nip05.split("@"); 17 | const isOnion = isTorOnionAddress(domain); 18 | const protocol = isOnion ? "http" : "https"; 19 | const url = `${protocol}://${domain}/.well-known/nostr.json?name=${username}`; 20 | return url; 21 | } 22 | -------------------------------------------------------------------------------- /packages/common/src/utils/nostr-url.test.ts: -------------------------------------------------------------------------------- 1 | import { BECH32_PREFIX } from "../types"; 2 | import { 3 | bechEncodePrivateKey, 4 | bechEncodeProfile, 5 | decodeNostrPrivateKeyString, 6 | decodeNostrProfileString, 7 | decodeNostrPublicKeyString, 8 | encodeNostrString, 9 | } from "./nostr-url"; 10 | 11 | /** 12 | * PUB 13 | */ 14 | 15 | test("encodePublicKey", () => { 16 | const url = encodeNostrString(BECH32_PREFIX.PublicKeys, [ 17 | { 18 | type: 0, 19 | value: "b75b9a3131f4263add94ba20beb352a11032684f2dac07a7e1af827c6f3c1505", 20 | }, 21 | ]); 22 | expect(url).toEqual( 23 | "nostr:npub1kade5vf37snr4hv5hgstav6j5ygry6z09kkq0flp47p8cmeuz5zs7zz2an" 24 | ); 25 | }); 26 | 27 | test("decodePublicKey", () => { 28 | const url = 29 | "nostr:npub1kade5vf37snr4hv5hgstav6j5ygry6z09kkq0flp47p8cmeuz5zs7zz2an"; 30 | const res = decodeNostrPublicKeyString(url); 31 | expect(res).toEqual( 32 | "b75b9a3131f4263add94ba20beb352a11032684f2dac07a7e1af827c6f3c1505" 33 | ); 34 | }); 35 | 36 | /** 37 | * PRIV 38 | */ 39 | 40 | test("encodePrivateKey", () => { 41 | const src = 42 | "67dea2ed018072d675f5415ecfaed7d2597555e202d85b3d65ea4e58d2d92ffa"; 43 | const res = bechEncodePrivateKey(src); 44 | expect(res).toEqual( 45 | "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5" 46 | ); 47 | }); 48 | 49 | test("decodePrivateKey", () => { 50 | const src = "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5"; 51 | const res = decodeNostrPrivateKeyString(src); 52 | expect(res).toEqual( 53 | "67dea2ed018072d675f5415ecfaed7d2597555e202d85b3d65ea4e58d2d92ffa" 54 | ); 55 | }); 56 | 57 | /** 58 | * PROF 59 | */ 60 | 61 | test("encodeProfile", () => { 62 | const src = 63 | "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; 64 | const relays = ["wss://r.x.com", "wss://djbas.sadkb.com"]; 65 | const res = bechEncodeProfile(src, relays); 66 | expect(res).toEqual( 67 | "nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhhytnc9e3k7mgpz4mhxue69uhkg6nzv9ejuumpv34kytnrdaksjlyr9p" 68 | ); 69 | }); 70 | 71 | test("decodeProfile", () => { 72 | const src = 73 | "nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhhytnc9e3k7mgpz4mhxue69uhkg6nzv9ejuumpv34kytnrdaksjlyr9p"; 74 | const res = decodeNostrProfileString(src); 75 | expect(res).toEqual([ 76 | { 77 | type: 0, 78 | value: "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", 79 | }, 80 | { 81 | type: 1, 82 | value: "wss://r.x.com", 83 | }, 84 | { 85 | type: 1, 86 | value: "wss://djbas.sadkb.com", 87 | }, 88 | ]); 89 | }); 90 | -------------------------------------------------------------------------------- /packages/common/src/utils/proof-of-work.test.ts: -------------------------------------------------------------------------------- 1 | import { NEvent } from "../classes"; 2 | import { EventBase, NEVENT_KIND } from "../types"; 3 | import { proofOfWork } from "./proof-of-work"; 4 | 5 | describe("Proof of Work Performance", () => { 6 | let sampleEvent: EventBase; 7 | 8 | beforeEach(() => { 9 | sampleEvent = { 10 | id: "e21921600ecbcbea699a9f76c8156886bef112b71c4f79ce1b894386b5413466", 11 | pubkey: 12 | "5276ac499c9c6a353634d3d2cb6f4ada5167c3b886108ab4ddeb8ddf7b0fff70", 13 | created_at: Math.floor(Date.now() / 1000), 14 | kind: NEVENT_KIND.SHORT_TEXT_NOTE, // or any other value you want 15 | tags: [], 16 | content: "Test content", 17 | sig: "sampleSignature", 18 | }; 19 | }); 20 | 21 | test("10 bits proof of work", async () => { 22 | const startTime = Date.now(); 23 | proofOfWork(sampleEvent, 10); 24 | const endTime = Date.now(); 25 | }); 26 | 27 | test("10 bits proof of work - event function", async () => { 28 | const ev = new NEvent(sampleEvent); 29 | const startTime = Date.now(); 30 | ev.proofOfWork(10); 31 | const endTime = Date.now(); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /packages/common/src/utils/proof-of-work.ts: -------------------------------------------------------------------------------- 1 | import { hash } from "./hash-event"; 2 | import { EventBase } from "../types"; 3 | 4 | /** 5 | * Count leading zeroes in a hex string 6 | * Copied from https://github.com/nostr-protocol/nips/blob/master/13.md#validating 7 | * All credits go to the author 8 | * @param hex 9 | * @returns 10 | */ 11 | function countLeadingZeroes(hex: string) { 12 | let count = 0; 13 | 14 | for (let i = 0; i < hex.length; i++) { 15 | const nibble = parseInt(hex[i], 16); 16 | if (nibble === 0) { 17 | count += 4; 18 | } else { 19 | count += Math.clz32(nibble) - 28; 20 | break; 21 | } 22 | } 23 | 24 | return count; 25 | } 26 | 27 | /** 28 | * Add proof of work to event 29 | * Importing: Anything above ~15-20 bits might take a while to compute 30 | */ 31 | export function proofOfWork( 32 | event: EventBase, 33 | bits: number, 34 | limitRounds?: number 35 | ) { 36 | let adjustmentValue = 0; 37 | const bitsString = bits.toString(); 38 | while (true) { 39 | const nonceIndex = event.tags.findIndex((t) => t[0] === "nonce"); 40 | if (nonceIndex !== -1) { 41 | event.tags[nonceIndex][1] = adjustmentValue.toString(); 42 | } else { 43 | event.tags.push(["nonce", adjustmentValue.toString(), bitsString]); 44 | } 45 | 46 | const serial = JSON.stringify([ 47 | 0, 48 | event.pubkey, 49 | event.created_at, 50 | event.kind, 51 | event.tags, 52 | event.content, 53 | ]); 54 | event.id = hash(serial); 55 | 56 | const leadingZeroes = countLeadingZeroes(event.id); 57 | 58 | if (leadingZeroes >= bits) { 59 | event.tags = event.tags.filter((t) => t[0] !== "nonce"); 60 | event.tags.push(["nonce", adjustmentValue.toString(), bitsString]); 61 | return event; 62 | } 63 | 64 | if (limitRounds && adjustmentValue >= limitRounds) { 65 | return undefined; 66 | } 67 | 68 | adjustmentValue++; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /packages/common/src/utils/provider-names.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Check whether the provider name is valid. 3 | * 4 | * @param {string} name - The provider name to check. 5 | * @returns {boolean} - Whether the provider name is valid. 6 | */ 7 | export function isValidProviderName(name: string): boolean { 8 | const regex = /^[a-z0-9\.\-_\/@]*$/; 9 | return regex.test(name); 10 | } 11 | 12 | /** 13 | * Normalize the provider name by replacing uppercase letters with lowercase letters. 14 | * @param {string} name - The provider name to normalize. 15 | * @returns {string} - The normalized provider name. 16 | */ 17 | export function normalizeProviderName(name: string): string { 18 | return name.toLowerCase(); 19 | } 20 | -------------------------------------------------------------------------------- /packages/common/src/utils/serialize-event.ts: -------------------------------------------------------------------------------- 1 | import { EventBase } from "../types"; 2 | 3 | /** 4 | * Serialize an event 5 | * @param event 6 | * @returns 7 | */ 8 | export function serializeEvent(event: EventBase) { 9 | const serialized = JSON.stringify([ 10 | 0, 11 | event.pubkey, 12 | event.created_at, 13 | event.kind, 14 | event.tags, 15 | event.content, 16 | ]); 17 | return serialized; 18 | } 19 | -------------------------------------------------------------------------------- /packages/common/src/utils/sign-event.ts: -------------------------------------------------------------------------------- 1 | import { schnorr } from "@noble/curves/secp256k1"; 2 | import { bytesToHex } from "@noble/curves/abstract/utils"; 3 | import { EventBase } from "../types"; 4 | import { hashEvent } from "./hash-event"; 5 | 6 | export function sign(eventHash: string, privateKey: string) { 7 | if (eventHash.length !== 64) throw new Error("Invalid event hash"); 8 | const sig = schnorr.sign(eventHash, privateKey); 9 | return bytesToHex(sig); 10 | } 11 | 12 | /** 13 | * Sign event and return signature as hex string 14 | * @param event 15 | * @param privateKey 16 | * @returns 17 | */ 18 | export function signEvent(event: EventBase, privateKey: string) { 19 | const serial = hashEvent(event); 20 | return sign(serial, privateKey); 21 | } 22 | -------------------------------------------------------------------------------- /packages/common/src/utils/user-metadata.ts: -------------------------------------------------------------------------------- 1 | import { UserMetadata } from "../types"; 2 | 3 | /** 4 | * https://github.com/nostr-protocol/nips/blob/master/01.md#basic-event-kinds 5 | * > stringified JSON object {name: , about: , picture: } 6 | * > describing the user who created the event 7 | * @param meta 8 | * @returns 9 | */ 10 | export function createUserMetadataString(meta?: UserMetadata) { 11 | if (!meta) { 12 | return ""; 13 | } 14 | return JSON.stringify(meta); 15 | } 16 | 17 | export function loadFromUserMetadataString(meta: string): UserMetadata | null { 18 | try { 19 | const data = JSON.parse(meta); 20 | 21 | // Check if properties exist on parsed object before accessing them 22 | return { 23 | name: data.name ?? null, 24 | display_name: data.display_name ?? null, 25 | picture: data.picture ?? null, 26 | banner: data.banner ?? null, 27 | nip05: data.nip05 ?? null, 28 | website: data.website ?? null, 29 | about: data.about ?? null, 30 | image: data.image ?? null, 31 | npub: data.npub ?? null, 32 | lud16: data.lud16 ?? null, 33 | lud06: data.lud06 ?? null, 34 | }; 35 | } catch (error) { 36 | console.error("Unable to parse user metadata string", error); 37 | return null; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/common/src/utils/utf8-coder.ts: -------------------------------------------------------------------------------- 1 | export const Utf8Encoder = new TextEncoder(); 2 | export const Utf8Decoder = new TextDecoder("utf-8"); 3 | -------------------------------------------------------------------------------- /packages/common/src/utils/verify-event.ts: -------------------------------------------------------------------------------- 1 | import { schnorr } from "@noble/curves/secp256k1"; 2 | import { EventBase } from "../types"; 3 | import { hashEvent } from "./hash-event"; 4 | 5 | export function verifyEvent(event: EventBase): boolean { 6 | try { 7 | const content = hashEvent(event); 8 | const valid = schnorr.verify(event.sig, content, event.pubkey); 9 | return valid; 10 | } catch (e) { 11 | console.error(e); 12 | return false; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/common/src/utils/websocket-url.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Check if the given url is a valid websocket url. 3 | */ 4 | export function isValidWebSocketUrl(url: string): boolean { 5 | const regex = /^(wss?):\/\/([a-zA-Z0-9.-]+)(:\d+)?(\/[a-zA-Z0-9_/.-]*)?$/; 6 | return regex.test(url); 7 | } 8 | -------------------------------------------------------------------------------- /packages/common/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/common/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./../tsconfig.base.json", 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "outDir": "dist/", 6 | "emitDecoratorMetadata": true 7 | }, 8 | "include": ["./src/**/*.ts",], 9 | "types": ["node", "dom"] 10 | } 11 | -------------------------------------------------------------------------------- /packages/node/.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | *.d.ts 3 | node_modules 4 | !build.js 5 | dist -------------------------------------------------------------------------------- /packages/node/README.md: -------------------------------------------------------------------------------- 1 | # nostr-ts Node 2 | 3 | > Nostr: A simple, open protocol that enables a truly censorship-resistant and global social network. 4 | 5 | This package is part of [nostr-ts](https://github.com/franzos/nostr-ts). 6 | 7 | - `@nostr-ts/common`: common types and functions 8 | - `@nostr-ts/node`: client for usage with node `ws` library 9 | - `@nostr-ts/web`: client for usage with browser `WebSocket` API 10 | 11 | Checkout the [documentation](https://github.com/franzos/nostr-ts) for more information. 12 | 13 | ## Installation 14 | 15 | ```bash 16 | pnpm add @nostr-ts/node 17 | ``` 18 | 19 | ## Get started 20 | 21 | ```js 22 | import { NewShortTextNote, NFilters } from '@nostr-ts/common' 23 | import { RelayClient, RelayDiscovery, loadOrCreateKeypair, NUser } from '@nostr-ts/node' 24 | ``` 25 | 26 | In the browser use: 27 | 28 | ```js 29 | import { NewShortTextNote, NFilters } from '@nostr-ts/common' 30 | import { RelayClient, loadOrCreateKeypair, NUser } from '@nostr-ts/web' 31 | ``` -------------------------------------------------------------------------------- /packages/node/build.js: -------------------------------------------------------------------------------- 1 | import { build } from 'esbuild'; 2 | import { nodeExternalsPlugin } from 'esbuild-node-externals'; 3 | import { esbuildDecorators } from '@anatine/esbuild-decorators'; 4 | 5 | const shared = { 6 | entryPoints: ['./src/index.ts'], 7 | bundle: true, 8 | treeShaking: true, 9 | platform: 'node', 10 | target: 'node18', 11 | plugins: [nodeExternalsPlugin()], 12 | } 13 | 14 | build({ 15 | ...shared, 16 | outfile: 'dist/index.cjs', 17 | format: 'cjs', 18 | plugins: [ 19 | esbuildDecorators({ 20 | tsconfig: 'tsconfig.build.json', 21 | cwd: process.cwd(), 22 | }), 23 | ...shared.plugins, 24 | ] 25 | }).catch((err) => { 26 | console.error(err); 27 | process.exit(1); 28 | }); 29 | 30 | build({ 31 | ...shared, 32 | outfile: 'dist/index.esm.js', 33 | format: 'esm', 34 | }).catch((err) => { 35 | console.error(err); 36 | process.exit(1); 37 | }); -------------------------------------------------------------------------------- /packages/node/jest.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "roots": [ 3 | "/src", 4 | ], 5 | "transform": { 6 | "^.+\\.tsx?$": "ts-jest" 7 | }, 8 | "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$", 9 | "moduleFileExtensions": [ 10 | "ts", 11 | "tsx", 12 | "js", 13 | "jsx", 14 | "json", 15 | "node" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /packages/node/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@nostr-ts/node", 3 | "version": "0.0.6", 4 | "description": "nostr on the backend: node.js implementation of the nostr protocol", 5 | "author": "Franz Geffke ", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/franzos/nostr-ts" 10 | }, 11 | "keywords": [ 12 | "nostr", 13 | "blockchain", 14 | "crypto" 15 | ], 16 | "type": "module", 17 | "scripts": { 18 | "prebuild": "rimraf dist", 19 | "tsc": "pnpm run build", 20 | "build": "pnpm run prebuild && node build.js && tsc -p tsconfig.json --emitDeclarationOnly", 21 | "check": "pnpm dlx madge --extensions ts --circular src/", 22 | "tests": "jest" 23 | }, 24 | "dependencies": { 25 | "@noble/curves": "^1.8.1", 26 | "@noble/hashes": "^1.7.1", 27 | "@nostr-ts/common": "workspace:*", 28 | "node-fetch": "^3.3.2", 29 | "ws": "^8.18.1" 30 | }, 31 | "devDependencies": { 32 | "@anatine/esbuild-decorators": "^0.2.19", 33 | "@types/jest": "^29.5.14", 34 | "@types/node": "^20.17.23", 35 | "@types/ws": "^8.18.0", 36 | "esbuild": "^0.17.19", 37 | "esbuild-node-externals": "^1.18.0", 38 | "jest": "^29.7.0", 39 | "rimraf": "^3.0.2", 40 | "tslib": "2.6.2", 41 | "typescript": "5.2.2" 42 | }, 43 | "main": "dist/index.cjs", 44 | "module": "dist/index.esm.js", 45 | "typings": "dist/index.d.ts", 46 | "files": [ 47 | "dist/*" 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /packages/node/src/classes/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./relay-client"; 2 | export * from "./relay-discovery"; 3 | export * from "./user"; 4 | -------------------------------------------------------------------------------- /packages/node/src/classes/relay-client.test.ts: -------------------------------------------------------------------------------- 1 | import { CLIENT_MESSAGE_TYPE, NFilters } from "@nostr-ts/common"; 2 | import { RelayClient } from "./relay-client"; 3 | 4 | function wait(s) { 5 | return new Promise((resolve) => setTimeout(resolve, s * 1000)); 6 | } 7 | 8 | test("RelayClient connect, disconnect", async () => { 9 | const client = new RelayClient([ 10 | { url: "wss://nostr-ts.relay", read: true, write: true }, 11 | ]); 12 | 13 | await wait(1); 14 | 15 | expect(client.relays.length).toBe(1); 16 | expect(client.relays[0].url).toBe("wss://nostr-ts.relay"); 17 | expect(client.relays[0].isConnected()).toBe(true); 18 | 19 | client.disconnect(); 20 | await wait(1); 21 | }); 22 | 23 | test("RelayClient connect, subscribe, disconnect", async () => { 24 | const client = new RelayClient([ 25 | { url: "wss://nostr-ts.relay", read: true, write: true }, 26 | ]); 27 | 28 | await wait(1); 29 | 30 | expect(client.relays.length).toBe(1); 31 | expect(client.relays[0].url).toBe("wss://nostr-ts.relay"); 32 | expect(client.relays[0].isConnected()).toBe(true); 33 | 34 | const filters = new NFilters({ 35 | kinds: [1], 36 | limit: 1, 37 | }); 38 | 39 | client.subscribe({ 40 | type: CLIENT_MESSAGE_TYPE.REQ, 41 | filters, 42 | options: { 43 | timeoutIn: 10000, 44 | }, 45 | }); 46 | 47 | await wait(1); 48 | 49 | expect(client.getSubscriptions().length).toBe(1); 50 | 51 | await wait(1); 52 | 53 | expect(client.getSubscriptions().length).toBe(1); 54 | 55 | client.disconnect(); 56 | 57 | await wait(1); 58 | 59 | expect(client.relays.length).toBe(0); 60 | expect(client.getSubscriptions().length).toBe(0); 61 | }, 15000); 62 | 63 | test("RelayClient subscription timeout", async () => { 64 | const client = new RelayClient([ 65 | { url: "wss://nostr-ts.relay", read: true, write: true }, 66 | ]); 67 | 68 | await wait(1); 69 | 70 | expect(client.relays.length).toBe(1); 71 | expect(client.relays[0].url).toBe("wss://nostr-ts.relay"); 72 | expect(client.relays[0].isConnected()).toBe(true); 73 | 74 | const filters = new NFilters({ 75 | kinds: [1], 76 | limit: 1, 77 | }); 78 | 79 | client.subscribe({ 80 | type: CLIENT_MESSAGE_TYPE.REQ, 81 | filters, 82 | options: { 83 | timeoutIn: 3000, 84 | }, 85 | }); 86 | 87 | await wait(1); 88 | 89 | expect(client.getSubscriptions().length).toBe(1); 90 | 91 | await wait(4); 92 | 93 | for (const sub of client.getSubscriptions()) { 94 | expect(sub.isActive).toBe(false); 95 | } 96 | 97 | client.disconnect(); 98 | 99 | await wait(2); 100 | }, 15000); 101 | -------------------------------------------------------------------------------- /packages/node/src/classes/user.ts: -------------------------------------------------------------------------------- 1 | import { 2 | NUserBase, 3 | UserBase, 4 | NewSignedZapRequest, 5 | UserZapRequestResponse, 6 | LnurlEndpointResponse, 7 | LnurlInvoiceResponse, 8 | isValidLnurlInvoiceResponse, 9 | isValidLnurlEndpointResponse, 10 | iNewUserZapRequest, 11 | iNewZAPRequest, 12 | encodeLnurl, 13 | } from "@nostr-ts/common"; 14 | import { makeRequest } from "../utils/make-request"; 15 | 16 | export class NUser extends NUserBase { 17 | constructor(data?: UserBase) { 18 | super(data); 19 | } 20 | 21 | /** 22 | * Make a zap request to get lightning invoice 23 | * 1. Fetch callback url and spec 24 | * 2. Create new, signed ZAP request 25 | * 3. Fetch invoice from ZAP request 26 | * @param opts 27 | * @param keypair 28 | * @returns 29 | */ 30 | public async makeZapRequest( 31 | opts: iNewUserZapRequest, 32 | keypair 33 | ): Promise { 34 | const lud = this.getLud16Or06Url(); 35 | 36 | if (lud) { 37 | try { 38 | if (!this.hasZapInfo()) { 39 | const info = (await makeRequest(lud.url)) as LnurlEndpointResponse; 40 | if (!isValidLnurlEndpointResponse(info)) { 41 | throw new Error( 42 | `Lnurl endpoint does not allow Nostr payments. Expected to find 'allowsNostr' in response.` 43 | ); 44 | } 45 | this.lightningZapInfo = info; 46 | } 47 | console.log("LnurlEndpointResponse", this.lightningZapInfo); 48 | 49 | const reqSigned: iNewZAPRequest = { 50 | ...opts, 51 | recipientPubkey: this.pubkey, 52 | lnurl: 53 | lud.type === "lud16" 54 | ? encodeLnurl(this.getLud16()) 55 | : this.getLud06(), 56 | }; 57 | 58 | // Make the zap request 59 | const req = NewSignedZapRequest( 60 | reqSigned, 61 | this.lightningZapInfo.callback, 62 | keypair 63 | ); 64 | 65 | // Fetch the invoice 66 | const inv = (await makeRequest(req.invoiceUrl)) as LnurlInvoiceResponse; 67 | if (!isValidLnurlInvoiceResponse(reqSigned, inv)) { 68 | throw new Error( 69 | `Lnurl invoice response is invalid or does not match your request.` 70 | ); 71 | } 72 | 73 | console.log("LnurlInvoiceResponse", inv); 74 | 75 | return { 76 | ...inv, 77 | event: req.event, 78 | }; 79 | } catch (error) { 80 | throw new Error(`Error making zap request: ${error}`); 81 | } 82 | } else { 83 | throw new Error("No lud16 or lud06 url found"); 84 | } 85 | } 86 | 87 | public async makeNIP05Request() { 88 | const nip05Url = this.getNip05Url(); 89 | 90 | if (nip05Url) { 91 | try { 92 | const res = await makeRequest(nip05Url); 93 | return res; 94 | } catch (error) { 95 | throw new Error(`Error making NIP05 request: ${error}`); 96 | } 97 | } else { 98 | throw new Error("No nip05 url found"); 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /packages/node/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./classes/index"; 2 | export * from "./utils/index"; 3 | -------------------------------------------------------------------------------- /packages/node/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./keypair"; 2 | -------------------------------------------------------------------------------- /packages/node/src/utils/keypair.ts: -------------------------------------------------------------------------------- 1 | import { generateClientKeys } from "@nostr-ts/common"; 2 | import { schnorr } from "@noble/curves/secp256k1"; 3 | import { bytesToHex } from "@noble/curves/abstract/utils"; 4 | 5 | import { readFile, statfs, writeFile } from "fs/promises"; 6 | 7 | export async function loadKeyFromFile(path: string) { 8 | let exists = false; 9 | try { 10 | await statfs(path); 11 | exists = true; 12 | } catch (e) { 13 | exists = false; 14 | console.log(`Private key file ${path} does not exist`); 15 | } 16 | 17 | if (exists) { 18 | const privateKey = await readFile(path); 19 | return privateKey.toString(); 20 | } 21 | } 22 | 23 | export async function saveKeyPair( 24 | keypair: { 25 | privateKey: string; 26 | publicKey: string; 27 | }, 28 | privateKeyPath?: string 29 | ) { 30 | const keypath = privateKeyPath || "key"; 31 | const { privateKey, publicKey } = keypair; 32 | await writeFile(keypath, privateKey); 33 | await writeFile(`${keypath}.pub`, publicKey); 34 | } 35 | 36 | export async function loadOrCreateKeypair(privateKeyPath?: string) { 37 | const keypath = privateKeyPath || "key"; 38 | const privateKey = await loadKeyFromFile(keypath); 39 | if (privateKey) { 40 | let publicKey = await loadKeyFromFile(`${keypath}.pub`); 41 | if (!publicKey) { 42 | console.log( 43 | `Public key file ${keypath}.pub does not exist. Generating ...` 44 | ); 45 | publicKey = bytesToHex(schnorr.getPublicKey(privateKey)); 46 | } 47 | return { privateKey, publicKey }; 48 | } else { 49 | const keypair = generateClientKeys(); 50 | await saveKeyPair(keypair, keypath); 51 | return keypair; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /packages/node/src/utils/make-request.ts: -------------------------------------------------------------------------------- 1 | import { Agent } from "https"; 2 | import type { Response } from "node-fetch"; 3 | 4 | export async function makeRequest( 5 | url: string, 6 | headers?: any, 7 | options?: { 8 | rejectUnauthorized?: boolean; 9 | } 10 | ) { 11 | try { 12 | const fetch = (await import("node-fetch")).default; 13 | const agent = 14 | options && options.rejectUnauthorized === false 15 | ? new Agent({ 16 | rejectUnauthorized: false, 17 | }) 18 | : undefined; 19 | 20 | let response = (await Promise.race([ 21 | fetch(url, { 22 | headers, 23 | agent, 24 | }), 25 | new Promise((_, reject) => 26 | setTimeout(() => reject(new Error("Timeout")), 5000) 27 | ), 28 | ])) as Response; 29 | 30 | if (!response.ok) { 31 | throw new Error(`HTTP error! status: ${response.status}`); 32 | } 33 | 34 | const result = await response.json(); 35 | return result; 36 | } catch (error) { 37 | throw new Error(`Error making request: ${error}`); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/node/src/utils/relay-information.ts: -------------------------------------------------------------------------------- 1 | import { RelayInformationDocument } from "@nostr-ts/common"; 2 | import { makeRequest } from "./make-request"; 3 | 4 | /** 5 | * Get relay information document 6 | * https://github.com/nostr-protocol/nips/blob/master/11.md 7 | * 8 | * Note: Some relays may not support this 9 | * @param webSocketUrl 10 | * @returns 11 | */ 12 | export async function getRelayInformationDocument( 13 | webSocketUrl: string, 14 | options?: { 15 | rejectUnauthorized?: boolean; 16 | } 17 | ): Promise { 18 | // Replace "wss://" with "https://" 19 | let httpsUrl = webSocketUrl.replace(/^wss:\/\//i, "https://"); 20 | 21 | console.log("###############"); 22 | console.log("Fetching relay information from", httpsUrl); 23 | console.log("###############"); 24 | 25 | const headers = { 26 | Accept: "application/nostr+json", 27 | }; 28 | 29 | // Use inline import for 'node-fetch' 30 | return makeRequest(httpsUrl, headers, options); 31 | } 32 | -------------------------------------------------------------------------------- /packages/node/src/utils/ws.ts: -------------------------------------------------------------------------------- 1 | import { WebSocket, CloseEvent, MessageEvent, ErrorEvent } from "ws"; 2 | import { 3 | RelayAuth, 4 | RelayCount, 5 | RelayEose, 6 | RelayEvent, 7 | RelayNotice, 8 | RelayOK, 9 | WebSocketClientBase, 10 | } from "@nostr-ts/common"; 11 | 12 | export class WebSocketClient implements WebSocketClientBase { 13 | connection: WebSocket; 14 | error: { 15 | error: any; 16 | message: string; 17 | type: string; 18 | } | null; 19 | 20 | constructor() {} 21 | 22 | connect( 23 | url: string, 24 | options?: { 25 | rejectUnauthorized?: boolean; 26 | } 27 | ) { 28 | this.connection = new WebSocket(url, { 29 | rejectUnauthorized: options && options.rejectUnauthorized ? true : false, 30 | }); 31 | } 32 | 33 | isConnected() { 34 | return ( 35 | this.connection && this.connection.readyState === this.connection.OPEN 36 | ); 37 | } 38 | 39 | sendMessage( 40 | data: string, 41 | options?: { 42 | retries: number; 43 | retryTimeout: number; 44 | retryCount: number; 45 | } 46 | ) { 47 | // Wait until the connection is open 48 | const opts = options 49 | ? options 50 | : { retries: 10, retryTimeout: 100, retryCount: 0 }; 51 | 52 | if (!this.isConnected()) { 53 | const count = opts.retryCount + 1; 54 | if (count === 10) { 55 | throw new Error(`Could not send message after ${count} retries`); 56 | } 57 | setTimeout(() => this.sendMessage(data, opts), 100); 58 | } else { 59 | this.connection.send(data); 60 | } 61 | } 62 | 63 | listen( 64 | onMessage: ( 65 | data: 66 | | RelayAuth 67 | | RelayCount 68 | | RelayEose 69 | | RelayEvent 70 | | RelayNotice 71 | | RelayOK 72 | ) => void 73 | ) { 74 | this.connection.onmessage = (event: MessageEvent) => { 75 | onMessage(JSON.parse(event.data as string)); 76 | }; 77 | } 78 | 79 | disconnect() { 80 | this.connection.close(); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /packages/node/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/node/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./../tsconfig.base.json", 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "outDir": "dist/", 6 | "emitDecoratorMetadata": true 7 | }, 8 | "include": ["./src/**/*.ts"], 9 | "types": ["node"] 10 | } 11 | -------------------------------------------------------------------------------- /packages/tsconfig.base.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "declaration": true, 6 | "noImplicitAny": false, 7 | "skipLibCheck": true, 8 | "noUnusedLocals": false, 9 | "importHelpers": true, 10 | "removeComments": false, 11 | "noLib": false, 12 | "emitDecoratorMetadata": true, 13 | "experimentalDecorators": true, 14 | "target": "es2020", 15 | "sourceMap": false, 16 | "allowJs": false, 17 | "strict": true, 18 | "strictNullChecks": false 19 | }, 20 | "exclude": ["**/dist/**/*.d.ts", "dist", "node_modules"] 21 | } -------------------------------------------------------------------------------- /packages/web/.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | *.d.ts 3 | node_modules 4 | !build.js 5 | dist -------------------------------------------------------------------------------- /packages/web/README.md: -------------------------------------------------------------------------------- 1 | # nostr-ts Web 2 | 3 | > Nostr: A simple, open protocol that enables a truly censorship-resistant and global social network. 4 | 5 | This package is part of [nostr-ts](https://github.com/franzos/nostr-ts). 6 | 7 | - `@nostr-ts/common`: common types and functions 8 | - `@nostr-ts/node`: client for usage with node `ws` library 9 | - `@nostr-ts/web`: client for usage with browser `WebSocket` API 10 | 11 | Checkout the [documentation](https://github.com/franzos/nostr-ts) for more information. 12 | 13 | ## Installation 14 | 15 | ```bash 16 | pnpm add @nostr-ts/web 17 | ``` 18 | 19 | ## Get started 20 | 21 | ```js 22 | import { NewShortTextNote, NFilters } from '@nostr-ts/common' 23 | import { RelayClient, RelayDiscovery, loadOrCreateKeypair, NUser } from '@nostr-ts/node' 24 | ``` 25 | 26 | In the browser use: 27 | 28 | ```js 29 | import { NewShortTextNote, NFilters } from '@nostr-ts/common' 30 | import { RelayClient, loadOrCreateKeypair, NUser } from '@nostr-ts/web' 31 | ``` -------------------------------------------------------------------------------- /packages/web/build.js: -------------------------------------------------------------------------------- 1 | import { build } from 'esbuild'; 2 | import { nodeExternalsPlugin } from 'esbuild-node-externals'; 3 | import { esbuildDecorators } from '@anatine/esbuild-decorators'; 4 | 5 | const shared = { 6 | entryPoints: ['./src/index.ts'], 7 | bundle: true, 8 | treeShaking: true, 9 | platform: 'node', 10 | target: 'node18', 11 | plugins: [nodeExternalsPlugin()], 12 | } 13 | 14 | build({ 15 | ...shared, 16 | outfile: 'dist/index.cjs', 17 | format: 'cjs', 18 | plugins: [ 19 | esbuildDecorators({ 20 | tsconfig: 'tsconfig.build.json', 21 | cwd: process.cwd(), 22 | }), 23 | ...shared.plugins, 24 | ] 25 | }).catch((err) => { 26 | console.error(err); 27 | process.exit(1); 28 | }); 29 | 30 | build({ 31 | ...shared, 32 | outfile: 'dist/index.esm.js', 33 | format: 'esm', 34 | }).catch((err) => { 35 | console.error(err); 36 | process.exit(1); 37 | }); -------------------------------------------------------------------------------- /packages/web/jest.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "roots": [ 3 | "/src", 4 | ], 5 | "transform": { 6 | "^.+\\.tsx?$": "ts-jest" 7 | }, 8 | "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$", 9 | "moduleFileExtensions": [ 10 | "ts", 11 | "tsx", 12 | "js", 13 | "jsx", 14 | "json", 15 | "node" 16 | ], 17 | } 18 | -------------------------------------------------------------------------------- /packages/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@nostr-ts/web", 3 | "version": "0.0.6", 4 | "description": "nostr in the browser", 5 | "author": "Franz Geffke ", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/franzos/nostr-ts" 10 | }, 11 | "keywords": [ 12 | "nostr", 13 | "blockchain", 14 | "crypto" 15 | ], 16 | "type": "module", 17 | "scripts": { 18 | "prebuild": "rimraf dist", 19 | "tsc": "pnpm run build", 20 | "build": "pnpm run prebuild && node build.js && tsc -p tsconfig.json --emitDeclarationOnly", 21 | "check": "pnpm dlx madge --extensions ts --circular src/", 22 | "tests": "jest" 23 | }, 24 | "dependencies": { 25 | "@noble/curves": "^1.8.1", 26 | "@noble/hashes": "^1.7.1", 27 | "@nostr-ts/common": "workspace:*", 28 | "idb": "^7.1.1", 29 | "nanoid": "^3.3.8" 30 | }, 31 | "devDependencies": { 32 | "@anatine/esbuild-decorators": "^0.2.19", 33 | "@types/jest": "^29.5.14", 34 | "esbuild": "^0.17.19", 35 | "esbuild-node-externals": "^1.18.0", 36 | "fake-indexeddb": "^4.0.2", 37 | "jest": "^29.7.0", 38 | "jest-environment-jsdom": "^29.7.0", 39 | "rimraf": "^3.0.2", 40 | "tslib": "2.6.2", 41 | "typescript": "5.2.2" 42 | }, 43 | "main": "dist/index.cjs", 44 | "module": "dist/index.esm.js", 45 | "typings": "dist/index.d.ts", 46 | "files": [ 47 | "dist/*" 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /packages/web/src/classes/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./relay-client"; 2 | export * from "./user"; 3 | export * from "./sattelite-cdn"; 4 | -------------------------------------------------------------------------------- /packages/web/src/classes/sattelite-cdn.test.ts: -------------------------------------------------------------------------------- 1 | import { NEvent, generateClientKeys } from "@nostr-ts/common"; 2 | import { 3 | sCDNCreditRequest, 4 | sCDNGetInvoice, 5 | sCDNGetTerms, 6 | } from "./sattelite-cdn"; 7 | 8 | describe("Satellite CDN", () => { 9 | test("Get invoice", async () => { 10 | const keypair = generateClientKeys(); 11 | 12 | const request = sCDNCreditRequest(1); 13 | request.signAndGenerateId(keypair); 14 | 15 | const terms = await sCDNGetTerms(request); 16 | 17 | expect(terms.payment.kind).toBe(9734); 18 | const payment = new NEvent(terms.payment); 19 | payment.signAndGenerateId(keypair); 20 | 21 | const invoice = await sCDNGetInvoice(terms, payment); 22 | 23 | expect(invoice.status).toBe("OK"); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /packages/web/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./classes/index"; 2 | export * from "./utils/index"; 3 | export * from "./worker/index"; 4 | -------------------------------------------------------------------------------- /packages/web/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./keypair"; 2 | -------------------------------------------------------------------------------- /packages/web/src/utils/keypair.ts: -------------------------------------------------------------------------------- 1 | import { generateClientKeys } from "@nostr-ts/common"; 2 | 3 | export function loadKeyFromLocalStorage(key: string): string | null { 4 | return localStorage.getItem(key); 5 | } 6 | 7 | /** 8 | * IMPORTANT: DO NOT USE THIS 9 | * 10 | * Well, of course you can use it. It'll work just fine ... 11 | * but if you care about your users, find another approach! 12 | * @param keypair 13 | */ 14 | export function saveKeyPairToLocalStorage(keypair: { 15 | privateKey: string; 16 | publicKey: string; 17 | }) { 18 | localStorage.setItem("private-key", keypair.privateKey); 19 | localStorage.setItem("public-key", keypair.publicKey); 20 | } 21 | 22 | export async function loadOrCreateKeypair() { 23 | let privateKey = loadKeyFromLocalStorage("private-key"); 24 | let publicKey = loadKeyFromLocalStorage("public-key"); 25 | 26 | if (privateKey && publicKey) { 27 | return { privateKey, publicKey }; 28 | } else { 29 | const keypair = generateClientKeys(); 30 | saveKeyPairToLocalStorage(keypair); 31 | return keypair; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/web/src/utils/make-request.ts: -------------------------------------------------------------------------------- 1 | export async function makeRequest( 2 | url: string, 3 | options?: { 4 | headers?: any; 5 | method?: "GET" | "POST" | "PUT" | "DELETE"; 6 | body?: any; 7 | } 8 | ) { 9 | try { 10 | let response = (await Promise.race([ 11 | fetch(url, { 12 | headers: options ? options?.headers : undefined, 13 | method: options ? options?.method : "GET", 14 | body: options ? options?.body : undefined, 15 | }), 16 | new Promise((_, reject) => 17 | setTimeout(() => reject(new Error("Timeout")), 5000) 18 | ), 19 | ])) as Response; 20 | 21 | if (!response.ok) { 22 | throw new Error(`HTTP error! status: ${response.status}`); 23 | } 24 | 25 | const result = await response.json(); 26 | return result; 27 | } catch (error) { 28 | throw new Error(`Error making request: ${error}`); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/web/src/utils/relay-information.ts: -------------------------------------------------------------------------------- 1 | import { RelayInformationDocument } from "@nostr-ts/common"; 2 | import { makeRequest } from "./make-request"; 3 | 4 | /** 5 | * Get relay information document 6 | * https://github.com/nostr-protocol/nips/blob/master/11.md 7 | * 8 | * Note: Some relays may not support this 9 | * @param webSocketUrl 10 | * @returns 11 | */ 12 | export async function getRelayInformationDocument( 13 | webSocketUrl: string 14 | ): Promise { 15 | // Replace "wss://" with "https://" 16 | let httpsUrl = webSocketUrl.replace(/^wss:\/\//i, "https://"); 17 | 18 | console.log("###############"); 19 | console.log("Fetching relay information from", httpsUrl); 20 | console.log("###############"); 21 | 22 | const controller = new AbortController(); 23 | setTimeout(() => { 24 | controller.abort(); 25 | }, 5000); 26 | 27 | const headers = { 28 | Accept: "application/nostr+json", 29 | }; 30 | 31 | return makeRequest(httpsUrl, { 32 | headers, 33 | }); 34 | } 35 | -------------------------------------------------------------------------------- /packages/web/src/utils/ws.ts: -------------------------------------------------------------------------------- 1 | import { 2 | RelayAuth, 3 | RelayCount, 4 | RelayEose, 5 | RelayEvent, 6 | RelayNotice, 7 | RelayOK, 8 | WebSocketClientBase, 9 | WebSocketClientConfig, 10 | } from "@nostr-ts/common"; 11 | 12 | export class WebSocketClient implements WebSocketClientBase { 13 | connection: WebSocket; 14 | error: { 15 | error: any; 16 | message: string; 17 | type: string; 18 | } | null; 19 | 20 | public config: WebSocketClientConfig; 21 | 22 | constructor() {} 23 | 24 | connect(url: string) { 25 | this.connection = new WebSocket(url); 26 | } 27 | 28 | isConnected() { 29 | return ( 30 | this.connection && this.connection.readyState === this.connection.OPEN 31 | ); 32 | } 33 | 34 | sendMessage( 35 | data: string, 36 | options?: { 37 | retries: number; 38 | retryTimeout: number; 39 | retryCount: number; 40 | } 41 | ) { 42 | // Wait until the connection is open 43 | const opts = options 44 | ? options 45 | : { retries: 10, retryTimeout: 100, retryCount: 0 }; 46 | 47 | if (!this.isConnected()) { 48 | const count = opts.retryCount + 1; 49 | if (count === 10) { 50 | throw new Error(`Could not send message after ${count} retries`); 51 | } 52 | setTimeout(() => this.sendMessage(data, opts), 100); 53 | } else { 54 | this.connection.send(data); 55 | } 56 | } 57 | 58 | listen( 59 | onMessage: ( 60 | data: 61 | | RelayAuth 62 | | RelayCount 63 | | RelayEose 64 | | RelayEvent 65 | | RelayNotice 66 | | RelayOK 67 | ) => void 68 | ) { 69 | this.connection.onmessage = (event: MessageEvent) => { 70 | onMessage(JSON.parse(event.data)); 71 | }; 72 | } 73 | 74 | disconnect() { 75 | this.connection.close(); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /packages/web/src/worker/database-migration.ts: -------------------------------------------------------------------------------- 1 | import { ProcessedUserBase, EventBase, NEVENT_KIND } from "@nostr-ts/common"; 2 | 3 | export function dbMigration(db, oldVersion, _newVersion, transaction) { 4 | if (oldVersion < 1) { 5 | // Initial version 6 | console.log(`DATABASE: Migrating database from version ${oldVersion} to 1`); 7 | if (!db.objectStoreNames.contains("users")) { 8 | db.createObjectStore("users", { keyPath: "user.pubkey" }); 9 | } 10 | if (!db.objectStoreNames.contains("following")) { 11 | db.createObjectStore("following", { keyPath: "user.pubkey" }); 12 | } 13 | } 14 | 15 | if (oldVersion < 2) { 16 | console.log(`DATABASE: Migrating database from version ${oldVersion} to 2`); 17 | if (db.objectStoreNames.contains("following")) { 18 | db.deleteObjectStore("following"); 19 | } 20 | } 21 | 22 | if (oldVersion < 3) { 23 | console.log(`DATABASE: Migrating database from version ${oldVersion} to 3`); 24 | if (!db.objectStoreNames.contains("lists")) { 25 | db.createObjectStore("lists", { keyPath: "id" }); 26 | const listStore = transaction.objectStore("lists"); 27 | listStore.createIndex("users", "userPubkeys", { multiEntry: true }); 28 | } 29 | } 30 | 31 | if (oldVersion < 4) { 32 | console.log(`DATABASE: Migrating database from version ${oldVersion} to 4`); 33 | const eventStore = db.createObjectStore("events", { keyPath: "id" }); 34 | 35 | eventStore.createIndex("pubkey", "pubkey", { unique: false }); 36 | eventStore.createIndex("kind", "kind", { unique: false }); 37 | eventStore.createIndex("created_at", "created_at", { unique: false }); 38 | 39 | eventStore.createIndex("kindAndPubkey", ["kind", "pubkey"], { 40 | unique: false, 41 | }); 42 | 43 | const tagStore = db.createObjectStore("tags", { keyPath: "id" }); 44 | 45 | tagStore.createIndex("eventId", "eventId", { unique: false }); 46 | tagStore.createIndex("typeAndValue", ["type", "value"], { 47 | unique: false, 48 | }); 49 | } 50 | } 51 | 52 | export interface NClientDB { 53 | users: { 54 | key: string; 55 | value: ProcessedUserBase; 56 | indexes: { 57 | user: { 58 | pubkey: string; 59 | }; 60 | }; 61 | }; 62 | lists: { 63 | key: string; 64 | value: { 65 | title: string; 66 | description?: string; 67 | tags?: string[]; 68 | userPubkeys?: string[]; 69 | }; 70 | indexes: { 71 | userPubkeys: string[]; 72 | }; 73 | }; 74 | events: { 75 | key: string; 76 | value: EventBase; 77 | indexes: { 78 | pubkey: string; 79 | kind: NEVENT_KIND; 80 | created_at: number; 81 | kindAndPubkey: [NEVENT_KIND, string]; 82 | }; 83 | }; 84 | tags: { 85 | key: string; 86 | value: { 87 | eventId: string; 88 | id: string; 89 | type: string; 90 | value: string; 91 | }; 92 | indexes: { 93 | eventId: string; 94 | typeAndValue: [string, string]; 95 | }; 96 | }; 97 | } 98 | -------------------------------------------------------------------------------- /packages/web/src/worker/index.ts: -------------------------------------------------------------------------------- 1 | export { NWorker } from "./worker"; 2 | export { IncomingEventsQueue } from "./worker-queue"; 3 | export * from "./worker-extra"; 4 | -------------------------------------------------------------------------------- /packages/web/src/worker/lists.ts: -------------------------------------------------------------------------------- 1 | import { UserRecord } from "@nostr-ts/common"; 2 | 3 | export interface CreateListRecord { 4 | title: string; 5 | description?: string; 6 | tags?: string[]; 7 | userPubkeys?: string[]; 8 | } 9 | 10 | export interface ListRecord extends CreateListRecord { 11 | id: string; 12 | } 13 | 14 | export interface ProcessedListRecord extends ListRecord { 15 | users?: UserRecord[]; 16 | } 17 | 18 | export type ProcessedEventKeys = 19 | | "reactions" 20 | | "replies" 21 | | "mentions" 22 | | "reposts" 23 | | "badgeAwards" 24 | | "zapReceipt"; 25 | -------------------------------------------------------------------------------- /packages/web/src/worker/worker-queue.ts: -------------------------------------------------------------------------------- 1 | export class IncomingEventsQueue { 2 | _priority: Promise[]; 3 | _background: Promise[]; 4 | _current: Promise; 5 | 6 | constructor() { 7 | this._priority = []; 8 | this._background = []; 9 | this._current = Promise.resolve(); 10 | } 11 | 12 | enqueuePriority(task: () => Promise) { 13 | const taskPromise = this._current.then(() => task()); 14 | this._priority.push(taskPromise); 15 | this._current = taskPromise.catch(() => {}); 16 | return taskPromise; 17 | } 18 | 19 | enqueueBackground(task: () => Promise) { 20 | const taskPromise = this._current.then(() => task()); 21 | this._background.push(taskPromise); 22 | this._current = taskPromise.catch(() => {}); 23 | return taskPromise; 24 | } 25 | 26 | clearPriority() { 27 | this._priority = []; 28 | } 29 | 30 | clearBackground() { 31 | this._background = []; 32 | } 33 | 34 | // Add a sleep function to introduce delay 35 | sleep(ms: number) { 36 | return new Promise((resolve) => setTimeout(resolve, ms)); 37 | } 38 | 39 | /** 40 | * Manually process the queue with throttling 41 | */ 42 | async process(batchSize = 5, delay = 500) { 43 | while (this._priority.length > 0 || this._background.length > 0) { 44 | const tasksToRun = []; 45 | 46 | // Take `batchSize` tasks from priority queue or from the background queue 47 | while ( 48 | tasksToRun.length < batchSize && 49 | (this._priority.length > 0 || this._background.length > 0) 50 | ) { 51 | tasksToRun.push(this._priority.shift() || this._background.shift()); 52 | } 53 | 54 | // Wait for the batch to complete 55 | await Promise.all(tasksToRun); 56 | 57 | // Introduce a delay before the next batch 58 | if (this._priority.length > 0 || this._background.length > 0) { 59 | await this.sleep(delay); 60 | } 61 | } 62 | 63 | this._current = Promise.resolve(); 64 | } 65 | 66 | clear() { 67 | this._priority = []; 68 | this._background = []; 69 | this._current = Promise.resolve(); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /packages/web/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./../tsconfig.base.json", 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "outDir": "dist/", 6 | "emitDecoratorMetadata": true 7 | }, 8 | "include": ["./src/**/*.ts"], 9 | "types": ["dom"] 10 | } 11 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | # all packages in direct subdirs of packages/ 3 | - 'packages/*' 4 | - 'client' 5 | - 'client-web' 6 | -------------------------------------------------------------------------------- /relay-docker/.gitignore: -------------------------------------------------------------------------------- 1 | selfsigned.crt 2 | selfsigned.key -------------------------------------------------------------------------------- /relay-docker/Dockerfile.gnost-relay: -------------------------------------------------------------------------------- 1 | FROM golang:1.21 AS builder 2 | 3 | RUN apt-get update && apt-get install -y git 4 | 5 | WORKDIR /go/src/github.com/barkyq/gnost-relay 6 | 7 | RUN git clone https://github.com/barkyq/gnost-relay.git . 8 | 9 | RUN go mod download 10 | 11 | RUN go build -o /go/bin/gnost-relay 12 | RUN ls /go/bin 13 | 14 | FROM golang:1.21 15 | 16 | WORKDIR / 17 | 18 | COPY --from=builder /go/bin/gnost-relay /gnost-relay 19 | COPY entrypoint.sh /entrypoint.sh 20 | 21 | RUN chmod +x /entrypoint.sh 22 | 23 | ENTRYPOINT ["/entrypoint.sh"] -------------------------------------------------------------------------------- /relay-docker/Dockerfile.nginx: -------------------------------------------------------------------------------- 1 | FROM nginx:latest 2 | 3 | RUN rm -f /etc/nginx/conf.d/* 4 | 5 | COPY nginx.conf /etc/nginx/conf.d/ 6 | 7 | COPY selfsigned.crt /etc/ssl/certs/ 8 | COPY selfsigned.key /etc/ssl/private/ 9 | -------------------------------------------------------------------------------- /relay-docker/README.md: -------------------------------------------------------------------------------- 1 | # Relay 2 | 3 | A collection of docker containers to quickly run a local relay. 4 | 5 | This is based on [gnost-relay](https://github.com/barkyq/gnost-relay). 6 | 7 | Generate a self-signed certificate for nginx: 8 | 9 | ```bash 10 | openssl req -x509 -newkey rsa:4096 -keyout selfsigned.key -out selfsigned.crt -days 365 -nodes 11 | ``` 12 | 13 | Start Docker: 14 | 15 | ```bash 16 | docker-compose up 17 | 18 | # Rebuild: 19 | # docker-compose up --build 20 | ``` 21 | 22 | Edit your hostfile (ex.: `/etc/hosts`): 23 | 24 | ``` 25 | 127.0.0.1 nostr-ts.relay 26 | ``` 27 | 28 | Connect to `wss://nostr-ts.relay` 29 | 30 | Check info with curl: 31 | 32 | ```bash 33 | curl --insecure -H "Accept: application/nostr+json" https://nostr-ts.relay 34 | ``` 35 | 36 | Usage in Browser: Open [https://nostr-ts.relay](https://nostr-ts.relay) and approve the self-signed certificate, then connect with `client-web`. 37 | 38 | If you want to use `client` instead, you might need to start it like this (self-signed certificate): 39 | 40 | ```bash 41 | NODE_TLS_REJECT_UNAUTHORIZED=0 node dist/index.js 42 | ``` -------------------------------------------------------------------------------- /relay-docker/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "host": "0.0.0.0:8080", 3 | "relay_url": "wss://nostr-ts.relay", 4 | "nip11_info_document": { 5 | "name": "GNOST Relay", 6 | "description": "GNOST Relay", 7 | "pubkey": "6c1a8060cbdf0e9628958b259e43112b9e94b4a07e8a858cf12a3ae8a7c76285", 8 | "contact": "barkyq", 9 | "supported_nips": [1, 9, 11, 12, 15, 16, 20, 22, 26, 28, 33, 40, 42], 10 | "software": "git+https://github.com/barkyq/gnost-relay", 11 | "version": "0.3" 12 | }, 13 | "max_limit": 25, 14 | "subid_max_length": 32, 15 | "websocket_rate_limit": 0.5, 16 | "websocket_burst": 4, 17 | "delete_expired_events_period": 3600 18 | } 19 | -------------------------------------------------------------------------------- /relay-docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | postgres: 5 | image: postgres:13 6 | environment: 7 | POSTGRES_USER: barkyq 8 | POSTGRES_PASSWORD: super_secret_password 9 | POSTGRES_DB: nostrdb 10 | container_name: postgres_service 11 | 12 | gnost_relay: 13 | build: 14 | context: . 15 | dockerfile: Dockerfile.gnost-relay 16 | # Uncomment if you're using nftables 17 | # network: host 18 | environment: 19 | DATABASE_URL: postgres://barkyq:super_secret_password@postgres:5432/nostrdb 20 | depends_on: 21 | - postgres 22 | volumes: 23 | - ./config.json:/config.json 24 | container_name: gnost_relay_service 25 | 26 | nginx: 27 | build: 28 | context: . 29 | dockerfile: Dockerfile.nginx 30 | ports: 31 | - "443:443" 32 | depends_on: 33 | - gnost_relay 34 | container_name: nginx_service 35 | -------------------------------------------------------------------------------- /relay-docker/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Wait for Postgres to start 4 | sleep 3 5 | 6 | ./gnost-relay 7 | -------------------------------------------------------------------------------- /relay-docker/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | server_name nostr-ts.relay; 4 | return 301 https://$host$request_uri; 5 | } 6 | 7 | server { 8 | listen 443 ssl; 9 | server_name nostr-ts.relay; 10 | 11 | ssl_certificate /etc/ssl/certs/selfsigned.crt; 12 | ssl_certificate_key /etc/ssl/private/selfsigned.key; 13 | 14 | location / { 15 | proxy_pass http://gnost_relay_service:8080; 16 | proxy_http_version 1.1; 17 | proxy_set_header Upgrade $http_upgrade; 18 | proxy_set_header Connection "Upgrade"; 19 | proxy_set_header Host $host; 20 | proxy_read_timeout 3600; 21 | proxy_send_timeout 3600; 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "declaration": true, 6 | "strict": true, 7 | "moduleResolution": "node", 8 | "resolveJsonModule": true, 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "baseUrl": ".", 13 | "paths": { 14 | "*": ["node_modules/*"], 15 | } 16 | }, 17 | "include": ["packages/*/src/**/*.ts"], 18 | "exclude": ["node_modules"] 19 | } 20 | --------------------------------------------------------------------------------