diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..fcd7d2e --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: [CodyTseng] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e4f0166 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,57 @@ +name: Build/release + +on: + push: + tags: + - v*.*.* + +permissions: + contents: write + +jobs: + release: + runs-on: ${{ matrix.os }} + + strategy: + matrix: + os: [ubuntu-latest, macos-13, windows-latest] + + steps: + - name: Check out Git repository + uses: actions/checkout@v4 + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install Dependencies + run: npm install + + - name: build-linux + if: matrix.os == 'ubuntu-latest' + run: npm run build:linux + + - name: build-mac + if: matrix.os == 'macos-13' + run: npm run build:mac + + - name: build-win + if: matrix.os == 'windows-latest' + run: npm run build:win + + - name: release + uses: softprops/action-gh-release@v2 + with: + draft: true + files: | + dist/*.exe + dist/*.zip + dist/*.dmg + dist/*.AppImage + dist/*.snap + dist/*.deb + dist/*.rpm + dist/*.tar.gz + env: + GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }} diff --git a/README.md b/README.md index e078fec..9a2bbce 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,19 @@ Yet another Nostr desktop client +## Features + +- **Relay-Based Browsing:** Explore content directly through relays without following specific users. Discover diverse topics across different relays +- **Relay-Friendly Design:** Minimized and simplified requests ensure efficient communication with relays +- **Relay Groups:** Organize similar relays into custom groups for seamless switching between different content streams +- **Clean Interface:** Enjoy a minimalist design and intuitive interactions + +## Download + +You can download the latest version from the [release page](https://github.com/CodyTseng/jumble/releases). If you want to use Apple Silicon version, you need to build it from the source code. + +Because the app is not signed, you may need to allow it to run in the system settings. + ## Build from source You can also build the app from the source code. diff --git a/build/icon.icns b/build/icon.icns index 28644aa..0a4cce0 100644 Binary files a/build/icon.icns and b/build/icon.icns differ diff --git a/build/icon.ico b/build/icon.ico index 72c391e..d21ccec 100644 Binary files a/build/icon.ico and b/build/icon.ico differ diff --git a/build/icon.png b/build/icon.png index cf9e8b2..523ef68 100644 Binary files a/build/icon.png and b/build/icon.png differ diff --git a/package-lock.json b/package-lock.json index 125eb0d..ab0ef37 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,12 +12,28 @@ "dependencies": { "@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/utils": "^3.0.0", + "@radix-ui/react-avatar": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.2", + "@radix-ui/react-dropdown-menu": "^2.1.2", + "@radix-ui/react-hover-card": "^1.1.2", + "@radix-ui/react-popover": "^1.1.2", + "@radix-ui/react-radio-group": "^1.2.1", + "@radix-ui/react-scroll-area": "^1.2.0", + "@radix-ui/react-select": "^2.1.2", + "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-toast": "^1.2.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "dayjs": "^1.11.13", + "lru-cache": "^11.0.1", "lucide-react": "^0.453.0", + "nostr-tools": "^2.9.1", + "react-resizable-panels": "^2.1.5", + "react-string-replace": "^1.1.1", "tailwind-merge": "^2.5.4", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "yet-another-react-lightbox": "^3.21.6" }, "devDependencies": { "@electron-toolkit/eslint-config-prettier": "^2.0.0", @@ -159,6 +175,15 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, "node_modules/@babel/helper-compilation-targets/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -1161,6 +1186,40 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.8.tgz", + "integrity": "sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==", + "dependencies": { + "@floating-ui/utils": "^0.2.8" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.11.tgz", + "integrity": "sha512-qkMCxSR24v2vGkhYDo/UzxfJN3D4syqSjyuTFz6C7XcpU1pASPRieNI0Kj5VP3/503mOfYiGY891ugBX1GlABQ==", + "dependencies": { + "@floating-ui/core": "^1.6.0", + "@floating-ui/utils": "^0.2.8" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz", + "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==", + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.8.tgz", + "integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==" + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -1423,6 +1482,47 @@ "node": ">= 10.0.0" } }, + "node_modules/@noble/ciphers": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz", + "integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "dependencies": { + "@noble/hashes": "1.3.2" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves/node_modules/@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz", + "integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1431,55 +1531,853 @@ "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" }, - "engines": { - "node": ">= 8" + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@pkgr/core": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz", + "integrity": "sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz", + "integrity": "sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz", + "integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.0.tgz", + "integrity": "sha512-FmlW1rCg7hBpEBwFbjHwCW6AmWLQM6g/v0Sn8XbP9NvmSZ2San1FpQeyPtufzOMSIx7Y4dzjlHoifhp+7NkZhw==", + "dependencies": { + "@radix-ui/react-primitive": "2.0.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.1.tgz", + "integrity": "sha512-eoOtThOmxeoizxpX6RiEsQZ2wj5r4+zoeqAwO0cBaFQGjJwIH3dIX0OCxNrCyrrdxG+vBweMETh3VziQG7c1kw==", + "dependencies": { + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.0.tgz", + "integrity": "sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-slot": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-context": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", + "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", + "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", + "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.2.tgz", + "integrity": "sha512-Yj4dZtqa2o+kG61fzB0H2qUvmwBA2oyQroGLyNtBj1beo1khoQ3q1a2AO8rrQYjd8256CO9+N8L9tvsS+bnIyA==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.1", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-portal": "1.1.2", + "@radix-ui/react-presence": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-slot": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "2.6.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", + "integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.1.tgz", + "integrity": "sha512-QSxg29lfr/xcev6kSz7MAlmDnzbP1eI/Dwn3Tp1ip0KT5CUELsxkekFEMVBEoykI3oV39hKT4TKZzBNMbcTZYQ==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-escape-keydown": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.2.tgz", + "integrity": "sha512-GVZMR+eqK8/Kes0a36Qrv+i20bAPXSn8rCBTHx30w+3ECnR5o3xixAlqcVaYvLeyKUsm0aqyhWfmUcqufM8nYA==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-menu": "2.1.2", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz", + "integrity": "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.0.tgz", + "integrity": "sha512-200UD8zylvEyL8Bx+z76RJnASR2gRMuxlgFCPAe/Q/679a/r0eK3MBVYMb7vZODZcffZBdob1EGnky78xmVvcA==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.2.tgz", + "integrity": "sha512-Y5w0qGhysvmqsIy6nQxaPa6mXNKznfoGjOfBgzOjocLxr2XlSjqBMYQQL+FfyogsMuX+m8cZyQGYhJxvxUzO4w==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.1", + "@radix-ui/react-popper": "1.2.0", + "@radix-ui/react-portal": "1.1.2", + "@radix-ui/react-presence": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", + "integrity": "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.2.tgz", + "integrity": "sha512-lZ0R4qR2Al6fZ4yCCZzu/ReTFrylHFxIqy7OezIpWF4bL0o9biKo0pFIvkaew3TyZ9Fy5gYVrR5zCGZBVbO1zg==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-collection": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-dismissable-layer": "1.1.1", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.0", + "@radix-ui/react-portal": "1.1.2", + "@radix-ui/react-presence": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-roving-focus": "1.1.0", + "@radix-ui/react-slot": "1.1.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "2.6.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.2.tgz", + "integrity": "sha512-u2HRUyWW+lOiA2g0Le0tMmT55FGOEWHwPFt1EPfbLly7uXQExFo5duNKqG2DzmFXIdqOeNd+TpE8baHWJCyP9w==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.1", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.0", + "@radix-ui/react-portal": "1.1.2", + "@radix-ui/react-presence": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-slot": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "2.6.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.0.tgz", + "integrity": "sha512-ZnRMshKF43aBxVWPWvbj21+7TQCvhuULWJ4gNIKYpRlQt5xGRhLx66tMp8pya2UkGHTSlhpXwmjqltDYHhw7Vg==", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-rect": "1.1.0", + "@radix-ui/react-use-size": "1.1.0", + "@radix-ui/rect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-context": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", + "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.2.tgz", + "integrity": "sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==", + "dependencies": { + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.1.tgz", + "integrity": "sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz", + "integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", + "dependencies": { + "@radix-ui/react-slot": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.2.1.tgz", + "integrity": "sha512-kdbv54g4vfRjja9DNWPMxKvXblzqbpEC8kspEkZ6dVP7kQksGCn+iZHkcCz2nb00+lPdRvxrqy4WrvvV1cNqrQ==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-presence": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-roving-focus": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz", + "integrity": "sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-collection": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-context": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", + "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.0.tgz", + "integrity": "sha512-q2jMBdsJ9zB7QG6ngQNzNwlvxLQqONyL58QbEGwuyRZZb/ARQwk3uQVbCF7GvQVOtV6EU/pDxAw3zRzJZI3rpQ==", + "dependencies": { + "@radix-ui/number": "1.1.0", + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-presence": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.2.tgz", + "integrity": "sha512-rZJtWmorC7dFRi0owDmoijm6nSJH1tVw64QGiNIZ9PNLyBDtG+iAq+XGsya052At4BfarzY/Dhv9wrrUr6IMZA==", + "dependencies": { + "@radix-ui/number": "1.1.0", + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-collection": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-dismissable-layer": "1.1.1", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.0", + "@radix-ui/react-portal": "1.1.2", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-slot": "1.1.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-visually-hidden": "1.1.0", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "2.6.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.0.tgz", + "integrity": "sha512-3uBAs+egzvJBDZAzvb/n4NxxOYpnspmWxO2u5NbZ8Y6FM/NdrGSF9bop3Cf6F6C71z1rTSn8KV0Fo2ZVd79lGA==", + "dependencies": { + "@radix-ui/react-primitive": "2.0.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", + "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.2.tgz", + "integrity": "sha512-Z6pqSzmAP/bFJoqMAston4eSNa+ud44NSZTiZUmUen+IOZ5nBY8kzuU5WDBVyFXPtcW6yUalOHsxM/BP6Sv8ww==", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-collection": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.1", + "@radix-ui/react-portal": "1.1.2", + "@radix-ui/react-presence": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-visually-hidden": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", + "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "engines": { - "node": ">= 8" + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", + "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz", + "integrity": "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==", "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" + "@radix-ui/react-use-callback-ref": "1.1.0" }, - "engines": { - "node": ">= 8" + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "optional": true, - "engines": { - "node": ">=14" + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz", + "integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@pkgr/core": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz", - "integrity": "sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==", - "dev": true, - "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.0.tgz", + "integrity": "sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, - "funding": { - "url": "https://opencollective.com/unts" + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@radix-ui/react-compose-refs": { + "node_modules/@radix-ui/react-use-rect": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", - "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz", + "integrity": "sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==", + "dependencies": { + "@radix-ui/rect": "1.1.0" + }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -1490,12 +2388,12 @@ } } }, - "node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-use-size": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", - "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz", + "integrity": "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.0" + "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", @@ -1507,6 +2405,33 @@ } } }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.0.tgz", + "integrity": "sha512-N8MDZqtgCgG5S3aV60INAB475osJousYpZ4cTJ2cFbMpdHS5Y6loLTH8LPtkj2QN0x93J30HT/M3qJXM0+lyeQ==", + "dependencies": { + "@radix-ui/react-primitive": "2.0.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz", + "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.24.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.24.0.tgz", @@ -1715,6 +2640,53 @@ "win32" ] }, + "node_modules/@scure/base": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz", + "integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ] + }, + "node_modules/@scure/bip32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.1.tgz", + "integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==", + "dependencies": { + "@noble/curves": "~1.1.0", + "@noble/hashes": "~1.3.1", + "@scure/base": "~1.1.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32/node_modules/@noble/curves": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz", + "integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==", + "dependencies": { + "@noble/hashes": "1.3.1" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.1.tgz", + "integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==", + "dependencies": { + "@noble/hashes": "~1.3.0", + "@scure/base": "~1.1.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@sindresorhus/is": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", @@ -1880,7 +2852,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==", - "dev": true, + "devOptional": true, "dependencies": { "@types/react": "*" } @@ -2418,6 +3390,17 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, + "node_modules/aria-hidden": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.4.tgz", + "integrity": "sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/array-buffer-byte-length": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", @@ -3378,6 +4361,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==" + }, "node_modules/debug": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", @@ -3482,6 +4470,11 @@ "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", "optional": true }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==" + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -4801,6 +5794,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "engines": { + "node": ">=6" + } + }, "node_modules/get-stream": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", @@ -5263,6 +6264,14 @@ "node": ">= 0.4" } }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/is-array-buffer": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", @@ -6002,12 +7011,11 @@ } }, "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "dependencies": { - "yallist": "^3.0.2" + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.0.1.tgz", + "integrity": "sha512-CgeuL5uom6j/ZVrg7G/+1IXqRY8JXX4Hghfy5YE0EhoYQWvndP1kufu58cmZLNIDKnRhZrXfdS9urVWx98AipQ==", + "engines": { + "node": "20 || >=22" } }, "node_modules/lucide-react": { @@ -6253,6 +7261,36 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/nostr-tools": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.9.1.tgz", + "integrity": "sha512-QYK/M2eugK82dEriVGSzZCRn6Sbb88bxICA15wr+ebEULSACysZnDhNoCxdJyqZPva5x+Ql7h4JmEfpc6Ov2fQ==", + "dependencies": { + "@noble/ciphers": "^0.5.1", + "@noble/curves": "1.2.0", + "@noble/hashes": "1.3.1", + "@scure/base": "1.1.1", + "@scure/bip32": "1.3.1", + "@scure/bip39": "1.2.1" + }, + "optionalDependencies": { + "nostr-wasm": "v0.1.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/nostr-wasm": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz", + "integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==", + "optional": true + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -6858,7 +7896,6 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "dev": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -6882,6 +7919,90 @@ "node": ">=0.10.0" } }, + "node_modules/react-remove-scroll": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.0.tgz", + "integrity": "sha512-I2U4JVEsQenxDAKaVa3VZ/JeJZe0/2DxPWL8Tj8yLKctQJQiZM52pn/GWFpSp8dftjM3pSAHVJZscAnC/y+ySQ==", + "dependencies": { + "react-remove-scroll-bar": "^2.3.6", + "react-style-singleton": "^2.2.1", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.0", + "use-sidecar": "^1.1.2" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.6.tgz", + "integrity": "sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g==", + "dependencies": { + "react-style-singleton": "^2.2.1", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-resizable-panels": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-2.1.5.tgz", + "integrity": "sha512-JMSe18rYupmx+dzYcdfWYZ93ZdxqQmLum3xWDVSUMI0UVwl9bB9gUaFmPbxYoO4G+m5sqgdXQCYQxnOysytfnw==", + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/react-string-replace": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/react-string-replace/-/react-string-replace-1.1.1.tgz", + "integrity": "sha512-26TUbLzLfHQ5jO5N7y3Mx88eeKo0Ml0UjCQuX4BMfOd/JX+enQqlKpL1CZnmjeBRvQE8TR+ds9j1rqx9CxhKHQ==", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", + "integrity": "sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==", + "dependencies": { + "get-nonce": "^1.0.0", + "invariant": "^2.2.4", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -7235,7 +8356,6 @@ "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "dev": true, "dependencies": { "loose-envify": "^1.1.0" } @@ -7942,8 +9062,7 @@ "node_modules/tslib": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.0.tgz", - "integrity": "sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA==", - "dev": true + "integrity": "sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA==" }, "node_modules/type-check": { "version": "0.4.0", @@ -8046,7 +9165,7 @@ "version": "5.6.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", - "dev": true, + "devOptional": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8122,6 +9241,47 @@ "punycode": "^2.1.0" } }, + "node_modules/use-callback-ref": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.2.tgz", + "integrity": "sha512-elOQwe6Q8gqZgDA8mrh44qRTQqpIHDcZ3hXTLjBe1i4ph8XpNJnO+aQf3NaG+lriLopI4HMx9VjQLfPQ6vhnoA==", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", + "integrity": "sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.9.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/utf8-byte-length": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", @@ -8419,6 +9579,18 @@ "fd-slicer": "~1.1.0" } }, + "node_modules/yet-another-react-lightbox": { + "version": "3.21.6", + "resolved": "https://registry.npmjs.org/yet-another-react-lightbox/-/yet-another-react-lightbox-3.21.6.tgz", + "integrity": "sha512-uKcRmmezsj1Fbj38B6hFOGwbAu94fPr8d5H6I0+1FmcToX56freEGXXXtdA1oRo6036ug+UgrKZzzvsw/MIM/w==", + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 860d0e6..8c81f16 100644 --- a/package.json +++ b/package.json @@ -28,12 +28,28 @@ "dependencies": { "@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/utils": "^3.0.0", + "@radix-ui/react-avatar": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.2", + "@radix-ui/react-dropdown-menu": "^2.1.2", + "@radix-ui/react-hover-card": "^1.1.2", + "@radix-ui/react-popover": "^1.1.2", + "@radix-ui/react-radio-group": "^1.2.1", + "@radix-ui/react-scroll-area": "^1.2.0", + "@radix-ui/react-select": "^2.1.2", + "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-toast": "^1.2.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "dayjs": "^1.11.13", + "lru-cache": "^11.0.1", "lucide-react": "^0.453.0", + "nostr-tools": "^2.9.1", + "react-resizable-panels": "^2.1.5", + "react-string-replace": "^1.1.1", "tailwind-merge": "^2.5.4", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "yet-another-react-lightbox": "^3.21.6" }, "devDependencies": { "@electron-toolkit/eslint-config-prettier": "^2.0.0", diff --git a/resources/icon.png b/resources/icon.png index cf9e8b2..523ef68 100644 Binary files a/resources/icon.png and b/resources/icon.png differ diff --git a/src/common/types.ts b/src/common/types.ts new file mode 100644 index 0000000..36ee22d --- /dev/null +++ b/src/common/types.ts @@ -0,0 +1,13 @@ +export type TRelayGroup = { + groupName: string + relayUrls: string[] + isActive: boolean +} + +export type TConfig = { + relayGroups: TRelayGroup[] + theme: TThemeSetting +} + +export type TThemeSetting = 'light' | 'dark' | 'system' +export type TTheme = 'light' | 'dark' diff --git a/src/main/index.ts b/src/main/index.ts index ff3b8a4..80febe9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,11 +1,16 @@ -import { app, shell, BrowserWindow, ipcMain } from 'electron' +import { electronApp, is, optimizer } from '@electron-toolkit/utils' +import { app, BrowserWindow, shell } from 'electron' import { join } from 'path' -import { electronApp, optimizer, is } from '@electron-toolkit/utils' import icon from '../../resources/icon.png?asset' +import { ThemeService } from './services/theme.service' +import { TSendToRenderer } from './types' +import { StorageService } from './services/storage.service' + +let mainWindow: BrowserWindow | null = null function createWindow(): void { // Create the browser window. - const mainWindow = new BrowserWindow({ + mainWindow = new BrowserWindow({ width: 900, height: 670, show: false, @@ -14,11 +19,16 @@ function createWindow(): void { webPreferences: { preload: join(__dirname, '../preload/index.js'), sandbox: false - } + }, + titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : undefined }) mainWindow.on('ready-to-show', () => { - mainWindow.show() + mainWindow?.show() + }) + + mainWindow.on('closed', () => { + mainWindow = null }) mainWindow.webContents.setWindowOpenHandler((details) => { @@ -33,14 +43,18 @@ function createWindow(): void { } else { mainWindow.loadFile(join(__dirname, '../renderer/index.html')) } + + if (is.dev) { + mainWindow.webContents.openDevTools() + } } // This method will be called when Electron has finished // initialization and is ready to create browser windows. // Some APIs can only be used after this event occurs. -app.whenReady().then(() => { +app.whenReady().then(async () => { // Set app user model id for windows - electronApp.setAppUserModelId('com.electron') + electronApp.setAppUserModelId('com.jumble') // Default open or close DevTools by F12 in development // and ignore CommandOrControl + R in production. @@ -49,8 +63,15 @@ app.whenReady().then(() => { optimizer.watchWindowShortcuts(window) }) - // IPC test - ipcMain.on('ping', () => console.log('pong')) + const sendToRenderer: TSendToRenderer = (channel, ...args) => { + mainWindow?.webContents.send(channel, ...args) + } + + const storageService = new StorageService() + storageService.init() + + const themeService = new ThemeService(storageService, sendToRenderer) + themeService.init() createWindow() diff --git a/src/main/services/storage.service.ts b/src/main/services/storage.service.ts new file mode 100644 index 0000000..6695233 --- /dev/null +++ b/src/main/services/storage.service.ts @@ -0,0 +1,85 @@ +import { TConfig, TRelayGroup, TThemeSetting } from '@common/types' +import { app, ipcMain } from 'electron' +import { existsSync, readFileSync, writeFileSync } from 'fs' +import path from 'path' + +export class StorageService { + private storage: Storage + + constructor() { + this.storage = new Storage() + } + + init() { + ipcMain.handle('storage:getRelayGroups', () => this.getRelayGroups()) + ipcMain.handle('storage:setRelayGroups', (_, relayGroups: TRelayGroup[]) => + this.setRelayGroups(relayGroups) + ) + } + + getRelayGroups(): TRelayGroup[] { + return ( + this.storage.get('relayGroups') ?? [ + { + groupName: 'Global', + relayUrls: [ + 'wss://relay.damus.io/', + 'wss://nos.lol/', + 'wss://nostr.mom/', + 'wss://relay.primal.net/' + ], + isActive: true + } + ] + ) + } + + setRelayGroups(relayGroups: TRelayGroup[]) { + this.storage.set('relayGroups', relayGroups) + } + + getTheme() { + return this.storage.get('theme') ?? 'system' + } + + setTheme(theme: TThemeSetting) { + this.storage.set('theme', theme) + } +} + +class Storage { + private path: string + private config: TConfig + private writeTimer: NodeJS.Timeout | null = null + + constructor() { + this.path = path.join(app.getPath('userData'), 'config.json') + this.checkConfigFile(this.path) + const json = readFileSync(this.path, 'utf-8') + this.config = JSON.parse(json) + } + + get(key: K): V | undefined { + return this.config[key] as V + } + + set(key: K, value: TConfig[K]) { + this.config[key] = value + if (this.writeTimer) return + + this.writeTimer = setTimeout(() => { + this.writeTimer = null + writeFileSync(this.path, JSON.stringify(this.config)) + }, 1000) + } + + private checkConfigFile(path: string) { + try { + if (!existsSync(path)) { + writeFileSync(path, '{}') + } + } catch (err) { + console.error(err) + } + } +} diff --git a/src/main/services/theme.service.ts b/src/main/services/theme.service.ts new file mode 100644 index 0000000..c4eea42 --- /dev/null +++ b/src/main/services/theme.service.ts @@ -0,0 +1,43 @@ +import { TThemeSetting } from '@common/types' +import { ipcMain, nativeTheme } from 'electron' +import { TSendToRenderer } from '../types' +import { StorageService } from './storage.service' + +export class ThemeService { + private themeSetting: TThemeSetting = 'system' + + constructor( + private storageService: StorageService, + private sendToRenderer: TSendToRenderer + ) {} + + init() { + this.themeSetting = this.storageService.getTheme() + + ipcMain.handle('theme:current', () => this.getCurrentTheme()) + ipcMain.handle('theme:themeSetting', () => this.themeSetting) + ipcMain.handle('theme:set', (_, theme: TThemeSetting) => this.setTheme(theme)) + nativeTheme.on('updated', () => { + if (this.themeSetting === 'system') { + this.sendCurrentThemeToRenderer() + } + }) + } + + getCurrentTheme() { + if (this.themeSetting === 'system') { + return nativeTheme.shouldUseDarkColors ? 'dark' : 'light' + } + return this.themeSetting + } + + private setTheme(theme: TThemeSetting) { + this.themeSetting = theme + this.storageService.setTheme(theme) + this.sendCurrentThemeToRenderer() + } + + private sendCurrentThemeToRenderer() { + this.sendToRenderer('theme:change', this.getCurrentTheme()) + } +} diff --git a/src/main/types.ts b/src/main/types.ts new file mode 100644 index 0000000..dad4d43 --- /dev/null +++ b/src/main/types.ts @@ -0,0 +1 @@ +export type TSendToRenderer = (channel: string, ...args: any[]) => void diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index a153669..fc66a37 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -1,8 +1,20 @@ +import { TRelayGroup, TTheme, TThemeSetting } from '@common/types' import { ElectronAPI } from '@electron-toolkit/preload' declare global { interface Window { electron: ElectronAPI - api: unknown + api: { + theme: { + onChange: (cb: (theme: TTheme) => void) => void + current: () => Promise + themeSetting: () => Promise + set: (themeSetting: TThemeSetting) => Promise + } + storage: { + getRelayGroups: () => Promise + setRelayGroups: (relayGroups: TRelayGroup[]) => Promise + } + } } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 2d18524..a1d86a5 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,8 +1,25 @@ -import { contextBridge } from 'electron' +import { TRelayGroup, TThemeSetting } from '@common/types' import { electronAPI } from '@electron-toolkit/preload' +import { contextBridge, ipcRenderer } from 'electron' // Custom APIs for renderer -const api = {} +const api = { + theme: { + onChange: (cb: (theme: 'dark' | 'light') => void) => { + ipcRenderer.on('theme:change', (_, theme) => { + cb(theme) + }) + }, + current: () => ipcRenderer.invoke('theme:current'), + themeSetting: () => ipcRenderer.invoke('theme:themeSetting'), + set: (themeSetting: TThemeSetting) => ipcRenderer.invoke('theme:set', themeSetting) + }, + storage: { + getRelayGroups: () => ipcRenderer.invoke('storage:getRelayGroups'), + setRelayGroups: (relayGroups: TRelayGroup[]) => + ipcRenderer.invoke('storage:setRelayGroups', relayGroups) + } +} // Use `contextBridge` APIs to expose Electron APIs to // renderer only if context isolation is enabled, otherwise diff --git a/src/renderer/index.html b/src/renderer/index.html index e198e05..027e508 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -4,10 +4,10 @@ Electron - + /> --> diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index ff628d7..d62885b 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,5 +1,29 @@ -function App(): JSX.Element { - return
Hello
-} +import 'yet-another-react-lightbox/styles.css' +import './assets/main.css' + +import { ThemeProvider } from '@renderer/components/theme-provider' +import { Toaster } from '@renderer/components/ui/toaster' +import { PageManager } from './PageManager' +import NoteListPage from './pages/primary/NoteListPage' +import HashtagPage from './pages/secondary/HashtagPage' +import NotePage from './pages/secondary/NotePage' +import ProfilePage from './pages/secondary/ProfilePage' -export default App +const routes = [ + { pageName: 'note', element: }, + { pageName: 'profile', element: }, + { pageName: 'hashtag', element: } +] + +export default function App(): JSX.Element { + return ( +
+ + + + + + +
+ ) +} diff --git a/src/renderer/src/PageManager.tsx b/src/renderer/src/PageManager.tsx new file mode 100644 index 0000000..fcc9a2d --- /dev/null +++ b/src/renderer/src/PageManager.tsx @@ -0,0 +1,134 @@ +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup +} from '@renderer/components/ui/resizable' +import { cloneElement, createContext, isValidElement, useContext, useState } from 'react' +import BlankPage from './pages/secondary/BlankPage' +import { cn } from '@renderer/lib/utils' + +type TRoute = { + pageName: string + element: React.ReactNode +} + +type TPushParams = { + pageName: string + props: any +} + +type TSecondaryPageContext = { + push: (params: TPushParams) => void + pop: () => void +} + +type TStackItem = { + pageName: string + props: any + component: React.ReactNode +} + +const SecondaryPageContext = createContext({ + push: () => {}, + pop: () => {} +}) + +export function useSecondaryPage() { + return useContext(SecondaryPageContext) +} + +export function PageManager({ + routes, + children, + maxStackSize = 5 +}: { + routes: TRoute[] + children: React.ReactNode + maxStackSize?: number +}) { + const [secondaryStack, setSecondaryStack] = useState([]) + + const routeMap = routes.reduce((acc, route) => { + acc[route.pageName] = route.element + return acc + }, {}) as Record + + const isCurrentPage = (stack: TStackItem[], { pageName, props }: TPushParams) => { + const currentPage = stack[stack.length - 1] + if (!currentPage) return false + + return ( + currentPage.pageName === pageName && + JSON.stringify(currentPage.props) === JSON.stringify(props) // TODO: deep compare + ) + } + + const pushSecondary = ({ pageName, props }: TPushParams) => { + if (isCurrentPage(secondaryStack, { pageName, props })) return + + const element = routeMap[pageName] + if (!element) return + if (!isValidElement(element)) return + + setSecondaryStack((prevStack) => { + const component = cloneElement(element, props) + const newStack = [...prevStack, { pageName, props, component }] + if (newStack.length > maxStackSize) newStack.shift() + return newStack + }) + } + + const popSecondary = () => setSecondaryStack((prevStack) => prevStack.slice(0, -1)) + + return ( + + + + {children} + + + + {secondaryStack.length ? ( + secondaryStack.map((item, index) => ( +
+ {item.component} +
+ )) + ) : ( + + )} +
+
+
+ ) +} + +export function SecondaryPageLink({ + to, + children, + className, + onClick +}: { + to: TPushParams + children: React.ReactNode + className?: string + onClick?: (e: React.MouseEvent) => void +}) { + const { push } = useSecondaryPage() + + return ( + { + onClick && onClick(e) + push(to) + }} + > + {children} + + ) +} diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 212aacf..7685eef 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -19,68 +19,49 @@ :root { --background: 0 0% 100%; - --foreground: 222.2 47.4% 11.2%; - - --muted: 210 40% 96.1%; - --muted-foreground: 215.4 16.3% 46.9%; - - --popover: 0 0% 100%; - --popover-foreground: 222.2 47.4% 11.2%; - - --border: 214.3 31.8% 91.4%; - --input: 214.3 31.8% 91.4%; - + --foreground: 240 10% 3.9%; --card: 0 0% 100%; - --card-foreground: 222.2 47.4% 11.2%; - - --primary: 222.2 47.4% 11.2%; - --primary-foreground: 210 40% 98%; - - --secondary: 210 40% 96.1%; - --secondary-foreground: 222.2 47.4% 11.2%; - - --accent: 210 40% 96.1%; - --accent-foreground: 222.2 47.4% 11.2%; - - --destructive: 0 100% 50%; - --destructive-foreground: 210 40% 98%; - - --ring: 215 20.2% 65.1%; - + --card-foreground: 240 10% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 240 10% 3.9%; + --primary: 259 43% 56%; + --primary-foreground: 0 0% 98%; + --secondary: 240 4.8% 95.9%; + --secondary-foreground: 240 5.9% 10%; + --muted: 240 4.8% 95.9%; + --muted-foreground: 240 3.8% 46.1%; + --accent: 240 4.8% 95.9%; + --accent-foreground: 240 5.9% 10%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 240 5.9% 90%; + --input: 240 5.9% 90%; + --ring: 259 43% 56%; + --highlight: 259 43% 56%; --radius: 0.5rem; } .dark { - --background: 224 71% 4%; - --foreground: 213 31% 91%; - - --muted: 223 47% 11%; - --muted-foreground: 215.4 16.3% 56.9%; - - --accent: 216 34% 17%; - --accent-foreground: 210 40% 98%; - - --popover: 224 71% 4%; - --popover-foreground: 215 20.2% 65.1%; - - --border: 216 34% 17%; - --input: 216 34% 17%; - - --card: 224 71% 4%; - --card-foreground: 213 31% 91%; - - --primary: 210 40% 98%; - --primary-foreground: 222.2 47.4% 1.2%; - - --secondary: 222.2 47.4% 11.2%; - --secondary-foreground: 210 40% 98%; - - --destructive: 0 63% 31%; - --destructive-foreground: 210 40% 98%; - - --ring: 216 34% 17%; - - --radius: 0.5rem; + --background: 240 10% 3.9%; + --foreground: 0 0% 98%; + --card: 240 10% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 240 10% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 259 43% 56%; + --primary-foreground: 240 5.9% 10%; + --secondary: 240 3.7% 15.9%; + --secondary-foreground: 0 0% 98%; + --muted: 240 3.7% 15.9%; + --muted-foreground: 240 5% 64.9%; + --accent: 240 3.7% 15.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 240 3.7% 15.9%; + --input: 240 3.7% 15.9%; + --ring: 259 43% 56%; + --highlight: 259 43% 56%; } } diff --git a/src/renderer/src/components/Content/index.tsx b/src/renderer/src/components/Content/index.tsx new file mode 100644 index 0000000..5aa9cf3 --- /dev/null +++ b/src/renderer/src/components/Content/index.tsx @@ -0,0 +1,125 @@ +import { + embedded, + embeddedHashtagRenderer, + embeddedNormalUrlRenderer, + embeddedNostrNpubRenderer, + embeddedNostrProfileRenderer +} from '@renderer/embedded' +import { isNsfwEvent } from '@renderer/lib/event' +import { cn } from '@renderer/lib/utils' +import { Event } from 'nostr-tools' +import { memo } from 'react' +import { EmbeddedNote } from '../Embedded' +import ImageGallery from '../ImageGallery' +import VideoPlayer from '../VideoPlayer' + +const Content = memo( + ({ + event, + className, + size = 'normal' + }: { + event: Event + className?: string + size?: 'normal' | 'small' + }) => { + const { content, images, videos, embeddedNotes } = preprocess(event.content) + const isNsfw = isNsfwEvent(event) + const nodes = embedded(content, [ + embeddedNormalUrlRenderer, + embeddedHashtagRenderer, + embeddedNostrNpubRenderer, + embeddedNostrProfileRenderer + ]) + + // Add images + if (images.length) { + nodes.push( + + ) + } + + // Add videos + if (videos.length) { + videos.forEach((src, index) => { + nodes.push( + + ) + }) + } + + // Add embedded notes + if (embeddedNotes.length) { + embeddedNotes.forEach((note, index) => { + const id = note.split(':')[1] + nodes.push() + }) + } + + return ( +
+ {nodes} +
+ ) + } +) +Content.displayName = 'Content' +export default Content + +function preprocess(content: string) { + const urlRegex = /(https?:\/\/[^\s"']+)/g + const urls = content.match(urlRegex) || [] + + let c = content + const images: string[] = [] + const videos: string[] = [] + + urls.forEach((url) => { + if (isImage(url)) { + c = c.replace(url, '').trim() + images.push(url) + } else if (isVideo(url)) { + c = c.replace(url, '').trim() + videos.push(url) + } + }) + + const embeddedNotes: string[] = [] + const embeddedNoteRegex = /(nostr:note1[a-z0-9]{58}|nostr:nevent1[a-z0-9]+)/g + ;(c.match(embeddedNoteRegex) || []).forEach((note) => { + c = c.replace(note, '').trim() + embeddedNotes.push(note) + }) + + return { content: c, images, videos, embeddedNotes } +} + +function isImage(url: string) { + try { + const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', 'webp', 'heic', 'svg'] + return imageExtensions.some((ext) => new URL(url).pathname.toLowerCase().endsWith(ext)) + } catch { + return false + } +} + +function isVideo(url: string) { + try { + const videoExtensions = ['.mp4', '.webm', '.ogg'] + return videoExtensions.some((ext) => new URL(url).pathname.toLowerCase().endsWith(ext)) + } catch { + return false + } +} diff --git a/src/renderer/src/components/Embedded/EmbeddedHashtag.tsx b/src/renderer/src/components/Embedded/EmbeddedHashtag.tsx new file mode 100644 index 0000000..b45b679 --- /dev/null +++ b/src/renderer/src/components/Embedded/EmbeddedHashtag.tsx @@ -0,0 +1,14 @@ +import { toHashtag } from '@renderer/lib/url' +import { SecondaryPageLink } from '@renderer/PageManager' + +export function EmbeddedHashtag({ hashtag }: { hashtag: string }) { + return ( + e.stopPropagation()} + > + #{hashtag} + + ) +} diff --git a/src/renderer/src/components/Embedded/EmbeddedMention.tsx b/src/renderer/src/components/Embedded/EmbeddedMention.tsx new file mode 100644 index 0000000..7bc8b87 --- /dev/null +++ b/src/renderer/src/components/Embedded/EmbeddedMention.tsx @@ -0,0 +1,9 @@ +import { useFetchProfile } from '@renderer/hooks' +import Username from '../Username' + +export function EmbeddedMention({ userId }: { userId: string }) { + const { pubkey } = useFetchProfile(userId) + if (!pubkey) return null + + return +} diff --git a/src/renderer/src/components/Embedded/EmbeddedNormalUrl.tsx b/src/renderer/src/components/Embedded/EmbeddedNormalUrl.tsx new file mode 100644 index 0000000..a1d1e72 --- /dev/null +++ b/src/renderer/src/components/Embedded/EmbeddedNormalUrl.tsx @@ -0,0 +1,13 @@ +export function EmbeddedNormalUrl({ url }: { url: string }) { + return ( + e.stopPropagation()} + rel="noreferrer" + > + {url} + + ) +} diff --git a/src/renderer/src/components/Embedded/EmbeddedNote.tsx b/src/renderer/src/components/Embedded/EmbeddedNote.tsx new file mode 100644 index 0000000..e856731 --- /dev/null +++ b/src/renderer/src/components/Embedded/EmbeddedNote.tsx @@ -0,0 +1,22 @@ +import { useFetchEventById } from '@renderer/hooks' +import { toNoStrudelNote } from '@renderer/lib/url' +import { kinds } from 'nostr-tools' +import ShortTextNoteCard from '../NoteCard/ShortTextNoteCard' + +export function EmbeddedNote({ noteId }: { noteId: string }) { + const event = useFetchEventById(noteId) + + return event && event.kind === kinds.ShortTextNote ? ( + + ) : ( + e.stopPropagation()} + rel="noreferrer" + > + {noteId} + + ) +} diff --git a/src/renderer/src/components/Embedded/index.tsx b/src/renderer/src/components/Embedded/index.tsx new file mode 100644 index 0000000..aa1bb29 --- /dev/null +++ b/src/renderer/src/components/Embedded/index.tsx @@ -0,0 +1,4 @@ +export * from './EmbeddedHashtag' +export * from './EmbeddedMention' +export * from './EmbeddedNormalUrl' +export * from './EmbeddedNote' diff --git a/src/renderer/src/components/ImageGallery/index.tsx b/src/renderer/src/components/ImageGallery/index.tsx new file mode 100644 index 0000000..1722ecb --- /dev/null +++ b/src/renderer/src/components/ImageGallery/index.tsx @@ -0,0 +1,55 @@ +import { ScrollArea, ScrollBar } from '@renderer/components/ui/scroll-area' +import { useState } from 'react' +import Lightbox from 'yet-another-react-lightbox' +import Zoom from 'yet-another-react-lightbox/plugins/zoom' +import NsfwOverlay from '../NsfwOverlay' +import { cn } from '@renderer/lib/utils' + +export default function ImageGallery({ + className, + images, + isNsfw = false, + size = 'normal' +}: { + className?: string + images: string[] + isNsfw?: boolean + size?: 'normal' | 'small' +}) { + const [index, setIndex] = useState(-1) + + const handlePhotoClick = (event: React.MouseEvent, current: number) => { + event.preventDefault() + setIndex(current) + } + + return ( +
e.stopPropagation()}> + +
+ {images.map((src, index) => { + return ( + handlePhotoClick(e, index)} + /> + ) + })} +
+ +
+ ({ src }))} + plugins={[Zoom]} + open={index >= 0} + close={() => setIndex(-1)} + controller={{ closeOnBackdropClick: true, closeOnPullUp: true, closeOnPullDown: true }} + styles={{ toolbar: { paddingTop: '2.25rem' } }} + /> + {isNsfw && } +
+ ) +} diff --git a/src/renderer/src/components/Nip05/index.tsx b/src/renderer/src/components/Nip05/index.tsx new file mode 100644 index 0000000..39f6b93 --- /dev/null +++ b/src/renderer/src/components/Nip05/index.tsx @@ -0,0 +1,22 @@ +import { useFetchNip05 } from '@renderer/hooks/useFetchNip05' +import { BadgeAlert, BadgeCheck } from 'lucide-react' + +export default function Nip05({ nip05, pubkey }: { nip05: string; pubkey: string }) { + const { nip05IsVerified, nip05Name, nip05Domain } = useFetchNip05(nip05, pubkey) + return ( +
+ {nip05Name !== '_' ? ( +
@{nip05Name}
+ ) : null} + + {nip05IsVerified ? : } +
{nip05Domain}
+
+
+ ) +} diff --git a/src/renderer/src/components/Note/NoteOptionsTrigger.tsx b/src/renderer/src/components/Note/NoteOptionsTrigger.tsx new file mode 100644 index 0000000..10df10e --- /dev/null +++ b/src/renderer/src/components/Note/NoteOptionsTrigger.tsx @@ -0,0 +1,37 @@ +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@renderer/components/ui/dropdown-menu' +import { Ellipsis } from 'lucide-react' +import { Event } from 'nostr-tools' +import { useState } from 'react' +import RawEventDialog from './RawEventDialog' + +export default function NoteOptionsTrigger({ event }: { event: Event }) { + const [isRawEventDialogOpen, setIsRawEventDialogOpen] = useState(false) + + return ( + <> + + + + + + setIsRawEventDialogOpen(true)}> + raw event + + + + setIsRawEventDialogOpen(false)} + /> + + ) +} diff --git a/src/renderer/src/components/Note/NoteStats.tsx b/src/renderer/src/components/Note/NoteStats.tsx new file mode 100644 index 0000000..a700ae9 --- /dev/null +++ b/src/renderer/src/components/Note/NoteStats.tsx @@ -0,0 +1,48 @@ +import useFetchEventStats from '@renderer/hooks/useFetchEventStats' +import { cn } from '@renderer/lib/utils' +import { EVENT_TYPES, eventBus } from '@renderer/services/event-bus.service' +import { Heart, MessageCircle, Repeat } from 'lucide-react' +import { Event } from 'nostr-tools' +import { useEffect, useState } from 'react' +import NoteOptionsTrigger from './NoteOptionsTrigger' + +export default function NoteStats({ event, className }: { event: Event; className?: string }) { + const [replyCount, setReplyCount] = useState(0) + const { stats } = useFetchEventStats(event.id) + + useEffect(() => { + const handler = (e: CustomEvent<{ eventId: string; replyCount: number }>) => { + const { eventId, replyCount } = e.detail + if (eventId === event.id) { + setReplyCount(replyCount) + } + } + eventBus.on(EVENT_TYPES.REPLY_COUNT_CHANGED, handler) + + return () => { + eventBus.remove(EVENT_TYPES.REPLY_COUNT_CHANGED, handler) + } + }, []) + + return ( +
+
+ +
{formatCount(replyCount)}
+
+
+ +
{formatCount(stats.repostCount)}
+
+
+ +
{formatCount(stats.reactionCount)}
+
+ +
+ ) +} + +function formatCount(count: number) { + return count >= 100 ? '99+' : count +} diff --git a/src/renderer/src/components/Note/RawEventDialog.tsx b/src/renderer/src/components/Note/RawEventDialog.tsx new file mode 100644 index 0000000..0bd3269 --- /dev/null +++ b/src/renderer/src/components/Note/RawEventDialog.tsx @@ -0,0 +1,29 @@ +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@renderer/components/ui/dialog' +import { ScrollArea, ScrollBar } from '@renderer/components/ui/scroll-area' +import { Event } from 'nostr-tools' + +export default function RawEventDialog({ + event, + isOpen, + onClose +}: { + event: Event + isOpen: boolean + onClose: () => void +}) { + return ( + + + + Raw Event + + +
+            {JSON.stringify(event, null, 2)}
+          
+ +
+
+
+ ) +} diff --git a/src/renderer/src/components/Note/index.tsx b/src/renderer/src/components/Note/index.tsx new file mode 100644 index 0000000..dfeaed1 --- /dev/null +++ b/src/renderer/src/components/Note/index.tsx @@ -0,0 +1,52 @@ +import { formatTimestamp } from '@renderer/lib/timestamp' +import { Event } from 'nostr-tools' +import Content from '../Content' +import UserAvatar from '../UserAvatar' +import Username from '../Username' +import NoteStats from './NoteStats' + +export default function Note({ + event, + parentEvent, + size = 'normal', + className, + displayStats = false +}: { + event: Event + parentEvent?: Event + size?: 'normal' | 'small' + className?: string + displayStats?: boolean +}) { + return ( +
+
+ +
+ +
{formatTimestamp(event.created_at)}
+
+
+ {parentEvent && ( +
+ +
+ )} + + {displayStats && } +
+ ) +} + +function ParentNote({ event }: { event: Event }) { + return ( +
+
reply to
+ +
{event.content}
+
+ ) +} diff --git a/src/renderer/src/components/NoteCard/RepostNoteCard.tsx b/src/renderer/src/components/NoteCard/RepostNoteCard.tsx new file mode 100644 index 0000000..6b4b615 --- /dev/null +++ b/src/renderer/src/components/NoteCard/RepostNoteCard.tsx @@ -0,0 +1,22 @@ +import { Event } from 'nostr-tools' +import { useFetchEventById } from '@renderer/hooks' +import { Repeat2 } from 'lucide-react' +import Username from '../Username' +import ShortTextNoteCard from './ShortTextNoteCard' + +export default function RepostNoteCard({ event, className }: { event: Event; className?: string }) { + const targetEventId = event.tags.find(([tagName]) => tagName === 'e')?.[1] + const targetEvent = useFetchEventById(targetEventId) + if (!targetEvent) return null + + return ( +
+
+ + +
reposted
+
+ +
+ ) +} diff --git a/src/renderer/src/components/NoteCard/ShortTextNoteCard.tsx b/src/renderer/src/components/NoteCard/ShortTextNoteCard.tsx new file mode 100644 index 0000000..99e26a7 --- /dev/null +++ b/src/renderer/src/components/NoteCard/ShortTextNoteCard.tsx @@ -0,0 +1,35 @@ +import { Event } from 'nostr-tools' +import { Card } from '@renderer/components/ui/card' +import { toNote } from '@renderer/lib/url' +import { useSecondaryPage } from '@renderer/PageManager' +import Note from '../Note' +import { useFetchEventById } from '@renderer/hooks' +import { getParentEventId, getRootEventId } from '@renderer/lib/event' + +export default function ShortTextNoteCard({ + event, + className, + size +}: { + event: Event + className?: string + size?: 'normal' | 'small' +}) { + const { push } = useSecondaryPage() + const rootEvent = useFetchEventById(getRootEventId(event)) + const parentEvent = useFetchEventById(getParentEventId(event)) + + return ( +
{ + e.stopPropagation() + push(toNote(rootEvent ?? event)) + }} + > + + + +
+ ) +} diff --git a/src/renderer/src/components/NoteCard/index.tsx b/src/renderer/src/components/NoteCard/index.tsx new file mode 100644 index 0000000..f456ae4 --- /dev/null +++ b/src/renderer/src/components/NoteCard/index.tsx @@ -0,0 +1,11 @@ +import { Event } from 'nostr-tools' +import { kinds } from 'nostr-tools' +import RepostNoteCard from './RepostNoteCard' +import ShortTextNoteCard from './ShortTextNoteCard' + +export default function NoteCard({ event, className }: { event: Event; className?: string }) { + if (event.kind === kinds.Repost) { + return + } + return +} diff --git a/src/renderer/src/components/NoteList/index.tsx b/src/renderer/src/components/NoteList/index.tsx new file mode 100644 index 0000000..d70dd61 --- /dev/null +++ b/src/renderer/src/components/NoteList/index.tsx @@ -0,0 +1,145 @@ +import { isReplyNoteEvent } from '@renderer/lib/event' +import { cn } from '@renderer/lib/utils' +import client from '@renderer/services/client.service' +import { EVENT_TYPES, eventBus } from '@renderer/services/event-bus.service' +import dayjs from 'dayjs' +import { RefreshCcw } from 'lucide-react' +import { Event, Filter, kinds } from 'nostr-tools' +import { useEffect, useMemo, useRef, useState } from 'react' +import NoteCard from '../NoteCard' + +export default function NoteList({ + filter = {}, + className, + isHomeTimeline = false +}: { + filter?: Filter + className?: string + isHomeTimeline?: boolean +}) { + const [events, setEvents] = useState([]) + const [since, setSince] = useState(() => dayjs().unix() + 1) + const [until, setUntil] = useState(() => dayjs().unix()) + const [hasMore, setHasMore] = useState(true) + const [refreshedAt, setRefreshedAt] = useState(() => dayjs().unix()) + const [refreshing, setRefreshing] = useState(false) + const observer = useRef(null) + const bottomRef = useRef(null) + + const noteFilter = useMemo(() => { + return { + kinds: [kinds.ShortTextNote, kinds.Repost], + limit: 50, + ...filter + } + }, [filter]) + + useEffect(() => { + if (!isHomeTimeline) return + + const handleClearList = () => { + setEvents([]) + setSince(dayjs().unix() + 1) + setUntil(dayjs().unix()) + setHasMore(true) + setRefreshedAt(dayjs().unix()) + setRefreshing(false) + } + + eventBus.on(EVENT_TYPES.RELOAD_TIMELINE, handleClearList) + + return () => { + eventBus.remove(EVENT_TYPES.RELOAD_TIMELINE, handleClearList) + } + }, []) + + const loadMore = async () => { + const events = await client.fetchEvents([{ ...noteFilter, until }]) + if (events.length === 0) { + setHasMore(false) + return + } + + const sortedEvents = events.sort((a, b) => b.created_at - a.created_at) + const processedEvents = sortedEvents.filter((e) => !isReplyNoteEvent(e)) + if (processedEvents.length > 0) { + setEvents((oldEvents) => [...oldEvents, ...processedEvents]) + } + + setUntil(sortedEvents[sortedEvents.length - 1].created_at - 1) + } + + const refresh = async () => { + const now = dayjs().unix() + setRefreshing(true) + const events = await client.fetchEvents([{ ...noteFilter, until: now, since }]) + + const sortedEvents = events.sort((a, b) => b.created_at - a.created_at) + const processedEvents = sortedEvents.filter((e) => !isReplyNoteEvent(e)) + if (sortedEvents.length >= noteFilter.limit) { + // reset + setEvents(processedEvents) + setUntil(sortedEvents[sortedEvents.length - 1].created_at - 1) + } else if (processedEvents.length > 0) { + // append + setEvents((oldEvents) => [...processedEvents, ...oldEvents]) + } + + if (sortedEvents.length > 0) { + setSince(sortedEvents[0].created_at + 1) + } + + setRefreshedAt(now) + setRefreshing(false) + } + + useEffect(() => { + const options = { + root: null, + rootMargin: '10px', + threshold: 1 + } + + observer.current = new IntersectionObserver((entries) => { + if (entries[0].isIntersecting && hasMore) { + loadMore() + } + }, options) + + if (bottomRef.current) { + observer.current.observe(bottomRef.current) + } + + return () => { + if (observer.current && bottomRef.current) { + observer.current.unobserve(bottomRef.current) + } + } + }, [until]) + + return ( + <> + {events.length > 0 && ( +
+ +
+ {refreshing + ? 'refreshing...' + : `last refreshed at ${dayjs(refreshedAt * 1000).format('HH:mm:ss')}`} +
+
+ )} +
+ {events.map((event, i) => ( + + ))} +
+
+ {hasMore ?
loading...
: 'no more notes'} +
+ + ) +} diff --git a/src/renderer/src/components/NsfwOverlay/index.tsx b/src/renderer/src/components/NsfwOverlay/index.tsx new file mode 100644 index 0000000..ff7687a --- /dev/null +++ b/src/renderer/src/components/NsfwOverlay/index.tsx @@ -0,0 +1,18 @@ +import { cn } from '@renderer/lib/utils' +import { useState } from 'react' + +export default function NsfwOverlay({ className }: { className?: string }) { + const [isHidden, setIsHidden] = useState(true) + + return ( + isHidden && ( +
setIsHidden(false)} + /> + ) + ) +} diff --git a/src/renderer/src/components/ProfileAbout/index.tsx b/src/renderer/src/components/ProfileAbout/index.tsx new file mode 100644 index 0000000..9bbe737 --- /dev/null +++ b/src/renderer/src/components/ProfileAbout/index.tsx @@ -0,0 +1,23 @@ +import { + embedded, + embeddedHashtagRenderer, + embeddedNormalUrlRenderer, + embeddedNostrNpubRenderer +} from '@renderer/embedded' +import { embeddedNpubRenderer } from '@renderer/embedded/EmbeddedNpub' +import { useMemo } from 'react' + +export default function ProfileAbout({ about }: { about?: string }) { + const nodes = useMemo(() => { + return about + ? embedded(about, [ + embeddedNormalUrlRenderer, + embeddedHashtagRenderer, + embeddedNostrNpubRenderer, + embeddedNpubRenderer + ]) + : null + }, [about]) + + return <>{nodes} +} diff --git a/src/renderer/src/components/ProfileCard/index.tsx b/src/renderer/src/components/ProfileCard/index.tsx new file mode 100644 index 0000000..cba63f5 --- /dev/null +++ b/src/renderer/src/components/ProfileCard/index.tsx @@ -0,0 +1,35 @@ +import { Avatar, AvatarFallback, AvatarImage } from '@renderer/components/ui/avatar' +import { generateImageByPubkey } from '@renderer/lib/pubkey' +import { useFetchProfile } from '@renderer/hooks' +import Nip05 from '../Nip05' +import ProfileAbout from '../ProfileAbout' + +export default function ProfileCard({ pubkey }: { pubkey: string }) { + const { avatar = '', username, nip05, about } = useFetchProfile(pubkey) + const defaultAvatar = generateImageByPubkey(pubkey) + + return ( +
+
+ + + + {pubkey} + + +
+
{username}
+ {nip05 && } +
+
+ {about && ( +
+ +
+ )} +
+ ) +} diff --git a/src/renderer/src/components/RelaySettings/RelayGroup.tsx b/src/renderer/src/components/RelaySettings/RelayGroup.tsx new file mode 100644 index 0000000..47ef82d --- /dev/null +++ b/src/renderer/src/components/RelaySettings/RelayGroup.tsx @@ -0,0 +1,229 @@ +import { Button } from '@renderer/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@renderer/components/ui/dropdown-menu' +import { Input } from '@renderer/components/ui/input' +import { Check, ChevronDown, Circle, CircleCheck, EllipsisVertical } from 'lucide-react' +import { useState } from 'react' +import { TRelayGroup } from './types' +import RelayUrls from './RelayUrl' + +export default function RelayGroup({ + group, + onSwitch, + onDelete, + onRename, + onRelayUrlsUpdate +}: { + group: TRelayGroup + onSwitch: (groupName: string) => void + onDelete: (groupName: string) => void + onRename: (oldGroupName: string, newGroupName: string) => string | null + onRelayUrlsUpdate: (groupName: string, relayUrls: string[]) => void +}) { + const { groupName, isActive, relayUrls } = group + const [expanded, setExpanded] = useState(false) + const [renaming, setRenaming] = useState(false) + + const toggleExpanded = () => setExpanded((prev) => !prev) + + return ( +
+
+
+ onSwitch(groupName)} + hasRelayUrls={relayUrls.length > 0} + /> + 0} + setRenaming={setRenaming} + save={onRename} + onToggle={() => onSwitch(groupName)} + /> +
+
+ + {relayUrls.length} relays + + +
+
+ {expanded && ( + onRelayUrlsUpdate(groupName, urls)} + /> + )} +
+ ) +} + +function RelayGroupActiveToggle({ + isActive, + hasRelayUrls, + onToggle +}: { + isActive: boolean + hasRelayUrls: boolean + onToggle: () => void +}) { + return ( + <> + {isActive ? ( + + ) : ( + { + if (hasRelayUrls) { + onToggle() + } + }} + /> + )} + + ) +} + +function RelayGroupName({ + groupName, + renaming, + hasRelayUrls, + setRenaming, + save, + onToggle +}: { + groupName: string + renaming: boolean + hasRelayUrls: boolean + setRenaming: (renaming: boolean) => void + save: (oldGroupName: string, newGroupName: string) => string | null + onToggle: () => void +}) { + const [newGroupName, setNewGroupName] = useState(groupName) + const [newNameError, setNewNameError] = useState(null) + + const saveNewGroupName = () => { + const errMsg = save(groupName, newGroupName) + if (errMsg) { + setNewNameError(errMsg) + return + } + setRenaming(false) + } + + const handleRenameInputChange = (e: React.ChangeEvent) => { + setNewGroupName(e.target.value) + setNewNameError(null) + } + + const handleRenameInputKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault() + saveNewGroupName() + } + } + + return ( + <> + {renaming ? ( +
+ + + {newNameError &&
{newNameError}
} +
+ ) : ( +
{ + if (hasRelayUrls) { + onToggle() + } + }} + > + {groupName} +
+ )} + + ) +} + +function RelayUrlsExpandToggle({ + expanded, + onClick, + children +}: { + expanded: boolean + onClick: () => void + children: React.ReactNode +}) { + return ( +
+
{children}
+ +
+ ) +} + +function RelayGroupOptions({ + groupName, + isActive, + onDelete, + setRenaming +}: { + groupName: string + isActive: boolean + onDelete: (groupName: string) => void + setRenaming: (renaming: boolean) => void +}) { + return ( + + + + + + setRenaming(true)}>Rename + onDelete(groupName)} + > + Delete + + + + ) +} diff --git a/src/renderer/src/components/RelaySettings/RelayUrl.tsx b/src/renderer/src/components/RelaySettings/RelayUrl.tsx new file mode 100644 index 0000000..bfbac9e --- /dev/null +++ b/src/renderer/src/components/RelaySettings/RelayUrl.tsx @@ -0,0 +1,146 @@ +import { Button } from '@renderer/components/ui/button' +import { Input } from '@renderer/components/ui/input' +import client from '@renderer/services/client.service' +import { CircleX } from 'lucide-react' +import { useEffect, useState } from 'react' + +export default function RelayUrls({ + isActive, + relayUrls: rawRelayUrls, + update +}: { + isActive: boolean + relayUrls: string[] + update: (urls: string[]) => void +}) { + const [newRelayUrl, setNewRelayUrl] = useState('') + const [newRelayUrlError, setNewRelayUrlError] = useState(null) + const [relays, setRelays] = useState< + { + url: string + isConnected: boolean + }[] + >(rawRelayUrls.map((url) => ({ url, isConnected: false }))) + + useEffect(() => { + const interval = setInterval(() => { + const connectionStatusMap = client.listConnectionStatus() + setRelays((pre) => { + return pre.map((relay) => { + const isConnected = connectionStatusMap.get(relay.url) || false + return { ...relay, isConnected } + }) + }) + }, 1000) + + return () => clearInterval(interval) + }, []) + + const removeRelayUrl = (url: string) => { + setRelays((relays) => relays.filter((relay) => relay.url !== url)) + update(relays.map(({ url }) => url).filter((u) => u !== url)) + } + + const saveNewRelayUrl = () => { + const normalizedUrl = normalizeURL(newRelayUrl) + if (relays.some(({ url }) => url === normalizedUrl)) { + return setNewRelayUrlError('already exists') + } + if (/^wss?:\/\/.+$/.test(normalizedUrl) === false) { + return setNewRelayUrlError('invalid URL') + } + setRelays((pre) => [...pre, { url: normalizedUrl, isConnected: false }]) + const newRelayUrls = [...relays.map(({ url }) => url), normalizedUrl] + update(newRelayUrls) + setNewRelayUrl('') + } + + const handleRelayUrlInputChange = (e: React.ChangeEvent) => { + setNewRelayUrl(e.target.value) + setNewRelayUrlError(null) + } + + const handleRelayUrlInputKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault() + saveNewRelayUrl() + } + } + + return ( + <> +
+ {relays.map(({ url, isConnected: isConnected }, index) => ( + removeRelayUrl(url)} + /> + ))} +
+
+ + +
+ {newRelayUrlError &&
{newRelayUrlError}
} + + ) +} + +function RelayUrl({ + isActive, + url, + isConnected, + onRemove +}: { + isActive: boolean + url: string + isConnected: boolean + onRemove: () => void +}) { + return ( +
+
+ {!isActive ? ( +
+ ) : isConnected ? ( +
+ ) : ( +
+ )} +
{url}
+
+
+ +
+
+ ) +} + +// copy from nostr-tools/utils +function normalizeURL(url: string): string { + if (url.indexOf('://') === -1) url = 'wss://' + url + const p = new URL(url) + p.pathname = p.pathname.replace(/\/+/g, '/') + if (p.pathname.endsWith('/')) p.pathname = p.pathname.slice(0, -1) + if ((p.port === '80' && p.protocol === 'ws:') || (p.port === '443' && p.protocol === 'wss:')) + p.port = '' + p.searchParams.sort() + p.hash = '' + return p.toString() +} diff --git a/src/renderer/src/components/RelaySettings/index.tsx b/src/renderer/src/components/RelaySettings/index.tsx new file mode 100644 index 0000000..168862f --- /dev/null +++ b/src/renderer/src/components/RelaySettings/index.tsx @@ -0,0 +1,143 @@ +import { Button } from '@renderer/components/ui/button' +import { Input } from '@renderer/components/ui/input' +import { Separator } from '@renderer/components/ui/separator' +import storage from '@renderer/services/storage.service' +import { useEffect, useRef, useState } from 'react' +import RelayGroup from './RelayGroup' +import { TRelayGroup } from './types' + +export default function RelaySettings() { + const [groups, setGroups] = useState([]) + const [newGroupName, setNewGroupName] = useState('') + const [newNameError, setNewNameError] = useState(null) + const dummyRef = useRef(null) + + useEffect(() => { + const init = async () => { + const storedGroups = await storage.getRelayGroups() + setGroups(storedGroups) + } + + if (dummyRef.current) { + dummyRef.current.focus() + } + init() + }, []) + + const updateGroups = async (newGroups: TRelayGroup[]) => { + setGroups(newGroups) + await storage.setRelayGroups(newGroups) + } + + const switchRelayGroup = (groupName: string) => { + updateGroups( + groups.map((group) => ({ + ...group, + isActive: group.groupName === groupName + })) + ) + } + + const deleteRelayGroup = (groupName: string) => { + updateGroups(groups.filter((group) => group.groupName !== groupName || group.isActive)) + } + + const updateRelayGroupRelayUrls = (groupName: string, relayUrls: string[]) => { + updateGroups( + groups.map((group) => ({ + ...group, + relayUrls: group.groupName === groupName ? relayUrls : group.relayUrls + })) + ) + } + + const renameRelayGroup = (oldGroupName: string, newGroupName: string) => { + if (newGroupName === '') { + return null + } + if (oldGroupName === newGroupName) { + return null + } + if (groups.some((group) => group.groupName === newGroupName)) { + return 'already exists' + } + updateGroups( + groups.map((group) => ({ + ...group, + groupName: group.groupName === oldGroupName ? newGroupName : group.groupName + })) + ) + return null + } + + const addRelayGroup = () => { + if (newGroupName === '') { + return + } + if (groups.some((group) => group.groupName === newGroupName)) { + return setNewNameError('already exists') + } + setNewGroupName('') + updateGroups([ + ...groups, + { + groupName: newGroupName, + relayUrls: [], + isActive: false + } + ]) + } + + const handleNewGroupNameChange = (e: React.ChangeEvent) => { + setNewGroupName(e.target.value) + setNewNameError(null) + } + + const handleNewGroupNameKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault() + addRelayGroup() + } + } + + return ( +
+
+
Relay Settings
+
+ {groups.map((group, index) => ( + + ))} +
+ {groups.length < 5 && ( + <> + +
+
+
Add a new relay group
+
+
+ + +
+ {newNameError &&
{newNameError}
} +
+ + )} +
+ ) +} diff --git a/src/renderer/src/components/RelaySettings/types.ts b/src/renderer/src/components/RelaySettings/types.ts new file mode 100644 index 0000000..41fd389 --- /dev/null +++ b/src/renderer/src/components/RelaySettings/types.ts @@ -0,0 +1,5 @@ +export type TRelayGroup = { + groupName: string + relayUrls: string[] + isActive: boolean +} diff --git a/src/renderer/src/components/ReplyNote/index.tsx b/src/renderer/src/components/ReplyNote/index.tsx new file mode 100644 index 0000000..cbae2f0 --- /dev/null +++ b/src/renderer/src/components/ReplyNote/index.tsx @@ -0,0 +1,55 @@ +import { Event } from 'nostr-tools' +import { formatTimestamp } from '@renderer/lib/timestamp' +import Content from '../Content' +import UserAvatar from '../UserAvatar' +import Username from '../Username' + +export default function ReplyNote({ + event, + parentEvent, + onClickParent = () => {}, + highlight = false +}: { + event: Event + parentEvent?: Event + onClickParent?: (eventId: string) => void + highlight?: boolean +}) { + return ( +
+ +
+
+ +
+ {formatTimestamp(event.created_at)} +
+
+ {parentEvent && ( +
onClickParent(parentEvent.id)} + > + +
+ )} + +
+
+ ) +} + +function ParentReplyNote({ event }: { event: Event }) { + return ( +
+
reply to
+ +
{event.content}
+
+ ) +} diff --git a/src/renderer/src/components/ReplyNoteList/index.tsx b/src/renderer/src/components/ReplyNoteList/index.tsx new file mode 100644 index 0000000..fb873ab --- /dev/null +++ b/src/renderer/src/components/ReplyNoteList/index.tsx @@ -0,0 +1,90 @@ +import { Separator } from '@renderer/components/ui/separator' +import { getParentEventId } from '@renderer/lib/event' +import { cn } from '@renderer/lib/utils' +import client from '@renderer/services/client.service' +import { createReplyCountChangedEvent, eventBus } from '@renderer/services/event-bus.service' +import dayjs from 'dayjs' +import { Event } from 'nostr-tools' +import { useEffect, useRef, useState } from 'react' +import ReplyNote from '../ReplyNote' + +export default function ReplyNoteList({ event, className }: { event: Event; className?: string }) { + const [eventsWithParentIds, setEventsWithParentId] = useState<[Event, string | undefined][]>([]) + const [eventMap, setEventMap] = useState>({}) + const [until, setUntil] = useState(() => dayjs().unix()) + const [loading, setLoading] = useState(false) + const [hasMore, setHasMore] = useState(false) + const [highlightReplyId, setHighlightReplyId] = useState(undefined) + const replyRefs = useRef>({}) + + const loadMore = async () => { + setLoading(true) + const events = await client.fetchEvents([ + { + '#e': [event.id], + kinds: [1], + limit: 200, + until + } + ]) + const sortedEvents = events.sort((a, b) => a.created_at - b.created_at) + if (sortedEvents.length > 0) { + const eventMap: Record = {} + const eventsWithParentIds = sortedEvents.map((event) => { + eventMap[event.id] = event + return [event, getParentEventId(event)] as [Event, string | undefined] + }) + setEventsWithParentId((pre) => [...eventsWithParentIds, ...pre]) + setEventMap((pre) => ({ ...pre, ...eventMap })) + setUntil(sortedEvents[0].created_at - 1) + } + setHasMore(sortedEvents.length >= 200) + setLoading(false) + } + + useEffect(() => { + loadMore() + }, []) + + useEffect(() => { + eventBus.emit(createReplyCountChangedEvent(event.id, eventsWithParentIds.length)) + }, [eventsWithParentIds]) + + const onClickParent = (eventId: string) => { + const ref = replyRefs.current[eventId] + if (ref) { + ref.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' }) + } + setHighlightReplyId(eventId) + setTimeout(() => { + setHighlightReplyId((pre) => (pre === eventId ? undefined : pre)) + }, 1500) + } + + return ( + <> +
+ {loading ? 'loading...' : hasMore ? 'load more older replies' : null} +
+ {eventsWithParentIds.length > 0 && (loading || hasMore) && } +
+ {eventsWithParentIds.map(([event, parentEventId], index) => ( +
(replyRefs.current[event.id] = el)} key={index}> + +
+ ))} +
+ {eventsWithParentIds.length === 0 && !loading && !hasMore && ( +
no replies
+ )} + + ) +} diff --git a/src/renderer/src/components/ScrollToTopButton/index.tsx b/src/renderer/src/components/ScrollToTopButton/index.tsx new file mode 100644 index 0000000..b2cf44e --- /dev/null +++ b/src/renderer/src/components/ScrollToTopButton/index.tsx @@ -0,0 +1,39 @@ +import { Button } from '@renderer/components/ui/button' +import { ChevronUp } from 'lucide-react' +import { useEffect, useState } from 'react' + +export default function ScrollToTopButton({ + scrollAreaRef +}: { + scrollAreaRef: React.RefObject +}) { + const [showScrollToTop, setShowScrollToTop] = useState(false) + + const handleScrollToTop = () => { + scrollAreaRef.current?.scrollTo({ top: 0, behavior: 'smooth' }) + } + + const handleScroll = () => { + if (scrollAreaRef.current) { + setShowScrollToTop(scrollAreaRef.current.scrollTop > 1000) + } + } + + useEffect(() => { + const scrollArea = scrollAreaRef.current + scrollArea?.addEventListener('scroll', handleScroll) + return () => { + scrollArea?.removeEventListener('scroll', handleScroll) + } + }, []) + + return ( + + ) +} diff --git a/src/renderer/src/components/Titlebar/index.tsx b/src/renderer/src/components/Titlebar/index.tsx new file mode 100644 index 0000000..2c8a860 --- /dev/null +++ b/src/renderer/src/components/Titlebar/index.tsx @@ -0,0 +1,46 @@ +import { Button } from '@renderer/components/ui/button' +import { cn } from '@renderer/lib/utils' + +export function Titlebar({ + children, + className +}: { + children?: React.ReactNode + className?: string +}) { + return ( +
+ {children} +
+ ) +} + +export function TitlebarButton({ + onClick, + disabled, + children, + title +}: { + onClick?: () => void + disabled?: boolean + children: React.ReactNode + title?: string +}) { + return ( + + ) +} diff --git a/src/renderer/src/components/UserAvatar/index.tsx b/src/renderer/src/components/UserAvatar/index.tsx new file mode 100644 index 0000000..d25ff71 --- /dev/null +++ b/src/renderer/src/components/UserAvatar/index.tsx @@ -0,0 +1,50 @@ +import { Avatar, AvatarFallback, AvatarImage } from '@renderer/components/ui/avatar' +import { HoverCard, HoverCardContent, HoverCardTrigger } from '@renderer/components/ui/hover-card' +import { Skeleton } from '@renderer/components/ui/skeleton' +import { useFetchProfile } from '@renderer/hooks' +import { generateImageByPubkey } from '@renderer/lib/pubkey' +import { toProfile } from '@renderer/lib/url' +import { cn } from '@renderer/lib/utils' +import { SecondaryPageLink } from '@renderer/PageManager' +import ProfileCard from '../ProfileCard' + +const UserAvatarSizeCnMap = { + large: 'w-24 h-24', + normal: 'w-10 h-10', + small: 'w-7 h-7', + tiny: 'w-3 h-3' +} + +export default function UserAvatar({ + userId, + className, + size = 'normal' +}: { + userId: string + className?: string + size?: 'large' | 'normal' | 'small' | 'tiny' +}) { + const { avatar, pubkey } = useFetchProfile(userId) + if (!pubkey) + return + + const defaultAvatar = generateImageByPubkey(pubkey) + + return ( + + + e.stopPropagation()}> + + + + {pubkey} + + + + + + + + + ) +} diff --git a/src/renderer/src/components/Username/index.tsx b/src/renderer/src/components/Username/index.tsx new file mode 100644 index 0000000..ffedfb2 --- /dev/null +++ b/src/renderer/src/components/Username/index.tsx @@ -0,0 +1,39 @@ +import { HoverCard, HoverCardContent, HoverCardTrigger } from '@renderer/components/ui/hover-card' +import { useFetchProfile } from '@renderer/hooks' +import { toProfile } from '@renderer/lib/url' +import { cn } from '@renderer/lib/utils' +import { SecondaryPageLink } from '@renderer/PageManager' +import ProfileCard from '../ProfileCard' + +export default function Username({ + userId, + showAt = false, + className +}: { + userId: string + showAt?: boolean + className?: string +}) { + const { username, pubkey } = useFetchProfile(userId) + if (!pubkey) return null + + return ( + + +
+ e.stopPropagation()} + > + {showAt && '@'} + {username} + +
+
+ + + +
+ ) +} diff --git a/src/renderer/src/components/VideoPlayer/index.tsx b/src/renderer/src/components/VideoPlayer/index.tsx new file mode 100644 index 0000000..ce88104 --- /dev/null +++ b/src/renderer/src/components/VideoPlayer/index.tsx @@ -0,0 +1,25 @@ +import { cn } from '@renderer/lib/utils' +import NsfwOverlay from '../NsfwOverlay' + +export default function VideoPlayer({ + src, + className, + isNsfw = false, + size = 'normal' +}: { + src: string + className?: string + isNsfw?: boolean + size?: 'normal' | 'small' +}) { + return ( +
+
+ ) +} diff --git a/src/renderer/src/components/theme-provider.tsx b/src/renderer/src/components/theme-provider.tsx new file mode 100644 index 0000000..8563069 --- /dev/null +++ b/src/renderer/src/components/theme-provider.tsx @@ -0,0 +1,71 @@ +import { createContext, useContext, useEffect, useState } from 'react' +import { TTheme, TThemeSetting } from '@common/types' + +type ThemeProviderProps = { + children: React.ReactNode + defaultTheme?: TTheme +} + +type ThemeProviderState = { + themeSetting: TThemeSetting + setThemeSetting: (themeSetting: TThemeSetting) => void +} + +const ThemeProviderContext = createContext(undefined) + +export function ThemeProvider({ children, ...props }: ThemeProviderProps) { + const [themeSetting, setThemeSetting] = useState( + (localStorage.getItem('themeSetting') as TTheme) ?? 'system' + ) + const [theme, setTheme] = useState('light') + + const init = async () => { + const [themeSetting, theme] = await Promise.all([ + window.api.theme.themeSetting(), + window.api.theme.current() + ]) + localStorage.setItem('theme', theme) + setTheme(theme) + setThemeSetting(themeSetting) + + window.api.theme.onChange((theme) => { + localStorage.setItem('theme', theme) + setTheme(theme) + }) + } + + useEffect(() => { + init() + }, []) + + useEffect(() => { + const updateTheme = async () => { + const root = window.document.documentElement + root.classList.remove('light', 'dark') + root.classList.add(theme) + localStorage.setItem('theme', theme) + } + updateTheme() + }, [theme]) + + const value = { + themeSetting: themeSetting, + setThemeSetting: (themeSetting: TThemeSetting) => { + window.api.theme.set(themeSetting).then(() => setThemeSetting(themeSetting)) + } + } + + return ( + + {children} + + ) +} + +export const useTheme = () => { + const context = useContext(ThemeProviderContext) + + if (context === undefined) throw new Error('useTheme must be used within a ThemeProvider') + + return context +} diff --git a/src/renderer/src/components/ui/avatar.tsx b/src/renderer/src/components/ui/avatar.tsx new file mode 100644 index 0000000..b7615b6 --- /dev/null +++ b/src/renderer/src/components/ui/avatar.tsx @@ -0,0 +1,48 @@ +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@renderer/lib/utils" + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/src/renderer/src/components/ui/button.tsx b/src/renderer/src/components/ui/button.tsx index a73e8dc..a1cfe03 100644 --- a/src/renderer/src/components/ui/button.tsx +++ b/src/renderer/src/components/ui/button.tsx @@ -1,35 +1,34 @@ -import * as React from "react" -import { Slot } from "@radix-ui/react-slot" -import { cva, type VariantProps } from "class-variance-authority" +import * as React from 'react' +import { Slot } from '@radix-ui/react-slot' +import { cva, type VariantProps } from 'class-variance-authority' -import { cn } from "@renderer/lib/utils" +import { cn } from '@renderer/lib/utils' const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', { variants: { variant: { - default: "bg-primary text-primary-foreground hover:bg-primary/90", - destructive: - "bg-destructive text-destructive-foreground hover:bg-destructive/90", - outline: - "border border-input bg-background hover:bg-accent hover:text-accent-foreground", - secondary: - "bg-secondary text-secondary-foreground hover:bg-secondary/80", - ghost: "hover:bg-accent hover:text-accent-foreground", - link: "text-primary underline-offset-4 hover:underline", + default: 'bg-primary text-primary-foreground hover:bg-primary/90', + destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90', + outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', + secondary: 'bg-secondary text-secondary-foreground hover:bg-muted/80', + 'secondary-2': 'bg-secondary text-secondary-foreground hover:bg-highlight', + ghost: 'hover:bg-accent hover:text-accent-foreground', + link: 'text-primary underline-offset-4 hover:underline' }, size: { - default: "h-10 px-4 py-2", - sm: "h-9 rounded-md px-3", - lg: "h-11 rounded-md px-8", - icon: "h-10 w-10", - }, + default: 'h-10 px-4 py-2', + sm: 'h-9 rounded-md px-3', + lg: 'h-11 rounded-md px-8', + icon: 'h-10 w-10', + xs: 'h-7 w-7 p-0 rounded-full' + } }, defaultVariants: { - variant: "default", - size: "default", - }, + variant: 'default', + size: 'default' + } } ) @@ -41,16 +40,12 @@ export interface ButtonProps const Button = React.forwardRef( ({ className, variant, size, asChild = false, ...props }, ref) => { - const Comp = asChild ? Slot : "button" + const Comp = asChild ? Slot : 'button' return ( - + ) } ) -Button.displayName = "Button" +Button.displayName = 'Button' export { Button, buttonVariants } diff --git a/src/renderer/src/components/ui/card.tsx b/src/renderer/src/components/ui/card.tsx new file mode 100644 index 0000000..edfa6cf --- /dev/null +++ b/src/renderer/src/components/ui/card.tsx @@ -0,0 +1,79 @@ +import * as React from "react" + +import { cn } from "@renderer/lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/src/renderer/src/components/ui/dialog.tsx b/src/renderer/src/components/ui/dialog.tsx new file mode 100644 index 0000000..c6086d3 --- /dev/null +++ b/src/renderer/src/components/ui/dialog.tsx @@ -0,0 +1,120 @@ +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { X } from "lucide-react" + +import { cn } from "@renderer/lib/utils" + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = DialogPrimitive.Portal + +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/src/renderer/src/components/ui/dropdown-menu.tsx b/src/renderer/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..477fa53 --- /dev/null +++ b/src/renderer/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,183 @@ +import * as React from 'react' +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu' +import { Check, ChevronRight, Circle } from 'lucide-react' + +import { cn } from '@renderer/lib/utils' + +const DropdownMenu = DropdownMenuPrimitive.Root + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger + +const DropdownMenuGroup = DropdownMenuPrimitive.Group + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal + +const DropdownMenuSub = DropdownMenuPrimitive.Sub + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes) => { + return +} +DropdownMenuShortcut.displayName = 'DropdownMenuShortcut' + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup +} diff --git a/src/renderer/src/components/ui/hover-card.tsx b/src/renderer/src/components/ui/hover-card.tsx new file mode 100644 index 0000000..4ad125d --- /dev/null +++ b/src/renderer/src/components/ui/hover-card.tsx @@ -0,0 +1,28 @@ +import * as React from 'react' +import * as HoverCardPrimitive from '@radix-ui/react-hover-card' + +import { cn } from '@renderer/lib/utils' + +const HoverCard = HoverCardPrimitive.Root + +const HoverCardTrigger = HoverCardPrimitive.Trigger + +const HoverCardContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => ( + +)) +HoverCardContent.displayName = HoverCardPrimitive.Content.displayName + +export { HoverCard, HoverCardTrigger, HoverCardContent } diff --git a/src/renderer/src/components/ui/input.tsx b/src/renderer/src/components/ui/input.tsx new file mode 100644 index 0000000..5e3cf65 --- /dev/null +++ b/src/renderer/src/components/ui/input.tsx @@ -0,0 +1,24 @@ +import * as React from 'react' + +import { cn } from '@renderer/lib/utils' + +export interface InputProps extends React.InputHTMLAttributes {} + +const Input = React.forwardRef( + ({ className, type, ...props }, ref) => { + return ( + + ) + } +) +Input.displayName = 'Input' + +export { Input } diff --git a/src/renderer/src/components/ui/popover.tsx b/src/renderer/src/components/ui/popover.tsx new file mode 100644 index 0000000..ee9e8ce --- /dev/null +++ b/src/renderer/src/components/ui/popover.tsx @@ -0,0 +1,29 @@ +import * as React from 'react' +import * as PopoverPrimitive from '@radix-ui/react-popover' + +import { cn } from '@renderer/lib/utils' + +const Popover = PopoverPrimitive.Root + +const PopoverTrigger = PopoverPrimitive.Trigger + +const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => ( + + + +)) +PopoverContent.displayName = PopoverPrimitive.Content.displayName + +export { Popover, PopoverTrigger, PopoverContent } diff --git a/src/renderer/src/components/ui/radio-group.tsx b/src/renderer/src/components/ui/radio-group.tsx new file mode 100644 index 0000000..3d9964f --- /dev/null +++ b/src/renderer/src/components/ui/radio-group.tsx @@ -0,0 +1,42 @@ +import * as React from "react" +import * as RadioGroupPrimitive from "@radix-ui/react-radio-group" +import { Circle } from "lucide-react" + +import { cn } from "@renderer/lib/utils" + +const RadioGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + ) +}) +RadioGroup.displayName = RadioGroupPrimitive.Root.displayName + +const RadioGroupItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + + + + + ) +}) +RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName + +export { RadioGroup, RadioGroupItem } diff --git a/src/renderer/src/components/ui/resizable.tsx b/src/renderer/src/components/ui/resizable.tsx new file mode 100644 index 0000000..535241a --- /dev/null +++ b/src/renderer/src/components/ui/resizable.tsx @@ -0,0 +1,43 @@ +import { GripVertical } from "lucide-react" +import * as ResizablePrimitive from "react-resizable-panels" + +import { cn } from "@renderer/lib/utils" + +const ResizablePanelGroup = ({ + className, + ...props +}: React.ComponentProps) => ( + +) + +const ResizablePanel = ResizablePrimitive.Panel + +const ResizableHandle = ({ + withHandle, + className, + ...props +}: React.ComponentProps & { + withHandle?: boolean +}) => ( + div]:rotate-90", + className + )} + {...props} + > + {withHandle && ( +
+ +
+ )} +
+) + +export { ResizablePanelGroup, ResizablePanel, ResizableHandle } diff --git a/src/renderer/src/components/ui/scroll-area.tsx b/src/renderer/src/components/ui/scroll-area.tsx new file mode 100644 index 0000000..cbeb566 --- /dev/null +++ b/src/renderer/src/components/ui/scroll-area.tsx @@ -0,0 +1,40 @@ +import * as React from 'react' +import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area' + +import { cn } from '@renderer/lib/utils' + +const ScrollArea = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { scrollBarClassName?: string } +>(({ className, scrollBarClassName, children, ...props }, ref) => ( + + + {children} + + + + +)) +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName + +const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = 'vertical', ...props }, ref) => ( + + + +)) +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName + +export { ScrollArea, ScrollBar } diff --git a/src/renderer/src/components/ui/select.tsx b/src/renderer/src/components/ui/select.tsx new file mode 100644 index 0000000..f068074 --- /dev/null +++ b/src/renderer/src/components/ui/select.tsx @@ -0,0 +1,158 @@ +import * as React from "react" +import * as SelectPrimitive from "@radix-ui/react-select" +import { Check, ChevronDown, ChevronUp } from "lucide-react" + +import { cn } from "@renderer/lib/utils" + +const Select = SelectPrimitive.Root + +const SelectGroup = SelectPrimitive.Group + +const SelectValue = SelectPrimitive.Value + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1", + className + )} + {...props} + > + {children} + + + + +)) +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)) +SelectContent.displayName = SelectPrimitive.Content.displayName + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectLabel.displayName = SelectPrimitive.Label.displayName + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + + {children} + +)) +SelectItem.displayName = SelectPrimitive.Item.displayName + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectSeparator.displayName = SelectPrimitive.Separator.displayName + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +} diff --git a/src/renderer/src/components/ui/separator.tsx b/src/renderer/src/components/ui/separator.tsx new file mode 100644 index 0000000..e09528a --- /dev/null +++ b/src/renderer/src/components/ui/separator.tsx @@ -0,0 +1,29 @@ +import * as React from "react" +import * as SeparatorPrimitive from "@radix-ui/react-separator" + +import { cn } from "@renderer/lib/utils" + +const Separator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>( + ( + { className, orientation = "horizontal", decorative = true, ...props }, + ref + ) => ( + + ) +) +Separator.displayName = SeparatorPrimitive.Root.displayName + +export { Separator } diff --git a/src/renderer/src/components/ui/skeleton.tsx b/src/renderer/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..05caf09 --- /dev/null +++ b/src/renderer/src/components/ui/skeleton.tsx @@ -0,0 +1,15 @@ +import { cn } from "@renderer/lib/utils" + +function Skeleton({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ) +} + +export { Skeleton } diff --git a/src/renderer/src/components/ui/toast.tsx b/src/renderer/src/components/ui/toast.tsx new file mode 100644 index 0000000..e1effc8 --- /dev/null +++ b/src/renderer/src/components/ui/toast.tsx @@ -0,0 +1,127 @@ +import * as React from "react" +import * as ToastPrimitives from "@radix-ui/react-toast" +import { cva, type VariantProps } from "class-variance-authority" +import { X } from "lucide-react" + +import { cn } from "@renderer/lib/utils" + +const ToastProvider = ToastPrimitives.Provider + +const ToastViewport = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastViewport.displayName = ToastPrimitives.Viewport.displayName + +const toastVariants = cva( + "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", + { + variants: { + variant: { + default: "border bg-background text-foreground", + destructive: + "destructive group border-destructive bg-destructive text-destructive-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Toast = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, ...props }, ref) => { + return ( + + ) +}) +Toast.displayName = ToastPrimitives.Root.displayName + +const ToastAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastAction.displayName = ToastPrimitives.Action.displayName + +const ToastClose = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +ToastClose.displayName = ToastPrimitives.Close.displayName + +const ToastTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastTitle.displayName = ToastPrimitives.Title.displayName + +const ToastDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastDescription.displayName = ToastPrimitives.Description.displayName + +type ToastProps = React.ComponentPropsWithoutRef + +type ToastActionElement = React.ReactElement + +export { + type ToastProps, + type ToastActionElement, + ToastProvider, + ToastViewport, + Toast, + ToastTitle, + ToastDescription, + ToastClose, + ToastAction, +} diff --git a/src/renderer/src/components/ui/toaster.tsx b/src/renderer/src/components/ui/toaster.tsx new file mode 100644 index 0000000..dd1c08e --- /dev/null +++ b/src/renderer/src/components/ui/toaster.tsx @@ -0,0 +1,31 @@ +import { useToast } from '@renderer/hooks/use-toast' +import { + Toast, + ToastClose, + ToastDescription, + ToastProvider, + ToastTitle, + ToastViewport +} from '@renderer/components/ui/toast' + +export function Toaster() { + const { toasts } = useToast() + + return ( + + {toasts.map(function ({ id, title, description, action, ...props }) { + return ( + +
+ {title && {title}} + {description && {description}} +
+ {action} + +
+ ) + })} + +
+ ) +} diff --git a/src/renderer/src/embedded/EmbeddedHashtag.tsx b/src/renderer/src/embedded/EmbeddedHashtag.tsx new file mode 100644 index 0000000..856211c --- /dev/null +++ b/src/renderer/src/embedded/EmbeddedHashtag.tsx @@ -0,0 +1,9 @@ +import { EmbeddedHashtag } from '../components/Embedded' +import { TEmbeddedRenderer } from './types' + +export const embeddedHashtagRenderer: TEmbeddedRenderer = { + regex: /#([^\s#]+)/g, + render: (hashtag: string, index: number) => { + return + } +} diff --git a/src/renderer/src/embedded/EmbeddedNormalUrl.tsx b/src/renderer/src/embedded/EmbeddedNormalUrl.tsx new file mode 100644 index 0000000..385d079 --- /dev/null +++ b/src/renderer/src/embedded/EmbeddedNormalUrl.tsx @@ -0,0 +1,9 @@ +import { EmbeddedNormalUrl } from '../components/Embedded' +import { TEmbeddedRenderer } from './types' + +export const embeddedNormalUrlRenderer: TEmbeddedRenderer = { + regex: /(https?:\/\/[^\s]+|wss?:\/\/[^\s]+)/g, + render: (url: string, index: number) => { + return + } +} diff --git a/src/renderer/src/embedded/EmbeddedNostrNpub.tsx b/src/renderer/src/embedded/EmbeddedNostrNpub.tsx new file mode 100644 index 0000000..52eca7f --- /dev/null +++ b/src/renderer/src/embedded/EmbeddedNostrNpub.tsx @@ -0,0 +1,10 @@ +import { EmbeddedMention } from '../components/Embedded' +import { TEmbeddedRenderer } from './types' + +export const embeddedNostrNpubRenderer: TEmbeddedRenderer = { + regex: /(nostr:npub1[a-z0-9]{58})/g, + render: (id: string, index: number) => { + const npub1 = id.split(':')[1] + return + } +} diff --git a/src/renderer/src/embedded/EmbeddedNostrProfile.tsx b/src/renderer/src/embedded/EmbeddedNostrProfile.tsx new file mode 100644 index 0000000..7bd01d4 --- /dev/null +++ b/src/renderer/src/embedded/EmbeddedNostrProfile.tsx @@ -0,0 +1,10 @@ +import { EmbeddedMention } from '../components/Embedded' +import { TEmbeddedRenderer } from './types' + +export const embeddedNostrProfileRenderer: TEmbeddedRenderer = { + regex: /(nostr:nprofile1[a-z0-9]+)/g, + render: (id: string, index: number) => { + const nprofile = id.split(':')[1] + return + } +} diff --git a/src/renderer/src/embedded/EmbeddedNpub.tsx b/src/renderer/src/embedded/EmbeddedNpub.tsx new file mode 100644 index 0000000..f989047 --- /dev/null +++ b/src/renderer/src/embedded/EmbeddedNpub.tsx @@ -0,0 +1,9 @@ +import { EmbeddedMention } from '../components/Embedded' +import { TEmbeddedRenderer } from './types' + +export const embeddedNpubRenderer: TEmbeddedRenderer = { + regex: /(npub1[a-z0-9]{58})/g, + render: (npub1: string, index: number) => { + return + } +} diff --git a/src/renderer/src/embedded/index.tsx b/src/renderer/src/embedded/index.tsx new file mode 100644 index 0000000..9a2465d --- /dev/null +++ b/src/renderer/src/embedded/index.tsx @@ -0,0 +1,17 @@ +import reactStringReplace from 'react-string-replace' +import { TEmbeddedRenderer } from './types' + +export * from './EmbeddedHashtag' +export * from './EmbeddedNormalUrl' +export * from './EmbeddedNostrNpub' +export * from './EmbeddedNostrProfile' + +export function embedded(content: string, renderers: TEmbeddedRenderer[]) { + let nodes: React.ReactNode[] = [content] + + renderers.forEach((renderer) => { + nodes = reactStringReplace(nodes, renderer.regex, renderer.render) + }) + + return nodes +} diff --git a/src/renderer/src/embedded/types.tsx b/src/renderer/src/embedded/types.tsx new file mode 100644 index 0000000..086a5f7 --- /dev/null +++ b/src/renderer/src/embedded/types.tsx @@ -0,0 +1,4 @@ +export type TEmbeddedRenderer = { + regex: RegExp + render: (match: string, index: number) => JSX.Element +} diff --git a/src/renderer/src/hooks/index.tsx b/src/renderer/src/hooks/index.tsx new file mode 100644 index 0000000..7a3b7ef --- /dev/null +++ b/src/renderer/src/hooks/index.tsx @@ -0,0 +1,2 @@ +export * from './useFetchEvent' +export * from './useFetchProfile' diff --git a/src/renderer/src/hooks/use-toast.ts b/src/renderer/src/hooks/use-toast.ts new file mode 100644 index 0000000..b7686a6 --- /dev/null +++ b/src/renderer/src/hooks/use-toast.ts @@ -0,0 +1,194 @@ +"use client" + +// Inspired by react-hot-toast library +import * as React from "react" + +import type { + ToastActionElement, + ToastProps, +} from "@renderer/components/ui/toast" + +const TOAST_LIMIT = 1 +const TOAST_REMOVE_DELAY = 1000000 + +type ToasterToast = ToastProps & { + id: string + title?: React.ReactNode + description?: React.ReactNode + action?: ToastActionElement +} + +const actionTypes = { + ADD_TOAST: "ADD_TOAST", + UPDATE_TOAST: "UPDATE_TOAST", + DISMISS_TOAST: "DISMISS_TOAST", + REMOVE_TOAST: "REMOVE_TOAST", +} as const + +let count = 0 + +function genId() { + count = (count + 1) % Number.MAX_SAFE_INTEGER + return count.toString() +} + +type ActionType = typeof actionTypes + +type Action = + | { + type: ActionType["ADD_TOAST"] + toast: ToasterToast + } + | { + type: ActionType["UPDATE_TOAST"] + toast: Partial + } + | { + type: ActionType["DISMISS_TOAST"] + toastId?: ToasterToast["id"] + } + | { + type: ActionType["REMOVE_TOAST"] + toastId?: ToasterToast["id"] + } + +interface State { + toasts: ToasterToast[] +} + +const toastTimeouts = new Map>() + +const addToRemoveQueue = (toastId: string) => { + if (toastTimeouts.has(toastId)) { + return + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId) + dispatch({ + type: "REMOVE_TOAST", + toastId: toastId, + }) + }, TOAST_REMOVE_DELAY) + + toastTimeouts.set(toastId, timeout) +} + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case "ADD_TOAST": + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + } + + case "UPDATE_TOAST": + return { + ...state, + toasts: state.toasts.map((t) => + t.id === action.toast.id ? { ...t, ...action.toast } : t + ), + } + + case "DISMISS_TOAST": { + const { toastId } = action + + // ! Side effects ! - This could be extracted into a dismissToast() action, + // but I'll keep it here for simplicity + if (toastId) { + addToRemoveQueue(toastId) + } else { + state.toasts.forEach((toast) => { + addToRemoveQueue(toast.id) + }) + } + + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + open: false, + } + : t + ), + } + } + case "REMOVE_TOAST": + if (action.toastId === undefined) { + return { + ...state, + toasts: [], + } + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId), + } + } +} + +const listeners: Array<(state: State) => void> = [] + +let memoryState: State = { toasts: [] } + +function dispatch(action: Action) { + memoryState = reducer(memoryState, action) + listeners.forEach((listener) => { + listener(memoryState) + }) +} + +type Toast = Omit + +function toast({ ...props }: Toast) { + const id = genId() + + const update = (props: ToasterToast) => + dispatch({ + type: "UPDATE_TOAST", + toast: { ...props, id }, + }) + const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) + + dispatch({ + type: "ADD_TOAST", + toast: { + ...props, + id, + open: true, + onOpenChange: (open) => { + if (!open) dismiss() + }, + }, + }) + + return { + id: id, + dismiss, + update, + } +} + +function useToast() { + const [state, setState] = React.useState(memoryState) + + React.useEffect(() => { + listeners.push(setState) + return () => { + const index = listeners.indexOf(setState) + if (index > -1) { + listeners.splice(index, 1) + } + } + }, [state]) + + return { + ...state, + toast, + dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), + } +} + +export { useToast, toast } diff --git a/src/renderer/src/hooks/useFetchEvent.tsx b/src/renderer/src/hooks/useFetchEvent.tsx new file mode 100644 index 0000000..8225906 --- /dev/null +++ b/src/renderer/src/hooks/useFetchEvent.tsx @@ -0,0 +1,50 @@ +import client from '@renderer/services/client.service' +import { Event, Filter, nip19 } from 'nostr-tools' +import { useEffect, useState } from 'react' + +export function useFetchEventById(id?: string) { + const [event, setEvent] = useState(undefined) + + useEffect(() => { + const fetchEvent = async () => { + if (!id) return + + let filter: Filter | undefined + if (/^[0-9a-f]{64}$/.test(id)) { + filter = { ids: [id] } + } else if (/^note1[a-z0-9]{58}$/.test(id)) { + const { data } = nip19.decode(id as `note1${string}`) + filter = { ids: [data] } + } else if (id.startsWith('nevent1')) { + const { data } = nip19.decode(id as `nevent1${string}`) + filter = {} + if (data.id) { + filter.ids = [data.id] + } + if (data.author) { + filter.authors = [data.author] + } + if (data.kind) { + filter.kinds = [data.kind] + } + } + if (!filter) return + + let event: Event | undefined + if (filter.ids) { + event = await client.fetchEventById(filter.ids[0]) + } else { + event = await client.fetchEventWithCache(filter) + } + if (event) { + setEvent(event) + } else { + setEvent(undefined) + } + } + + fetchEvent() + }, [id]) + + return event +} diff --git a/src/renderer/src/hooks/useFetchEventStats.tsx b/src/renderer/src/hooks/useFetchEventStats.tsx new file mode 100644 index 0000000..842b7bc --- /dev/null +++ b/src/renderer/src/hooks/useFetchEventStats.tsx @@ -0,0 +1,29 @@ +import client from '@renderer/services/client.service' +import { TEventStats } from '@renderer/types' +import { useEffect, useState } from 'react' + +export default function useFetchEventStats(eventId: string) { + const [stats, setStats] = useState({ + reactionCount: 0, + repostCount: 0 + }) + const [loading, setLoading] = useState(true) + + useEffect(() => { + const fetchStats = async () => { + setLoading(true) + try { + const stats = await client.fetchEventStatsById(eventId) + setStats(stats) + } catch (error) { + console.error('Failed to fetch event stats', error) + } finally { + setLoading(false) + } + } + + fetchStats() + }, [eventId]) + + return { stats, loading } +} diff --git a/src/renderer/src/hooks/useFetchNip05.tsx b/src/renderer/src/hooks/useFetchNip05.tsx new file mode 100644 index 0000000..f93d23e --- /dev/null +++ b/src/renderer/src/hooks/useFetchNip05.tsx @@ -0,0 +1,19 @@ +import { verifyNip05 } from '@renderer/lib/nip05' +import { useEffect, useState } from 'react' + +export function useFetchNip05(nip05?: string, pubkey?: string) { + const [nip05IsVerified, setNip05IsVerified] = useState(false) + const [nip05Name, setNip05Name] = useState('') + const [nip05Domain, setNip05Domain] = useState('') + + useEffect(() => { + if (!nip05 || !pubkey) return + verifyNip05(nip05, pubkey).then(({ isVerified, nip05Name, nip05Domain }) => { + setNip05IsVerified(isVerified) + setNip05Name(nip05Name) + setNip05Domain(nip05Domain) + }) + }, [nip05, pubkey]) + + return { nip05IsVerified, nip05Name, nip05Domain } +} diff --git a/src/renderer/src/hooks/useFetchProfile.tsx b/src/renderer/src/hooks/useFetchProfile.tsx new file mode 100644 index 0000000..f9bcca5 --- /dev/null +++ b/src/renderer/src/hooks/useFetchProfile.tsx @@ -0,0 +1,69 @@ +import { formatNpub } from '@renderer/lib/pubkey' +import client from '@renderer/services/client.service' +import { nip19 } from 'nostr-tools' +import { useCallback, useEffect, useState } from 'react' + +type TProfile = { + username: string + pubkey?: string + npub?: `npub1${string}` + banner?: string + avatar?: string + nip05?: string + about?: string +} + +const decodeUserId = (id: string): { pubkey?: string; npub?: `npub1${string}` } => { + if (/^npub1[a-z0-9]{58}$/.test(id)) { + const { data } = nip19.decode(id as `npub1${string}`) + return { pubkey: data, npub: id as `npub1${string}` } + } else if (id.startsWith('nprofile1')) { + const { data } = nip19.decode(id as `nprofile1${string}`) + return { pubkey: data.pubkey, npub: nip19.npubEncode(data.pubkey) } + } else if (/^[0-9a-f]{64}$/.test(id)) { + return { pubkey: id, npub: nip19.npubEncode(id) } + } + return {} +} + +export function useFetchProfile(id?: string) { + const initialProfile: TProfile = { + username: id ? (id.length > 9 ? id.slice(0, 4) + '...' + id.slice(-4) : id) : 'username' + } + const [profile, setProfile] = useState(initialProfile) + + const fetchProfile = useCallback(async () => { + try { + if (!id) return + + const { pubkey, npub } = decodeUserId(id) + if (!pubkey || !npub) return + + const profileEvent = await client.fetchProfile(pubkey) + const username = npub ? formatNpub(npub) : initialProfile.username + setProfile({ pubkey, npub, username }) + if (!profileEvent) return + + const profileObj = JSON.parse(profileEvent.content) + setProfile({ + ...initialProfile, + pubkey, + npub, + banner: profileObj.banner, + avatar: profileObj.picture, + username: + profileObj.display_name?.trim() || profileObj.name?.trim() || initialProfile.username, + nip05: profileObj.nip05, + about: profileObj.about + }) + } catch (err) { + console.error(err) + } + }, [id]) + + useEffect(() => { + fetchProfile() + }, [id]) + + return profile +} diff --git a/src/renderer/src/layouts/PrimaryPageLayout/RelaySettingsPopover.tsx b/src/renderer/src/layouts/PrimaryPageLayout/RelaySettingsPopover.tsx new file mode 100644 index 0000000..822438f --- /dev/null +++ b/src/renderer/src/layouts/PrimaryPageLayout/RelaySettingsPopover.tsx @@ -0,0 +1,24 @@ +import RelaySettings from '@renderer/components/RelaySettings' +import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover' +import { ScrollArea } from '@renderer/components/ui/scroll-area' +import { Server } from 'lucide-react' + +export default function RelaySettingsPopover() { + return ( + + + + + + +
+ +
+
+
+
+ ) +} diff --git a/src/renderer/src/layouts/PrimaryPageLayout/ReloadTimelineButton.tsx b/src/renderer/src/layouts/PrimaryPageLayout/ReloadTimelineButton.tsx new file mode 100644 index 0000000..725a01b --- /dev/null +++ b/src/renderer/src/layouts/PrimaryPageLayout/ReloadTimelineButton.tsx @@ -0,0 +1,14 @@ +import { TitlebarButton } from '@renderer/components/Titlebar' +import { createReloadTimelineEvent, eventBus } from '@renderer/services/event-bus.service' +import { Eraser } from 'lucide-react' + +export default function ReloadTimelineButton() { + return ( + eventBus.emit(createReloadTimelineEvent())} + title="reload timeline" + > + + + ) +} diff --git a/src/renderer/src/layouts/PrimaryPageLayout/index.tsx b/src/renderer/src/layouts/PrimaryPageLayout/index.tsx new file mode 100644 index 0000000..4dab781 --- /dev/null +++ b/src/renderer/src/layouts/PrimaryPageLayout/index.tsx @@ -0,0 +1,56 @@ +import ScrollToTopButton from '@renderer/components/ScrollToTopButton' +import { Titlebar } from '@renderer/components/Titlebar' +import { ScrollArea } from '@renderer/components/ui/scroll-area' +import { isMacOS } from '@renderer/lib/platform' +import { forwardRef, useImperativeHandle, useRef } from 'react' +import ReloadTimelineButton from './ReloadTimelineButton' +import RelaySettingsPopover from './RelaySettingsPopover' + +const PrimaryPageLayout = forwardRef( + ( + { children, titlebarContent }: { children: React.ReactNode; titlebarContent?: React.ReactNode }, + ref + ) => { + const scrollAreaRef = useRef(null) + + useImperativeHandle( + ref, + () => ({ + scrollToTop: () => { + scrollAreaRef.current?.scrollTo({ top: 0 }) + } + }), + [] + ) + + return ( + + +
{children}
+ +
+ ) + } +) +PrimaryPageLayout.displayName = 'PrimaryPageLayout' +export default PrimaryPageLayout + +export type TPrimaryPageLayoutRef = { + scrollToTop: () => void +} + +export function PrimaryPageTitlebar({ content }: { content?: React.ReactNode }) { + return ( + +
{content}
+
+ + +
+
+ ) +} diff --git a/src/renderer/src/layouts/SecondaryPageLayout/BackButton.tsx b/src/renderer/src/layouts/SecondaryPageLayout/BackButton.tsx new file mode 100644 index 0000000..616f51e --- /dev/null +++ b/src/renderer/src/layouts/SecondaryPageLayout/BackButton.tsx @@ -0,0 +1,17 @@ +import { TitlebarButton } from '@renderer/components/Titlebar' +import { useSecondaryPage } from '@renderer/PageManager' +import { ChevronLeft } from 'lucide-react' + +export default function BackButton({ hide = false }: { hide?: boolean }) { + const { pop } = useSecondaryPage() + + return ( + <> + {!hide && ( + pop()}> + + + )} + + ) +} diff --git a/src/renderer/src/layouts/SecondaryPageLayout/ThemeToggle.tsx b/src/renderer/src/layouts/SecondaryPageLayout/ThemeToggle.tsx new file mode 100644 index 0000000..b5837be --- /dev/null +++ b/src/renderer/src/layouts/SecondaryPageLayout/ThemeToggle.tsx @@ -0,0 +1,25 @@ +import { useTheme } from '@renderer/components/theme-provider' +import { TitlebarButton } from '@renderer/components/Titlebar' +import { Moon, Sun, SunMoon } from 'lucide-react' + +export default function ThemeToggle() { + const { themeSetting, setThemeSetting } = useTheme() + + return ( + <> + {themeSetting === 'system' ? ( + setThemeSetting('light')} title="switch to light theme"> + + + ) : themeSetting === 'light' ? ( + setThemeSetting('dark')} title="switch to dark theme"> + + + ) : ( + setThemeSetting('system')} title="switch to system theme"> + + + )} + + ) +} diff --git a/src/renderer/src/layouts/SecondaryPageLayout/index.tsx b/src/renderer/src/layouts/SecondaryPageLayout/index.tsx new file mode 100644 index 0000000..c0af8e0 --- /dev/null +++ b/src/renderer/src/layouts/SecondaryPageLayout/index.tsx @@ -0,0 +1,50 @@ +import ScrollToTopButton from '@renderer/components/ScrollToTopButton' +import { ScrollArea } from '@renderer/components/ui/scroll-area' +import { isMacOS } from '@renderer/lib/platform' +import { useRef } from 'react' +import { Titlebar } from '../../components/Titlebar' +import BackButton from './BackButton' +import ThemeToggle from './ThemeToggle' + +export default function SecondaryPageLayout({ + children, + titlebarContent, + hideBackButton = false +}: { + children: React.ReactNode + titlebarContent?: React.ReactNode + hideBackButton?: boolean +}): JSX.Element { + const scrollAreaRef = useRef(null) + return ( + + +
{children}
+ +
+ ) +} + +export function SecondaryPageTitlebar({ + content, + hideBackButton = false +}: { + content?: React.ReactNode + hideBackButton?: boolean +}): JSX.Element { + return ( + +
+ +
{content}
+
+
+ +
+
+ ) +} diff --git a/src/renderer/src/lib/event.ts b/src/renderer/src/lib/event.ts new file mode 100644 index 0000000..c8b5236 --- /dev/null +++ b/src/renderer/src/lib/event.ts @@ -0,0 +1,23 @@ +import { Event, kinds } from 'nostr-tools' + +export function isNsfwEvent(event: Event) { + return event.tags.some( + ([tagName, tagValue]) => + tagName === 'content-warning' || (tagName === 't' && tagValue.toLowerCase() === 'nsfw') + ) +} + +export function isReplyNoteEvent(event: Event) { + return ( + event.kind === kinds.ShortTextNote && + event.tags.some(([tagName, , , type]) => tagName === 'e' && ['root', 'reply'].includes(type)) + ) +} + +export function getParentEventId(event: Event) { + return event.tags.find(([tagName, , , type]) => tagName === 'e' && type === 'reply')?.[1] +} + +export function getRootEventId(event: Event) { + return event.tags.find(([tagName, , , type]) => tagName === 'e' && type === 'root')?.[1] +} diff --git a/src/renderer/src/lib/nip05.ts b/src/renderer/src/lib/nip05.ts new file mode 100644 index 0000000..cc2c28e --- /dev/null +++ b/src/renderer/src/lib/nip05.ts @@ -0,0 +1,41 @@ +import { LRUCache } from 'lru-cache' + +type TVerifyNip05Result = { + isVerified: boolean + nip05Name: string + nip05Domain: string +} + +const verifyNip05ResultCache = new LRUCache({ + max: 1000, + fetchMethod: (key) => { + const { nip05, pubkey } = JSON.parse(key) + return _verifyNip05(nip05, pubkey) + } +}) + +async function _verifyNip05(nip05: string, pubkey: string): Promise { + const [nip05Name, nip05Domain] = nip05?.split('@') || [undefined, undefined] + const result = { isVerified: false, nip05Name, nip05Domain } + if (!nip05Name || !nip05Domain || !pubkey) return result + + try { + const res = await fetch(`https://${nip05Domain}/.well-known/nostr.json?name=${nip05Name}`) + const json = await res.json() + if (json.names?.[nip05Name] === pubkey) { + return { ...result, isVerified: true } + } + } catch { + // ignore + } + return result +} + +export async function verifyNip05(nip05: string, pubkey: string): Promise { + const result = await verifyNip05ResultCache.fetch(JSON.stringify({ nip05, pubkey })) + if (result) { + return result + } + const [nip05Name, nip05Domain] = nip05?.split('@') || [undefined, undefined] + return { isVerified: false, nip05Name, nip05Domain } +} diff --git a/src/renderer/src/lib/platform.ts b/src/renderer/src/lib/platform.ts new file mode 100644 index 0000000..48cde35 --- /dev/null +++ b/src/renderer/src/lib/platform.ts @@ -0,0 +1,3 @@ +export function isMacOS() { + return window.electron.process.platform === 'darwin' +} diff --git a/src/renderer/src/lib/pubkey.ts b/src/renderer/src/lib/pubkey.ts new file mode 100644 index 0000000..e26a4ee --- /dev/null +++ b/src/renderer/src/lib/pubkey.ts @@ -0,0 +1,91 @@ +import { LRUCache } from 'lru-cache' +import { nip19 } from 'nostr-tools' + +export function formatPubkey(pubkey: string) { + const npub = pubkeyToNpub(pubkey) + if (npub) { + return formatNpub(npub) + } + return pubkey.slice(0, 4) + '...' + pubkey.slice(-4) +} + +export function formatNpub(npub: string, length = 12) { + if (length < 12) { + length = 12 + } + + if (length >= 63) { + return npub + } + + const prefixLength = Math.floor((length - 5) / 2) + 5 + const suffixLength = length - prefixLength + return npub.slice(0, prefixLength) + '...' + npub.slice(-suffixLength) +} + +export function pubkeyToNpub(pubkey: string) { + try { + return nip19.npubEncode(pubkey) + } catch { + return null + } +} + +export function userIdToPubkey(userId: string) { + if (userId.startsWith('npub1')) { + const { data } = nip19.decode(userId as `npub1${string}`) + return data + } + return userId +} + +const pubkeyImageCache = new LRUCache({ max: 1000 }) +export function generateImageByPubkey(pubkey: string): string { + if (pubkeyImageCache.has(pubkey)) { + return pubkeyImageCache.get(pubkey)! + } + + const paddedPubkey = pubkey.padEnd(2, '0') + + // Split into 3 parts for colors and the rest for control points + const colors: string[] = [] + const controlPoints: string[] = [] + for (let i = 0; i < 11; i++) { + const part = paddedPubkey.slice(i * 6, (i + 1) * 6) + if (i < 3) { + colors.push(`#${part}`) + } else { + controlPoints.push(part) + } + } + + // Generate SVG with multiple radial gradients + const gradients = controlPoints + .map((point, index) => { + const cx = parseInt(point.slice(0, 2), 16) % 100 + const cy = parseInt(point.slice(2, 4), 16) % 100 + const r = (parseInt(point.slice(4, 6), 16) % 35) + 30 + const c = colors[index % (colors.length - 1)] + + return ` + + + + + + ` + }) + .join('') + + const image = ` + + + ${gradients} + + ` + const imageData = `data:image/svg+xml;base64,${btoa(image)}` + + pubkeyImageCache.set(pubkey, imageData) + + return imageData +} diff --git a/src/renderer/src/lib/timestamp.ts b/src/renderer/src/lib/timestamp.ts new file mode 100644 index 0000000..799a129 --- /dev/null +++ b/src/renderer/src/lib/timestamp.ts @@ -0,0 +1,28 @@ +import dayjs from 'dayjs' + +export function formatTimestamp(timestamp: number) { + const time = dayjs(timestamp * 1000) + const now = dayjs() + + const diffMonth = now.diff(time, 'month') + if (diffMonth >= 1) { + return time.format('MMM D, YYYY') + } + + const diffDay = now.diff(time, 'day') + if (diffDay >= 1) { + return `${diffDay} days ago` + } + + const diffHour = now.diff(time, 'hour') + if (diffHour >= 1) { + return `${diffHour} hours ago` + } + + const diffMinute = now.diff(time, 'minute') + if (diffMinute >= 1) { + return `${diffMinute} minutes ago` + } + + return 'just now' +} diff --git a/src/renderer/src/lib/url.ts b/src/renderer/src/lib/url.ts new file mode 100644 index 0000000..2a57408 --- /dev/null +++ b/src/renderer/src/lib/url.ts @@ -0,0 +1,7 @@ +import { Event } from 'nostr-tools' + +export const toProfile = (pubkey: string) => ({ pageName: 'profile', props: { pubkey } }) +export const toNoStrudelProfile = (id: string) => `https://nostrudel.ninja/#/u/${id}` +export const toNote = (event: Event) => ({ pageName: 'note', props: { event } }) +export const toNoStrudelNote = (id: string) => `https://nostrudel.ninja/#/n/${id}` +export const toHashtag = (hashtag: string) => ({ pageName: 'hashtag', props: { hashtag } }) diff --git a/src/renderer/src/pages/primary/NoteListPage/index.tsx b/src/renderer/src/pages/primary/NoteListPage/index.tsx new file mode 100644 index 0000000..b52ca68 --- /dev/null +++ b/src/renderer/src/pages/primary/NoteListPage/index.tsx @@ -0,0 +1,10 @@ +import NoteList from '@renderer/components/NoteList' +import PrimaryPageLayout from '@renderer/layouts/PrimaryPageLayout' + +export default function NoteListPage() { + return ( + + + + ) +} diff --git a/src/renderer/src/pages/secondary/BlankPage/index.tsx b/src/renderer/src/pages/secondary/BlankPage/index.tsx new file mode 100644 index 0000000..62e3a59 --- /dev/null +++ b/src/renderer/src/pages/secondary/BlankPage/index.tsx @@ -0,0 +1,11 @@ +import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout' + +export default function BlankPage() { + return ( + +
+ Welcome! 🥳 +
+
+ ) +} diff --git a/src/renderer/src/pages/secondary/HashtagPage/index.tsx b/src/renderer/src/pages/secondary/HashtagPage/index.tsx new file mode 100644 index 0000000..4c7c41a --- /dev/null +++ b/src/renderer/src/pages/secondary/HashtagPage/index.tsx @@ -0,0 +1,15 @@ +import NoteList from '@renderer/components/NoteList' +import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout' + +export default function HashtagPage({ hashtag }: { hashtag?: string }) { + if (!hashtag) { + return null + } + const normalizedHashtag = hashtag.toLowerCase() + + return ( + + + + ) +} diff --git a/src/renderer/src/pages/secondary/NotePage/index.tsx b/src/renderer/src/pages/secondary/NotePage/index.tsx new file mode 100644 index 0000000..d1a9153 --- /dev/null +++ b/src/renderer/src/pages/secondary/NotePage/index.tsx @@ -0,0 +1,19 @@ +import ReplyNoteList from '@renderer/components/ReplyNoteList' +import Note from '@renderer/components/Note' +import { Separator } from '@renderer/components/ui/separator' +import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout' +import { Event } from 'nostr-tools' + +export default function NotePage({ event }: { event?: Event }) { + return ( + + {event && ( + <> + + + + + )} + + ) +} diff --git a/src/renderer/src/pages/secondary/ProfilePage/index.tsx b/src/renderer/src/pages/secondary/ProfilePage/index.tsx new file mode 100644 index 0000000..7c6e16f --- /dev/null +++ b/src/renderer/src/pages/secondary/ProfilePage/index.tsx @@ -0,0 +1,93 @@ +import Nip05 from '@renderer/components/Nip05' +import NoteList from '@renderer/components/NoteList' +import ProfileAbout from '@renderer/components/ProfileAbout' +import { Separator } from '@renderer/components/ui/separator' +import UserAvatar from '@renderer/components/UserAvatar' +import { useFetchProfile } from '@renderer/hooks' +import SecondaryPageLayout from '@renderer/layouts/SecondaryPageLayout' +import { formatNpub, generateImageByPubkey } from '@renderer/lib/pubkey' +import { Copy } from 'lucide-react' +import { useEffect, useState } from 'react' + +export default function ProfilePage({ pubkey }: { pubkey?: string }) { + const { banner, username, nip05, about, npub } = useFetchProfile(pubkey) + const [copied, setCopied] = useState(false) + + if (!pubkey || !npub) return null + + const copyNpub = () => { + if (!npub) return + navigator.clipboard.writeText(npub) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + return ( + +
+ + +
+
+
{username}
+ {nip05 && } +
copyNpub()} + > + {copied ? ( +
Copied!
+ ) : ( + <> +
{formatNpub(npub, 24)}
+ + + )} +
+
+ +
+
+ + +
+ ) +} + +function ProfileBanner({ + banner, + pubkey, + className +}: { + banner?: string + pubkey: string + className?: string +}) { + const defaultBanner = generateImageByPubkey(pubkey) + const [bannerUrl, setBannerUrl] = useState(banner || defaultBanner) + + useEffect(() => { + if (banner) { + setBannerUrl(banner) + } else { + setBannerUrl(defaultBanner) + } + }, [pubkey, banner]) + + return ( + Banner setBannerUrl(defaultBanner)} + /> + ) +} diff --git a/src/renderer/src/services/client.service.ts b/src/renderer/src/services/client.service.ts new file mode 100644 index 0000000..45b7427 --- /dev/null +++ b/src/renderer/src/services/client.service.ts @@ -0,0 +1,225 @@ +import { TRelayGroup } from '@common/types' +import { TEventStats } from '@renderer/types' +import { LRUCache } from 'lru-cache' +import { Filter, kinds, Event as NEvent, SimplePool } from 'nostr-tools' +import { EVENT_TYPES, eventBus } from './event-bus.service' +import storage from './storage.service' + +class ClientService { + static instance: ClientService + + private pool = new SimplePool() + private initPromise!: Promise + private relayUrls: string[] = [] + private cache = new LRUCache({ + max: 10000, + fetchMethod: async (filter) => this.fetchEvent(JSON.parse(filter)) + }) + + // Event cache + private eventsCache = new LRUCache>({ + max: 10000, + ttl: 1000 * 60 * 10 // 10 minutes + }) + private fetchEventQueue = new Map< + string, + { + resolve: (value: NEvent | undefined) => void + reject: (reason: any) => void + } + >() + private fetchEventTimer: NodeJS.Timeout | null = null + + // Event stats cache + private eventStatsCache = new LRUCache>({ + max: 10000, + ttl: 1000 * 60 * 10, // 10 minutes + fetchMethod: async (id) => this._fetchEventStatsById(id) + }) + + // Profile cache + private profilesCache = new LRUCache>({ + max: 10000, + ttl: 1000 * 60 * 10 // 10 minutes + }) + private fetchProfileQueue = new Map< + string, + { + resolve: (value: NEvent | undefined) => void + reject: (reason: any) => void + } + >() + private fetchProfileTimer: NodeJS.Timeout | null = null + + constructor() { + if (!ClientService.instance) { + this.initPromise = this.init() + ClientService.instance = this + } + return ClientService.instance + } + + async init() { + const relayGroups = await storage.getRelayGroups() + this.relayUrls = relayGroups.find((group) => group.isActive)?.relayUrls ?? [] + eventBus.on(EVENT_TYPES.RELAY_GROUPS_CHANGED, (event) => { + this.onRelayGroupsChange(event.detail) + }) + } + + onRelayGroupsChange(relayGroups: TRelayGroup[]) { + const newRelayUrls = relayGroups.find((group) => group.isActive)?.relayUrls ?? [] + if ( + newRelayUrls.length === this.relayUrls.length && + newRelayUrls.every((url) => this.relayUrls.includes(url)) + ) { + return + } + this.relayUrls = newRelayUrls + } + + listConnectionStatus() { + return this.pool.listConnectionStatus() + } + + async fetchEvents(filters: Filter[]) { + await this.initPromise + return new Promise((resolve) => { + const events: NEvent[] = [] + this.pool.subscribeManyEose(this.relayUrls, filters, { + onevent(event) { + events.push(event) + }, + onclose() { + resolve(events) + } + }) + }) + } + + async fetchEventWithCache(filter: Filter) { + return this.cache.fetch(JSON.stringify(filter)) + } + + async fetchEvent(filter: Filter) { + const events = await this.fetchEvents([{ ...filter, limit: 1 }]) + return events.length ? events[0] : undefined + } + + async fetchEventById(id: string): Promise { + const cache = this.eventsCache.get(id) + if (cache) { + return cache + } + + const promise = new Promise((resolve, reject) => { + this.fetchEventQueue.set(id, { resolve, reject }) + if (this.fetchEventTimer) { + return + } + + this.fetchEventTimer = setTimeout(async () => { + this.fetchEventTimer = null + const queue = new Map(this.fetchEventQueue) + this.fetchEventQueue.clear() + + try { + const ids = Array.from(queue.keys()) + const events = await this.fetchEvents([{ ids, limit: ids.length }]) + for (const event of events) { + queue.get(event.id)?.resolve(event) + queue.delete(event.id) + } + + for (const [, job] of queue) { + job.resolve(undefined) + } + queue.clear() + } catch (err) { + for (const [id, job] of queue) { + this.eventsCache.delete(id) + job.reject(err) + } + } + }, 20) + }) + + this.eventsCache.set(id, promise) + return promise + } + + async fetchEventStatsById(id: string): Promise { + const stats = await this.eventStatsCache.fetch(id) + return stats ?? { reactionCount: 0, repostCount: 0 } + } + + private async _fetchEventStatsById(id: string) { + const [reactionEvents, repostEvents] = await Promise.all([ + this.fetchEvents([{ '#e': [id], kinds: [kinds.Reaction] }]), + this.fetchEvents([{ '#e': [id], kinds: [kinds.Repost] }]) + ]) + + return { reactionCount: reactionEvents.length, repostCount: repostEvents.length } + } + + async fetchProfile(pubkey: string): Promise { + const cache = this.profilesCache.get(pubkey) + if (cache) { + return cache + } + + const promise = new Promise((resolve, reject) => { + this.fetchProfileQueue.set(pubkey, { resolve, reject }) + if (this.fetchProfileTimer) { + return + } + + this.fetchProfileTimer = setTimeout(async () => { + this.fetchProfileTimer = null + const queue = new Map(this.fetchProfileQueue) + this.fetchProfileQueue.clear() + + try { + const pubkeys = Array.from(queue.keys()) + const events = await this.fetchEvents([ + { + authors: pubkeys, + kinds: [0], + limit: pubkeys.length + } + ]) + const eventsMap = new Map() + for (const event of events) { + const pubkey = event.pubkey + const existing = eventsMap.get(pubkey) + if (!existing || existing.created_at < event.created_at) { + eventsMap.set(pubkey, event) + } + } + + for (const [pubkey, job] of queue) { + const event = eventsMap.get(pubkey) + if (event) { + job.resolve(event) + } else { + job.resolve(undefined) + } + queue.delete(pubkey) + } + } catch (err) { + for (const [pubkey, job] of queue) { + this.profilesCache.delete(pubkey) + job.reject(err) + } + } + }, 20) + }) + + this.profilesCache.set(pubkey, promise) + return promise + } +} + +const instance = new ClientService() + +export default instance diff --git a/src/renderer/src/services/event-bus.service.ts b/src/renderer/src/services/event-bus.service.ts new file mode 100644 index 0000000..31d0f50 --- /dev/null +++ b/src/renderer/src/services/event-bus.service.ts @@ -0,0 +1,43 @@ +import { TRelayGroup } from '@common/types' + +export const EVENT_TYPES = { + RELAY_GROUPS_CHANGED: 'relay-groups-changed', + RELOAD_TIMELINE: 'reload-timeline', + REPLY_COUNT_CHANGED: 'reply-count-changed' +} as const + +type TEventMap = { + [EVENT_TYPES.RELAY_GROUPS_CHANGED]: TRelayGroup[] + [EVENT_TYPES.RELOAD_TIMELINE]: unknown + [EVENT_TYPES.REPLY_COUNT_CHANGED]: { eventId: string; replyCount: number } +} + +type TCustomEventMap = { + [K in keyof TEventMap]: CustomEvent +} + +export const createRelayGroupsChangedEvent = (relayGroups: TRelayGroup[]) => { + return new CustomEvent(EVENT_TYPES.RELAY_GROUPS_CHANGED, { detail: relayGroups }) +} +export const createReloadTimelineEvent = () => { + return new CustomEvent(EVENT_TYPES.RELOAD_TIMELINE) +} +export const createReplyCountChangedEvent = (eventId: string, replyCount: number) => { + return new CustomEvent(EVENT_TYPES.REPLY_COUNT_CHANGED, { detail: { eventId, replyCount } }) +} + +class EventBus extends EventTarget { + emit(event: TCustomEventMap[K]): boolean { + return super.dispatchEvent(event) + } + + on(type: K, listener: (event: TCustomEventMap[K]) => void): void { + super.addEventListener(type, listener as EventListener) + } + + remove(type: K, listener: (event: TCustomEventMap[K]) => void): void { + super.removeEventListener(type, listener as EventListener) + } +} + +export const eventBus = new EventBus() diff --git a/src/renderer/src/services/storage.service.ts b/src/renderer/src/services/storage.service.ts new file mode 100644 index 0000000..2085ed9 --- /dev/null +++ b/src/renderer/src/services/storage.service.ts @@ -0,0 +1,26 @@ +import { TRelayGroup } from '@common/types' +import { createRelayGroupsChangedEvent, eventBus } from './event-bus.service' + +class StorageService { + static instance: StorageService + + constructor() { + if (!StorageService.instance) { + StorageService.instance = this + } + return StorageService.instance + } + + async getRelayGroups() { + return await window.api.storage.getRelayGroups() + } + + async setRelayGroups(relayGroups: TRelayGroup[]) { + await window.api.storage.setRelayGroups(relayGroups) + eventBus.emit(createRelayGroupsChangedEvent(relayGroups)) + } +} + +const instance = new StorageService() + +export default instance diff --git a/src/renderer/src/types.ts b/src/renderer/src/types.ts new file mode 100644 index 0000000..828b0e6 --- /dev/null +++ b/src/renderer/src/types.ts @@ -0,0 +1 @@ +export type TEventStats = { reactionCount: number; repostCount: number } diff --git a/tsconfig.json b/tsconfig.json index ec508a3..87c9d3b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,7 @@ "compilerOptions": { "paths": { "@renderer/*": ["./src/renderer/src/*"], + "@common/*": ["./src/common/*"], } }, "files": [], diff --git a/tsconfig.node.json b/tsconfig.node.json index db23a68..6b0be2c 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -1,8 +1,14 @@ { "extends": "@electron-toolkit/tsconfig/tsconfig.node.json", - "include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*"], + "include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*", "src/common/**/*"], "compilerOptions": { "composite": true, - "types": ["electron-vite/node"] + "types": ["electron-vite/node"], + "baseUrl": ".", + "paths": { + "@common/*": [ + "src/common/*" + ], + } } } diff --git a/tsconfig.web.json b/tsconfig.web.json index 9c16b66..2c1030d 100644 --- a/tsconfig.web.json +++ b/tsconfig.web.json @@ -4,7 +4,8 @@ "src/renderer/src/env.d.ts", "src/renderer/src/**/*", "src/renderer/src/**/*.tsx", - "src/preload/*.d.ts" + "src/preload/*.d.ts", + "src/common/**/*" ], "compilerOptions": { "composite": true, @@ -13,7 +14,10 @@ "paths": { "@renderer/*": [ "src/renderer/src/*" - ] + ], + "@common/*": [ + "src/common/*" + ], } } }