Compare commits
176 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ea3b353127 | |||
| 53e62cee80 | |||
|
|
4c28b4879c | ||
|
|
9753e6d6b4 | ||
|
|
3488f05960 | ||
|
|
c6674c4a2d | ||
|
|
55cd556cd6 | ||
| 0191180f31 | |||
| 60ed56b1b9 | |||
|
|
da722afed3 | ||
| d8eb51e49c | |||
| c700a45ab6 | |||
| b806a34edb | |||
| 21989e6fa5 | |||
| 0539c5649d | |||
| ad488ff72d | |||
| 02e0309a41 | |||
| b7f4af7883 | |||
| cc48a4f36b | |||
| 46ed3330fc | |||
| 1fa1872ca6 | |||
|
|
c389a23365 | ||
| eaf9bda077 | |||
| 84a248a5a9 | |||
|
|
711c1d561a | ||
| 21210b4336 | |||
| 3bd480b75e | |||
| 2b19650e46 | |||
| 23482531c5 | |||
| cfda9ba899 | |||
| 698bd78684 | |||
| b97676dd3e | |||
| 25ae4f2201 | |||
|
|
59435ccd13 | ||
|
|
e81912c5e9 | ||
| af1b4e60d3 | |||
| 648cbf6f80 | |||
| 7b06a82ee7 | |||
|
|
d18de93c60 | ||
| df15eb7a03 | |||
|
|
06674df6cc | ||
|
|
8295625a44 | ||
| b11e2a4291 | |||
| 353c18bb76 | |||
|
|
02b0c9e48a | ||
|
|
ff73c8ac88 | ||
|
|
bc48391a1a | ||
| b0a443c002 | |||
| bef1f136ad | |||
| 9ba584bf14 | |||
|
|
43509fc943 | ||
|
|
4a99eb94e2 | ||
| 74426e13c8 | |||
| bd45c36072 | |||
| c13aefcd15 | |||
| 167caee8bc | |||
| d527078d5c | |||
|
|
763ace5ddf | ||
| 057c57b70f | |||
| cb71786ac1 | |||
|
|
67afeac198 | ||
| f4ee25de8e | |||
| 445a218a9e | |||
| f09139ffbe | |||
| 446721729b | |||
|
|
e0250d7f5c | ||
| 9fcdac4edb | |||
| b726ae3c7c | |||
| a3460418f6 | |||
| f65175f11e | |||
| 16efd495a0 | |||
| ed6423e4aa | |||
| 0e9418949b | |||
| 240fe8bc7c | |||
| c3482cddd8 | |||
| d13e7b3ef6 | |||
| 47800bd2ff | |||
| c0305db5fc | |||
| 0b745cb40e | |||
| a20f5ca15d | |||
| c29b4e173e | |||
| 33dd8b1d8a | |||
| 1503d90bd5 | |||
| 6581ffb92b | |||
| 939dfd9cc1 | |||
| 7744a5e17c | |||
| 3301af5cbb | |||
| 3f1218e7bc | |||
| fbcb3ae6dc | |||
| e93aedb703 | |||
| dae4b1d52b | |||
| f908c46a19 | |||
| ab27bd5f44 | |||
| 72870bb131 | |||
| 1822eac488 | |||
| 0487b8a801 | |||
| 67c6177291 | |||
| ad6ae6745d | |||
| a9d10ff93b | |||
| e0d4c53098 | |||
| 2c8571ecc7 | |||
| a8cd34d998 | |||
| a5ad4fe05c | |||
| f2504071cd | |||
| 73f90ebaf9 | |||
| c172c0f80f | |||
| aa80301778 | |||
| c04ca3a1ab | |||
| 3eae38e1cb | |||
| 87099c6388 | |||
| 7554a35c31 | |||
| 70707f69c8 | |||
|
|
a98ffd4887 | ||
|
|
2e23b3ae06 | ||
| 8e8e6fe244 | |||
| 2726bfd595 | |||
| 542b6033c2 | |||
| fcde669685 | |||
| f4cbcee8b4 | |||
| ba13ac7535 | |||
| 9f27d68533 | |||
| 698f5a5d6d | |||
| 7856d6d49d | |||
| a52fb3c437 | |||
| 499765c10a | |||
| 56fab1dda6 | |||
| b1d2496f8e | |||
| ddbbcf41b5 | |||
| 55d6318614 | |||
| be333260f2 | |||
| e1edba8a78 | |||
| 4fc3cc8a80 | |||
| 893f3f7181 | |||
| 4103b509d4 | |||
| ed538c91c6 | |||
| b4dac2d477 | |||
| 3956ed622d | |||
| e1db873bd5 | |||
| 227c2ddefa | |||
| a6da07cd3f | |||
| 9591d8626d | |||
| ee4e6b1ee6 | |||
| a882ead649 | |||
| 0522611669 | |||
| 2536630ff7 | |||
| 4670778181 | |||
| a6ca2589ab | |||
| d9e8d05db7 | |||
| ec2ac2dce3 | |||
| 55298515af | |||
| 344bdc0c66 | |||
| ba88a4e0f2 | |||
| 17c64ee357 | |||
| ba93bdbb91 | |||
| 591373fd52 | |||
| 2fcc4dead1 | |||
| d9ab7893e0 | |||
|
|
a93ebd3861 | ||
| 7c4ec71089 | |||
|
|
e9d845cf25 | ||
| 8883be7ed6 | |||
| 132ea7f887 | |||
|
|
f9402f5c4f | ||
| 72a38e3aa7 | |||
| 38e82a4feb | |||
| 6440680898 | |||
| e507187044 | |||
|
|
feeb92b6ef | ||
|
|
2d4a77e8ed | ||
| 6f5ea1229d | |||
| 68886ad584 | |||
| 5f90bd0d22 | |||
| 8b434d577f | |||
| f2b1458bd2 | |||
| 7507cd9ba1 | |||
| 95124e5ded |
44
.dockerignore
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Dependencies
|
||||||
|
**/node_modules
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# Local env files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# Turbo
|
||||||
|
.turbo
|
||||||
|
|
||||||
|
# Vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# Build Outputs
|
||||||
|
**/.next/
|
||||||
|
**/out/
|
||||||
|
**/build
|
||||||
|
**/dist
|
||||||
|
**/target
|
||||||
|
|
||||||
|
|
||||||
|
# Debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# Unnecessary files
|
||||||
|
**/.git/
|
||||||
|
.github/
|
||||||
|
flatpak/*.xml
|
||||||
|
flatpak/*.desktop
|
||||||
|
flatpak/*.yml
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
/**/node_modules/*
|
|
||||||
node_modules/
|
|
||||||
dist/
|
|
||||||
49
.eslintrc.js
@@ -1,49 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
root: true,
|
|
||||||
parser: '@typescript-eslint/parser',
|
|
||||||
parserOptions: {
|
|
||||||
ecmaVersion: 2020,
|
|
||||||
sourceType: 'module',
|
|
||||||
ecmaFeatures: {
|
|
||||||
jsx: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
settings: {
|
|
||||||
react: {
|
|
||||||
version: 'detect',
|
|
||||||
},
|
|
||||||
'import/resolver': {
|
|
||||||
node: {
|
|
||||||
paths: ['src'],
|
|
||||||
extensions: ['.js', '.jsx', '.ts', '.tsx'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
env: {
|
|
||||||
browser: true,
|
|
||||||
amd: true,
|
|
||||||
node: true,
|
|
||||||
},
|
|
||||||
extends: [
|
|
||||||
'eslint:recommended',
|
|
||||||
'plugin:@typescript-eslint/recommended',
|
|
||||||
'plugin:react/recommended',
|
|
||||||
'plugin:jsx-a11y/recommended',
|
|
||||||
'prettier'
|
|
||||||
],
|
|
||||||
plugins: [],
|
|
||||||
rules: {
|
|
||||||
'react/react-in-jsx-scope': 'off',
|
|
||||||
'jsx-a11y/accessible-emoji': 'off',
|
|
||||||
'react/prop-types': 'off',
|
|
||||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
|
||||||
'jsx-a11y/anchor-is-valid': [
|
|
||||||
'error',
|
|
||||||
{
|
|
||||||
components: ['Link'],
|
|
||||||
specialLink: ['hrefLeft', 'hrefRight'],
|
|
||||||
aspects: ['invalidHref', 'preferButton'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
72
.github/workflows/flatpak-bundle.yml
vendored
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
name: Flatpak
|
||||||
|
on: workflow_dispatch
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
prepare-repo:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: 'recursive'
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
- name: cache of container
|
||||||
|
id: cache-container
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: prepare-dist
|
||||||
|
key: ${{ runner.os }}-container-${{ hashFiles('prepare-dist') }}
|
||||||
|
- name: Run latest-tag
|
||||||
|
id: latest-tag
|
||||||
|
uses: oprypin/find-latest-tag@v1
|
||||||
|
with:
|
||||||
|
repository:
|
||||||
|
lumehq/lume
|
||||||
|
#FIXME: lumehq after merged fix, now it just won't find tags
|
||||||
|
# repository: ${{ github.repository }}
|
||||||
|
|
||||||
|
- name: Build container
|
||||||
|
# if: steps.cache-container.outputs.cache-hit != 'true'
|
||||||
|
run: |
|
||||||
|
docker buildx build -t flatpak-prepare-lume --build-arg=${{steps.latest-tag.outputs.tag}} --rm --output=. --target=final -f flatpak/Containerfile .
|
||||||
|
- name: Copy flatpak files content
|
||||||
|
run: |
|
||||||
|
cp -r flatpak/*.xml flatpak/*.desktop flatpak/*.yml prepare-dist
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: repo-dist
|
||||||
|
path: prepare-dist
|
||||||
|
flatpak:
|
||||||
|
name: flatpak-bundle
|
||||||
|
needs: prepare-repo
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: bilelmoussaoui/flatpak-github-actions:gnome-45
|
||||||
|
options: --privileged
|
||||||
|
steps:
|
||||||
|
- uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: repo-dist
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: flathub/shared-modules
|
||||||
|
path: shared-modules
|
||||||
|
- uses: flatpak/flatpak-github-actions/flatpak-builder@v6
|
||||||
|
with:
|
||||||
|
bundle: lume.flatpak
|
||||||
|
manifest-path: nu.lume.Lume.yml
|
||||||
|
restore-cache: false
|
||||||
|
# cache-key: flatpak-builder-${{ github.sha }}
|
||||||
|
- name: Release
|
||||||
|
uses: softprops/action-gh-release@v1
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
with:
|
||||||
|
append_body: true
|
||||||
|
files: lume.flatpak
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- uses: geekyeggo/delete-artifact@v4
|
||||||
|
with:
|
||||||
|
name: repo-dist
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
4
.github/workflows/main.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
|||||||
- name: setup node
|
- name: setup node
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 18
|
node-version: 20
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
with:
|
||||||
targets: aarch64-apple-darwin
|
targets: aarch64-apple-darwin
|
||||||
@@ -67,4 +67,4 @@ jobs:
|
|||||||
releaseDraft: true
|
releaseDraft: true
|
||||||
prerelease: false
|
prerelease: false
|
||||||
args: ${{ matrix.settings.args }}
|
args: ${{ matrix.settings.args }}
|
||||||
includeDebug: true
|
includeDebug: false
|
||||||
|
|||||||
59
.gitignore
vendored
@@ -1,33 +1,36 @@
|
|||||||
# Logs
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
.pnp
|
||||||
dist-ssr
|
.pnp.js
|
||||||
out
|
|
||||||
*.local
|
|
||||||
.next
|
|
||||||
.vscode
|
|
||||||
*.db
|
|
||||||
*.db-journal
|
|
||||||
bun.lockb
|
|
||||||
|
|
||||||
.direnv
|
# Local env files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
# Editor directories and files
|
# Testing
|
||||||
.vscode/*
|
coverage/
|
||||||
!.vscode/extensions.json
|
|
||||||
.idea
|
# Turbo
|
||||||
|
.turbo/
|
||||||
|
|
||||||
|
# Vercel
|
||||||
|
.vercel/
|
||||||
|
|
||||||
|
# Build Outputs
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
|
||||||
|
# Debug
|
||||||
|
*.log*
|
||||||
|
|
||||||
|
# Misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.suo
|
*.pem
|
||||||
*.ntvs*
|
|
||||||
*.njsproj
|
|
||||||
*.sln
|
|
||||||
*.sw?
|
|
||||||
/.gtm/
|
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
. "$(dirname -- "$0")/_/husky.sh"
|
|
||||||
|
|
||||||
pnpm exec lint-staged
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
.tmp
|
|
||||||
.cache/
|
|
||||||
coverage/
|
|
||||||
.nyc_output/
|
|
||||||
**/.yarn/**
|
|
||||||
**/.pnp.*
|
|
||||||
/dist*/
|
|
||||||
node_modules/
|
|
||||||
src-tauri/
|
|
||||||
21
.prettierrc
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"semi": true,
|
|
||||||
"trailingComma": "es5",
|
|
||||||
"singleQuote": true,
|
|
||||||
"tabWidth": 2,
|
|
||||||
"printWidth": 90,
|
|
||||||
"useTabs": false,
|
|
||||||
"bracketSpacing": true,
|
|
||||||
"bracketSameLine": false,
|
|
||||||
"importOrder": [
|
|
||||||
"^@app/(.*)$",
|
|
||||||
"^@libs/(.*)$",
|
|
||||||
"^@shared/(.*)$",
|
|
||||||
"^@utils/(.*)$",
|
|
||||||
"^[./]"
|
|
||||||
],
|
|
||||||
"importOrderSeparation": true,
|
|
||||||
"importOrderSortSpecifiers": true,
|
|
||||||
"plugins": ["@trivago/prettier-plugin-sort-imports", "prettier-plugin-tailwindcss"],
|
|
||||||
"pluginSearchDirs": false
|
|
||||||
}
|
|
||||||
14
README.md
@@ -4,7 +4,7 @@ Lume is a nostr client
|
|||||||
|
|
||||||
### Usage
|
### Usage
|
||||||
|
|
||||||
Download Lume for your platform here: [https://github.com/luminous-devs/lume/releases](https://github.com/luminous-devs/lume/releases)
|
Download Lume for your platform here: [https://github.com/lumehq/lume/releases](https://github.com/lumehq/lume/releases)
|
||||||
|
|
||||||
Supported platform: macOS, Windows and Linux
|
Supported platform: macOS, Windows and Linux
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ Supported platform: macOS, Windows and Linux
|
|||||||
Clone project
|
Clone project
|
||||||
|
|
||||||
```
|
```
|
||||||
git clone https://github.com/luminous-devs/lume.git && cd lume
|
git clone https://github.com/lumehq/lume.git && cd lume
|
||||||
```
|
```
|
||||||
|
|
||||||
Install packages
|
Install packages
|
||||||
@@ -48,3 +48,13 @@ Requirements:
|
|||||||
1. [Setup `direnv`](https://zero-to-flakes.com/direnv)
|
1. [Setup `direnv`](https://zero-to-flakes.com/direnv)
|
||||||
|
|
||||||
`cd` into the root folder of the project to enter `nix develop` shell. Run `direnv allow` (only once). Then run `pnpm` or `bun` (experimental) commands as described above.
|
`cd` into the root folder of the project to enter `nix develop` shell. Run `direnv allow` (only once). Then run `pnpm` or `bun` (experimental) commands as described above.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Copyright (C) 2023-2024 Ren Amamiya & other Lume contributors (see AUTHORS.md)
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License along with this program. If not, see https://www.gnu.org/licenses/.
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
<title>Lume</title>
|
<title>Lume</title>
|
||||||
</head>
|
</head>
|
||||||
<body
|
<body
|
||||||
class="relative h-screen w-screen cursor-default select-none overflow-hidden font-sans text-neutral-950 antialiased dark:text-neutral-50"
|
class="relative w-screen h-screen overflow-hidden font-sans antialiased cursor-default select-none text-neutral-950 dark:text-neutral-50"
|
||||||
>
|
>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
<script type="module" src="./src/main.jsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
77
apps/desktop/package.json
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
{
|
||||||
|
"name": "lume",
|
||||||
|
"private": true,
|
||||||
|
"version": "3.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@columns/antenas": "workspace:^",
|
||||||
|
"@columns/default": "workspace:^",
|
||||||
|
"@columns/foryou": "workspace:^",
|
||||||
|
"@columns/global": "workspace:^",
|
||||||
|
"@columns/group": "workspace:^",
|
||||||
|
"@columns/hashtag": "workspace:^",
|
||||||
|
"@columns/thread": "workspace:^",
|
||||||
|
"@columns/timeline": "workspace:^",
|
||||||
|
"@columns/trending-notes": "workspace:^",
|
||||||
|
"@columns/user": "workspace:^",
|
||||||
|
"@columns/waifu": "workspace:^",
|
||||||
|
"@getalby/sdk": "^3.2.3",
|
||||||
|
"@lume/ark": "workspace:^",
|
||||||
|
"@lume/icons": "workspace:^",
|
||||||
|
"@lume/storage": "workspace:^",
|
||||||
|
"@lume/ui": "workspace:^",
|
||||||
|
"@lume/utils": "workspace:^",
|
||||||
|
"@nostr-dev-kit/ndk": "^2.3.3",
|
||||||
|
"@radix-ui/react-accordion": "^1.1.2",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.0.5",
|
||||||
|
"@radix-ui/react-avatar": "^1.0.4",
|
||||||
|
"@radix-ui/react-checkbox": "^1.0.4",
|
||||||
|
"@radix-ui/react-collapsible": "^1.0.3",
|
||||||
|
"@radix-ui/react-dialog": "^1.0.5",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||||
|
"@radix-ui/react-hover-card": "^1.0.7",
|
||||||
|
"@radix-ui/react-popover": "^1.0.7",
|
||||||
|
"@radix-ui/react-select": "^2.0.0",
|
||||||
|
"@radix-ui/react-switch": "^1.0.3",
|
||||||
|
"@radix-ui/react-tooltip": "^1.0.7",
|
||||||
|
"@tanstack/react-query": "^5.17.19",
|
||||||
|
"framer-motion": "^11.0.3",
|
||||||
|
"i18next": "^23.8.1",
|
||||||
|
"i18next-resources-to-backend": "^1.2.0",
|
||||||
|
"jotai": "^2.6.3",
|
||||||
|
"minidenticons": "^4.2.0",
|
||||||
|
"nanoid": "^5.0.4",
|
||||||
|
"nostr-fetch": "^0.15.0",
|
||||||
|
"nostr-tools": "^1.17.0",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-currency-input-field": "^3.6.14",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-hook-form": "^7.49.3",
|
||||||
|
"react-i18next": "^14.0.1",
|
||||||
|
"react-router-dom": "^6.21.3",
|
||||||
|
"smol-toml": "^1.1.4",
|
||||||
|
"sonner": "^1.4.0",
|
||||||
|
"virtua": "^0.23.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@lume/tailwindcss": "workspace:^",
|
||||||
|
"@lume/tsconfig": "workspace:^",
|
||||||
|
"@lume/types": "workspace:^",
|
||||||
|
"@types/node": "^20.11.10",
|
||||||
|
"@types/react": "^18.2.48",
|
||||||
|
"@types/react-dom": "^18.2.18",
|
||||||
|
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||||
|
"autoprefixer": "^10.4.17",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
|
"encoding": "^0.1.13",
|
||||||
|
"postcss": "^8.4.33",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"typescript": "^5.3.3",
|
||||||
|
"vite": "^5.0.12",
|
||||||
|
"vite-plugin-top-level-await": "^1.4.1",
|
||||||
|
"vite-tsconfig-paths": "^4.3.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 398 KiB After Width: | Height: | Size: 398 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
BIN
apps/desktop/public/columns/antenas.jpg
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
apps/desktop/public/columns/antenas@2x.jpg
Normal file
|
After Width: | Height: | Size: 428 KiB |
BIN
apps/desktop/public/columns/global.jpg
Normal file
|
After Width: | Height: | Size: 119 KiB |
BIN
apps/desktop/public/columns/global@2x.jpg
Normal file
|
After Width: | Height: | Size: 448 KiB |
BIN
apps/desktop/public/columns/group.jpg
Normal file
|
After Width: | Height: | Size: 111 KiB |
BIN
apps/desktop/public/columns/group@2x.jpg
Normal file
|
After Width: | Height: | Size: 416 KiB |
BIN
apps/desktop/public/columns/trending-notes.jpg
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
apps/desktop/public/columns/trending-notes@2x.jpg
Normal file
|
After Width: | Height: | Size: 381 KiB |
BIN
apps/desktop/public/columns/waifu.jpg
Normal file
|
After Width: | Height: | Size: 121 KiB |
BIN
apps/desktop/public/columns/waifu@2x.jpg
Normal file
|
After Width: | Height: | Size: 457 KiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 986 KiB After Width: | Height: | Size: 986 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB |
BIN
apps/desktop/public/heading.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
apps/desktop/public/heading@2x.png
Normal file
|
After Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
BIN
apps/desktop/public/translate.jpg
Normal file
|
After Width: | Height: | Size: 473 KiB |
BIN
apps/desktop/public/translate@2x.jpg
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
apps/desktop/public/tutorial-1.gif
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
apps/desktop/public/tutorial-2.gif
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
apps/desktop/public/tutorial-3.gif
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
|
Before Width: | Height: | Size: 341 KiB After Width: | Height: | Size: 341 KiB |
48
apps/desktop/src/app.css
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.break-p {
|
||||||
|
word-break: break-word;
|
||||||
|
word-wrap: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :where(iframe):not(:where([class~='not-prose'] *)) {
|
||||||
|
@apply w-full h-auto mx-auto aspect-video;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shadow-toolbar {
|
||||||
|
box-shadow: 0 0 #0000, 0 0 #0000, 0 8px 24px 0 rgba(0, 0, 0, .2), 0 2px 8px 0 rgba(0, 0, 0, .08), inset 0 0 0 1px rgba(0, 0, 0, .2), inset 0 0 0 2px hsla(0, 0%, 100%, .14)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
@apply cursor-default no-underline !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
@apply cursor-default focus:outline-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input::-ms-reveal,
|
||||||
|
input::-ms-clear {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-input-placeholder {
|
||||||
|
line-height: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border {
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
media-controller {
|
||||||
|
@apply w-full;
|
||||||
|
}
|
||||||
32
apps/desktop/src/app.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { ColumnProvider, LumeProvider } from "@lume/ark";
|
||||||
|
import { StorageProvider } from "@lume/storage";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { I18nextProvider } from "react-i18next";
|
||||||
|
import { Toaster } from "sonner";
|
||||||
|
import i18n from "./i18n";
|
||||||
|
import Router from "./router";
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000), // 10 seconds
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<I18nextProvider i18n={i18n} defaultNS={"translation"}>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Toaster position="top-center" theme="system" closeButton />
|
||||||
|
<StorageProvider>
|
||||||
|
<LumeProvider>
|
||||||
|
<ColumnProvider>
|
||||||
|
<Router />
|
||||||
|
</ColumnProvider>
|
||||||
|
</LumeProvider>
|
||||||
|
</StorageProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</I18nextProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
apps/desktop/src/i18n.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { resolveResource } from "@tauri-apps/api/path";
|
||||||
|
import { readTextFile } from "@tauri-apps/plugin-fs";
|
||||||
|
import { locale } from "@tauri-apps/plugin-os";
|
||||||
|
import i18n from "i18next";
|
||||||
|
import resourcesToBackend from "i18next-resources-to-backend";
|
||||||
|
import { initReactI18next } from "react-i18next";
|
||||||
|
|
||||||
|
const currentLocale = (await locale()).slice(0, 2);
|
||||||
|
|
||||||
|
i18n
|
||||||
|
.use(
|
||||||
|
resourcesToBackend(async (language: string) => {
|
||||||
|
const file_path = await resolveResource(`locales/${language}.json`);
|
||||||
|
return JSON.parse(await readTextFile(file_path));
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.use(initReactI18next)
|
||||||
|
.init({
|
||||||
|
lng: currentLocale,
|
||||||
|
fallbackLng: "en",
|
||||||
|
interpolation: {
|
||||||
|
escapeValue: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default i18n;
|
||||||
8
apps/desktop/src/main.jsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import App from "./app";
|
||||||
|
import "./app.css";
|
||||||
|
|
||||||
|
const container = document.getElementById("root");
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
root.render(<App />);
|
||||||
285
apps/desktop/src/router.tsx
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
import { useArk } from "@lume/ark";
|
||||||
|
import { LoaderIcon } from "@lume/icons";
|
||||||
|
import { useStorage } from "@lume/storage";
|
||||||
|
import { AppLayout, AuthLayout, HomeLayout, SettingsLayout } from "@lume/ui";
|
||||||
|
import { fetch } from "@tauri-apps/plugin-http";
|
||||||
|
import {
|
||||||
|
RouterProvider,
|
||||||
|
createBrowserRouter,
|
||||||
|
defer,
|
||||||
|
redirect,
|
||||||
|
} from "react-router-dom";
|
||||||
|
import { ErrorScreen } from "./routes/error";
|
||||||
|
|
||||||
|
export default function Router() {
|
||||||
|
const ark = useArk();
|
||||||
|
const storage = useStorage();
|
||||||
|
|
||||||
|
const router = createBrowserRouter([
|
||||||
|
{
|
||||||
|
element: <AppLayout platform={storage.platform} />,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
element: <HomeLayout />,
|
||||||
|
errorElement: <ErrorScreen />,
|
||||||
|
loader: async () => {
|
||||||
|
if (!ark.account) return redirect("auth");
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
index: true,
|
||||||
|
async lazy() {
|
||||||
|
const { HomeScreen } = await import("./routes/home");
|
||||||
|
return { Component: HomeScreen };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "settings",
|
||||||
|
element: <SettingsLayout />,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
index: true,
|
||||||
|
async lazy() {
|
||||||
|
const { GeneralSettingScreen } = await import(
|
||||||
|
"./routes/settings/general"
|
||||||
|
);
|
||||||
|
return { Component: GeneralSettingScreen };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "profile",
|
||||||
|
async lazy() {
|
||||||
|
const { ProfileSettingScreen } = await import(
|
||||||
|
"./routes/settings/profile"
|
||||||
|
);
|
||||||
|
return { Component: ProfileSettingScreen };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "backup",
|
||||||
|
async lazy() {
|
||||||
|
const { BackupSettingScreen } = await import(
|
||||||
|
"./routes/settings/backup"
|
||||||
|
);
|
||||||
|
return { Component: BackupSettingScreen };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "advanced",
|
||||||
|
async lazy() {
|
||||||
|
const { AdvancedSettingScreen } = await import(
|
||||||
|
"./routes/settings/advanced"
|
||||||
|
);
|
||||||
|
return { Component: AdvancedSettingScreen };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "nwc",
|
||||||
|
async lazy() {
|
||||||
|
const { NWCScreen } = await import("./routes/settings/nwc");
|
||||||
|
return { Component: NWCScreen };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "about",
|
||||||
|
async lazy() {
|
||||||
|
const { AboutScreen } = await import("./routes/settings/about");
|
||||||
|
return { Component: AboutScreen };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "activity",
|
||||||
|
async lazy() {
|
||||||
|
const { ActivityScreen } = await import("./routes/activty");
|
||||||
|
return { Component: ActivityScreen };
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: ":id",
|
||||||
|
async lazy() {
|
||||||
|
const { ActivityIdScreen } = await import(
|
||||||
|
"./routes/activty/id"
|
||||||
|
);
|
||||||
|
return { Component: ActivityIdScreen };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "relays",
|
||||||
|
async lazy() {
|
||||||
|
const { RelaysScreen } = await import("./routes/relays");
|
||||||
|
return { Component: RelaysScreen };
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
index: true,
|
||||||
|
async lazy() {
|
||||||
|
const { RelayGlobalScreen } = await import(
|
||||||
|
"./routes/relays/global"
|
||||||
|
);
|
||||||
|
return { Component: RelayGlobalScreen };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "follows",
|
||||||
|
async lazy() {
|
||||||
|
const { RelayFollowsScreen } = await import(
|
||||||
|
"./routes/relays/follows"
|
||||||
|
);
|
||||||
|
return { Component: RelayFollowsScreen };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ":url",
|
||||||
|
loader: async ({ request, params }) => {
|
||||||
|
return defer({
|
||||||
|
relay: fetch(`https://${params.url}`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/nostr+json",
|
||||||
|
},
|
||||||
|
signal: request.signal,
|
||||||
|
}).then((res) => res.json()),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async lazy() {
|
||||||
|
const { RelayUrlScreen } = await import("./routes/relays/url");
|
||||||
|
return { Component: RelayUrlScreen };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "depot",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
index: true,
|
||||||
|
loader: () => {
|
||||||
|
const depot = storage.checkDepot();
|
||||||
|
if (!depot) return redirect("/depot/onboarding/");
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
async lazy() {
|
||||||
|
const { DepotScreen } = await import("./routes/depot");
|
||||||
|
return { Component: DepotScreen };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "onboarding",
|
||||||
|
async lazy() {
|
||||||
|
const { DepotOnboardingScreen } = await import(
|
||||||
|
"./routes/depot/onboarding"
|
||||||
|
);
|
||||||
|
return { Component: DepotOnboardingScreen };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "auth",
|
||||||
|
element: <AuthLayout platform={storage.platform} />,
|
||||||
|
errorElement: <ErrorScreen />,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
index: true,
|
||||||
|
async lazy() {
|
||||||
|
const { WelcomeScreen } = await import("./routes/auth/welcome");
|
||||||
|
return { Component: WelcomeScreen };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "create",
|
||||||
|
async lazy() {
|
||||||
|
const { CreateAccountScreen } = await import(
|
||||||
|
"./routes/auth/create"
|
||||||
|
);
|
||||||
|
return { Component: CreateAccountScreen };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "create-keys",
|
||||||
|
async lazy() {
|
||||||
|
const { CreateAccountKeys } = await import(
|
||||||
|
"./routes/auth/create-keys"
|
||||||
|
);
|
||||||
|
return { Component: CreateAccountKeys };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "create-address",
|
||||||
|
loader: async () => {
|
||||||
|
return await ark.getOAuthServices();
|
||||||
|
},
|
||||||
|
async lazy() {
|
||||||
|
const { CreateAccountAddress } = await import(
|
||||||
|
"./routes/auth/create-address"
|
||||||
|
);
|
||||||
|
return { Component: CreateAccountAddress };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "login",
|
||||||
|
async lazy() {
|
||||||
|
const { LoginScreen } = await import("./routes/auth/login");
|
||||||
|
return { Component: LoginScreen };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "login-key",
|
||||||
|
async lazy() {
|
||||||
|
const { LoginWithKey } = await import("./routes/auth/login-key");
|
||||||
|
return { Component: LoginWithKey };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "login-nsecbunker",
|
||||||
|
async lazy() {
|
||||||
|
const { LoginWithNsecbunker } = await import(
|
||||||
|
"./routes/auth/login-nsecbunker"
|
||||||
|
);
|
||||||
|
return { Component: LoginWithNsecbunker };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "login-oauth",
|
||||||
|
async lazy() {
|
||||||
|
const { LoginWithOAuth } = await import(
|
||||||
|
"./routes/auth/login-oauth"
|
||||||
|
);
|
||||||
|
return { Component: LoginWithOAuth };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "onboarding",
|
||||||
|
async lazy() {
|
||||||
|
const { OnboardingScreen } = await import(
|
||||||
|
"./routes/auth/onboarding"
|
||||||
|
);
|
||||||
|
return { Component: OnboardingScreen };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RouterProvider
|
||||||
|
router={router}
|
||||||
|
fallbackElement={
|
||||||
|
<div className="flex items-center justify-center w-full h-full">
|
||||||
|
<LoaderIcon className="w-6 h-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
future={{ v7_startTransition: true }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { User } from "@lume/ark";
|
||||||
|
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
export function ActivityRepost({ event }: { event: NDKEvent }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={`/activity/${event.id}`}
|
||||||
|
className="block px-5 py-4 border-b border-black/5 dark:border-white/5 hover:bg-black/10 dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<User.Provider pubkey={event.pubkey}>
|
||||||
|
<User.Root className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<User.Avatar className="size-8 rounded-lg shrink-0" />
|
||||||
|
<div className="inline-flex items-center gap-1.5">
|
||||||
|
<User.Name className="max-w-[8rem] font-semibold text-neutral-950 dark:text-neutral-50" />
|
||||||
|
<p className="shrink-0">{t("activity.repost")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<User.Time
|
||||||
|
time={event.created_at}
|
||||||
|
className="text-neutral-500 dark:text-neutral-400"
|
||||||
|
/>
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
apps/desktop/src/routes/activty/components/activityText.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { User } from "@lume/ark";
|
||||||
|
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
export function ActivityText({ event }: { event: NDKEvent }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={`/activity/${event.id}`}
|
||||||
|
className="block px-5 py-4 border-b border-black/5 dark:border-white/5 hover:bg-black/10 dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<User.Provider pubkey={event.pubkey}>
|
||||||
|
<User.Root className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<User.Avatar className="size-8 rounded-lg shrink-0" />
|
||||||
|
<div className="inline-flex items-center gap-1.5">
|
||||||
|
<User.Name className="max-w-[8rem] font-semibold text-neutral-950 dark:text-neutral-50" />
|
||||||
|
<p className="shrink-0">{t("activity.mention")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<User.Time
|
||||||
|
time={event.created_at}
|
||||||
|
className="text-neutral-500 dark:text-neutral-400"
|
||||||
|
/>
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
apps/desktop/src/routes/activty/components/activityZap.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { User } from "@lume/ark";
|
||||||
|
import { compactNumber } from "@lume/utils";
|
||||||
|
import { NDKEvent, zapInvoiceFromEvent } from "@nostr-dev-kit/ndk";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
export function ActivityZap({ event }: { event: NDKEvent }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const invoice = zapInvoiceFromEvent(event);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={`/activity/${event.id}`}
|
||||||
|
className="block px-5 py-4 border-b border-black/5 dark:border-white/5 hover:bg-black/10 dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<User.Provider pubkey={event.pubkey}>
|
||||||
|
<User.Root className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<User.Avatar className="size-8 rounded-lg shrink-0" />
|
||||||
|
<div className="inline-flex items-center gap-1.5">
|
||||||
|
<User.Name className="max-w-[8rem] font-semibold text-neutral-950 dark:text-neutral-50" />
|
||||||
|
<p className="shrink-0">
|
||||||
|
{t("activity.zap")} {compactNumber.format(invoice.amount)} sats
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<User.Time
|
||||||
|
time={event.created_at}
|
||||||
|
className="text-neutral-500 dark:text-neutral-400"
|
||||||
|
/>
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
apps/desktop/src/routes/activty/components/list.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { useArk } from "@lume/ark";
|
||||||
|
import { ArrowRightCircleIcon, LoaderIcon } from "@lume/icons";
|
||||||
|
import { FETCH_LIMIT } from "@lume/utils";
|
||||||
|
import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk";
|
||||||
|
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ActivityRepost } from "./activityRepost";
|
||||||
|
import { ActivityText } from "./activityText";
|
||||||
|
import { ActivityZap } from "./activityZap";
|
||||||
|
|
||||||
|
export function ActivityList() {
|
||||||
|
const ark = useArk();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data, hasNextPage, isLoading, isFetchingNextPage, fetchNextPage } =
|
||||||
|
useInfiniteQuery({
|
||||||
|
queryKey: ["activity"],
|
||||||
|
initialPageParam: 0,
|
||||||
|
queryFn: async ({
|
||||||
|
signal,
|
||||||
|
pageParam,
|
||||||
|
}: {
|
||||||
|
signal: AbortSignal;
|
||||||
|
pageParam: number;
|
||||||
|
}) => {
|
||||||
|
const events = await ark.getInfiniteEvents({
|
||||||
|
filter: {
|
||||||
|
kinds: [NDKKind.Text, NDKKind.Repost, NDKKind.Zap],
|
||||||
|
"#p": [ark.account.pubkey],
|
||||||
|
},
|
||||||
|
limit: FETCH_LIMIT,
|
||||||
|
pageParam,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
return events;
|
||||||
|
},
|
||||||
|
getNextPageParam: (lastPage) => {
|
||||||
|
const lastEvent = lastPage.at(-1);
|
||||||
|
if (!lastEvent) return;
|
||||||
|
return lastEvent.created_at - 1;
|
||||||
|
},
|
||||||
|
initialData: () => {
|
||||||
|
const queryCacheData = queryClient.getQueryState(["activity"])
|
||||||
|
?.data as NDKEvent[];
|
||||||
|
if (queryCacheData) {
|
||||||
|
return {
|
||||||
|
pageParams: [undefined, 1],
|
||||||
|
pages: [queryCacheData],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
staleTime: 360 * 1000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnMount: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const allEvents = useMemo(
|
||||||
|
() => (data ? data.pages.flatMap((page) => page) : []),
|
||||||
|
[data],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderEvenKind = useCallback(
|
||||||
|
(event: NDKEvent) => {
|
||||||
|
if (event.pubkey === ark.account.pubkey) return null;
|
||||||
|
switch (event.kind) {
|
||||||
|
case NDKKind.Text:
|
||||||
|
return <ActivityText key={event.id} event={event} />;
|
||||||
|
case NDKKind.Repost:
|
||||||
|
return <ActivityRepost key={event.id} event={event} />;
|
||||||
|
case NDKKind.Zap:
|
||||||
|
return <ActivityZap key={event.id} event={event} />;
|
||||||
|
default:
|
||||||
|
return <ActivityText key={event.id} event={event} />;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[data],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="w-full h-full flex flex-col items-center justify-center">
|
||||||
|
<LoaderIcon className="size-5 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : !allEvents.length ? (
|
||||||
|
<div className="w-full h-full flex flex-col items-center justify-center">
|
||||||
|
<p className="mb-2 text-2xl">🎉</p>
|
||||||
|
<p className="text-center font-medium">{t("activity.empty")}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
allEvents.map((event) => renderEvenKind(event))
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-center h-16 px-5">
|
||||||
|
{hasNextPage ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fetchNextPage()}
|
||||||
|
disabled={!hasNextPage || isFetchingNextPage}
|
||||||
|
className="inline-flex items-center justify-center w-full h-12 gap-2 font-medium bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20 rounded-xl focus:outline-none"
|
||||||
|
>
|
||||||
|
{isFetchingNextPage ? (
|
||||||
|
<LoaderIcon className="size-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ArrowRightCircleIcon className="size-5" />
|
||||||
|
{t("global.loadMore")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
apps/desktop/src/routes/activty/components/rootNote.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { Note, useEvent } from "@lume/ark";
|
||||||
|
|
||||||
|
export function ActivityRootNote({ eventId }: { eventId: string }) {
|
||||||
|
const { isLoading, isError, data } = useEvent(eventId);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="relative flex gap-3">
|
||||||
|
<div className="relative flex-1 rounded-md bg-neutral-200 px-2 py-2 dark:bg-neutral-800">
|
||||||
|
<div className="h-4 w-full animate-pulse bg-neutral-300 dark:bg-neutral-700" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div className="relative flex gap-3">
|
||||||
|
<div className="relative flex-1 rounded-md bg-neutral-200 px-2 py-2 dark:bg-neutral-800">
|
||||||
|
Failed to fetch event
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Note.Provider event={data}>
|
||||||
|
<Note.Root>
|
||||||
|
<div className="flex items-center justify-between px-3 h-14">
|
||||||
|
<Note.User className="flex-1 pr-1" />
|
||||||
|
</div>
|
||||||
|
<Note.Content className="min-w-0 px-3" />
|
||||||
|
<div className="flex items-center justify-between px-3 h-14">
|
||||||
|
<Note.Pin />
|
||||||
|
<div className="inline-flex items-center gap-10" />
|
||||||
|
</div>
|
||||||
|
</Note.Root>
|
||||||
|
</Note.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
apps/desktop/src/routes/activty/components/singleRepost.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { User } from "@lume/ark";
|
||||||
|
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ActivityRootNote } from "./rootNote";
|
||||||
|
|
||||||
|
export function ActivitySingleRepost({ event }: { event: NDKEvent }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const repostId = event.tags.find((el) => el[0] === "e")[1];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pb-3 flex flex-col">
|
||||||
|
<div className="h-14 shrink-0 border-b border-neutral-100 dark:border-neutral-900 flex flex-col items-center justify-center px-3">
|
||||||
|
<h3 className="text-center font-semibold leading-tight">
|
||||||
|
{t("activity.boost")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-blue-500 font-medium leading-tight">
|
||||||
|
{t("activity.boostSubtitle")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
|
<div className="max-w-xl mx-auto py-6 flex flex-col items-center gap-6">
|
||||||
|
<User.Provider pubkey={event.pubkey}>
|
||||||
|
<User.Root>
|
||||||
|
<User.Avatar className="size-10 shrink-0 rounded-lg object-cover" />
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<div className="h-4 w-px bg-blue-500" />
|
||||||
|
<h3 className="font-semibold capitalize">{t("activity.repost")}</h3>
|
||||||
|
<div className="h-4 w-px bg-blue-500" />
|
||||||
|
</div>
|
||||||
|
<ActivityRootNote eventId={repostId} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
apps/desktop/src/routes/activty/components/singleText.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { Note, useArk } from "@lume/ark";
|
||||||
|
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ActivityRootNote } from "./rootNote";
|
||||||
|
|
||||||
|
export function ActivitySingleText({ event }: { event: NDKEvent }) {
|
||||||
|
const ark = useArk();
|
||||||
|
const thread = ark.getEventThread({
|
||||||
|
content: event.content,
|
||||||
|
tags: event.tags,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full flex flex-col justify-between">
|
||||||
|
<div className="h-14 border-b border-neutral-100 dark:border-neutral-900 flex flex-col items-center justify-center px-3">
|
||||||
|
<h3 className="text-center font-semibold leading-tight">
|
||||||
|
{t("activity.conversation")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-blue-500 font-medium leading-tight">
|
||||||
|
{t("activity.conversationSubtitle")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-y-auto">
|
||||||
|
<div className="max-w-xl mx-auto py-6">
|
||||||
|
{thread ? (
|
||||||
|
<div className="flex flex-col gap-3 mb-1">
|
||||||
|
{thread.rootEventId ? (
|
||||||
|
<ActivityRootNote eventId={thread.rootEventId} />
|
||||||
|
) : null}
|
||||||
|
{thread.replyEventId ? (
|
||||||
|
<ActivityRootNote eventId={thread.replyEventId} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="mt-3 flex flex-col gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<p className="text-teal-500 font-medium">
|
||||||
|
{t("activity.newReply")}
|
||||||
|
</p>
|
||||||
|
<div className="flex-1 h-px bg-teal-300" />
|
||||||
|
<div className="w-4 shrink-0 h-px bg-teal-300" />
|
||||||
|
</div>
|
||||||
|
<Note.Provider event={event}>
|
||||||
|
<Note.Root>
|
||||||
|
<div className="flex items-center justify-between px-3 h-14">
|
||||||
|
<Note.User className="flex-1 pr-1" />
|
||||||
|
</div>
|
||||||
|
<Note.Content className="min-w-0 px-3" />
|
||||||
|
<div className="flex items-center justify-between px-3 h-14">
|
||||||
|
<Note.Pin />
|
||||||
|
<div className="inline-flex items-center gap-10" />
|
||||||
|
</div>
|
||||||
|
</Note.Root>
|
||||||
|
</Note.Provider>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
apps/desktop/src/routes/activty/components/singleZap.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { User } from "@lume/ark";
|
||||||
|
import { compactNumber } from "@lume/utils";
|
||||||
|
import { NDKEvent, zapInvoiceFromEvent } from "@nostr-dev-kit/ndk";
|
||||||
|
import { ActivityRootNote } from "./rootNote";
|
||||||
|
|
||||||
|
export function ActivitySingleZap({ event }: { event: NDKEvent }) {
|
||||||
|
const zapEventId = event.tags.find((el) => el[0] === "e")[1];
|
||||||
|
const invoice = zapInvoiceFromEvent(event);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full flex flex-col justify-between">
|
||||||
|
<div className="h-14 border-b border-neutral-100 dark:border-neutral-900 flex flex-col items-center justify-center px-3">
|
||||||
|
<h3 className="text-center font-semibold leading-tight">
|
||||||
|
Conversation
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-blue-500 font-medium leading-tight">
|
||||||
|
@ Someone has replied to your note
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
|
<div className="max-w-xl mx-auto py-6 flex flex-col items-center gap-6">
|
||||||
|
<User.Provider pubkey={event.pubkey}>
|
||||||
|
<User.Root>
|
||||||
|
<User.Avatar className="size-10 shrink-0 rounded-lg object-cover" />
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<div className="h-4 w-px bg-blue-500" />
|
||||||
|
<h3 className="font-semibold">
|
||||||
|
Zap you {compactNumber.format(invoice.amount)} sats for
|
||||||
|
</h3>
|
||||||
|
<div className="h-4 w-px bg-blue-500" />
|
||||||
|
</div>
|
||||||
|
<ActivityRootNote eventId={zapEventId} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
apps/desktop/src/routes/activty/id.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { useEvent } from "@lume/ark";
|
||||||
|
import { LoaderIcon } from "@lume/icons";
|
||||||
|
import { NDKKind } from "@nostr-dev-kit/ndk";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { ActivitySingleRepost } from "./components/singleRepost";
|
||||||
|
import { ActivitySingleText } from "./components/singleText";
|
||||||
|
import { ActivitySingleZap } from "./components/singleZap";
|
||||||
|
|
||||||
|
export function ActivityIdScreen() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const { isLoading, data } = useEvent(id);
|
||||||
|
|
||||||
|
if (isLoading || !data) {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
|
<LoaderIcon className="size-5 animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.kind === NDKKind.Text) return <ActivitySingleText event={data} />;
|
||||||
|
if (data.kind === NDKKind.Zap) return <ActivitySingleZap event={data} />;
|
||||||
|
if (data.kind === NDKKind.Repost)
|
||||||
|
return <ActivitySingleRepost event={data} />;
|
||||||
|
|
||||||
|
return <ActivitySingleText event={data} />;
|
||||||
|
}
|
||||||
29
apps/desktop/src/routes/activty/index.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { activityUnreadAtom } from "@lume/utils";
|
||||||
|
import { useSetAtom } from "jotai";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Outlet } from "react-router-dom";
|
||||||
|
import { ActivityList } from "./components/list";
|
||||||
|
|
||||||
|
export function ActivityScreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const setUnreadActivity = useSetAtom(activityUnreadAtom);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setUnreadActivity(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full rounded-xl shadow-[rgba(50,_50,_105,_0.15)_0px_2px_5px_0px,_rgba(0,_0,_0,_0.05)_0px_1px_1px_0px] dark:shadow-none dark:ring-1 dark:ring-white/10">
|
||||||
|
<div className="h-full flex flex-col w-96 shrink-0 rounded-l-xl bg-white/50 backdrop-blur-xl dark:bg-black/50">
|
||||||
|
<div className="h-14 shrink-0 flex items-center px-5 text-lg font-semibold border-b border-black/10 dark:border-white/10">
|
||||||
|
{t("activity.title")}
|
||||||
|
</div>
|
||||||
|
<ActivityList />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 rounded-r-xl bg-white pb-20 dark:bg-black">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
264
apps/desktop/src/routes/auth/create-address.tsx
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
import { useArk } from "@lume/ark";
|
||||||
|
import { CheckIcon, ChevronDownIcon, LoaderIcon } from "@lume/icons";
|
||||||
|
import { useStorage } from "@lume/storage";
|
||||||
|
import { onboardingAtom } from "@lume/utils";
|
||||||
|
import NDK, {
|
||||||
|
NDKEvent,
|
||||||
|
NDKKind,
|
||||||
|
NDKNip46Signer,
|
||||||
|
NDKPrivateKeySigner,
|
||||||
|
} from "@nostr-dev-kit/ndk";
|
||||||
|
import * as Select from "@radix-ui/react-select";
|
||||||
|
import { UnlistenFn } from "@tauri-apps/api/event";
|
||||||
|
import { Window } from "@tauri-apps/api/window";
|
||||||
|
import { useSetAtom } from "jotai";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useLoaderData, useNavigate } from "react-router-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
const Item = ({ event }: { event: NDKEvent }) => {
|
||||||
|
const domain = JSON.parse(event.content).nip05.replace("_@", "");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select.Item
|
||||||
|
value={event.id}
|
||||||
|
className="relative flex items-center pr-10 leading-none rounded-md select-none text-neutral-100 rounded-mg h-9 pl-7"
|
||||||
|
>
|
||||||
|
<Select.ItemText>@{domain}</Select.ItemText>
|
||||||
|
<Select.ItemIndicator className="absolute left-0 inline-flex items-center justify-center transform h-7">
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</Select.ItemIndicator>
|
||||||
|
</Select.Item>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CreateAccountAddress() {
|
||||||
|
const ark = useArk();
|
||||||
|
const storage = useStorage();
|
||||||
|
const services = useLoaderData() as NDKEvent[];
|
||||||
|
const setOnboarding = useSetAtom(onboardingAtom);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [serviceId, setServiceId] = useState(services?.[0]?.id);
|
||||||
|
const [loading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { isValid },
|
||||||
|
} = useForm();
|
||||||
|
|
||||||
|
const getDomainName = (id: string) => {
|
||||||
|
const event = services.find((ev) => ev.id === id);
|
||||||
|
return JSON.parse(event.content).nip05.replace("_@", "") as string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (data: { username: string; email: string }) => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
const domain = getDomainName(serviceId);
|
||||||
|
const service = services.find((ev) => ev.id === serviceId);
|
||||||
|
|
||||||
|
// generate ndk for nsecbunker
|
||||||
|
const localSigner = NDKPrivateKeySigner.generate();
|
||||||
|
const bunker = new NDK({
|
||||||
|
explicitRelayUrls: [
|
||||||
|
"wss://relay.nsecbunker.com/",
|
||||||
|
"wss://nostr.vulpem.com/",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await bunker.connect(2000);
|
||||||
|
|
||||||
|
// generate tmp remote singer for create account
|
||||||
|
const remoteSigner = new NDKNip46Signer(
|
||||||
|
bunker,
|
||||||
|
service.pubkey,
|
||||||
|
localSigner,
|
||||||
|
);
|
||||||
|
|
||||||
|
// handle auth url request
|
||||||
|
let unlisten: UnlistenFn;
|
||||||
|
let authWindow: Window;
|
||||||
|
let account: string = undefined;
|
||||||
|
|
||||||
|
remoteSigner.addListener("authUrl", async (authUrl: string) => {
|
||||||
|
authWindow = new Window(`auth-${serviceId}`, {
|
||||||
|
url: authUrl,
|
||||||
|
title: domain,
|
||||||
|
titleBarStyle: "overlay",
|
||||||
|
width: 600,
|
||||||
|
height: 650,
|
||||||
|
center: true,
|
||||||
|
closable: false,
|
||||||
|
});
|
||||||
|
unlisten = await authWindow.onCloseRequested(() => {
|
||||||
|
if (!account) {
|
||||||
|
setIsLoading(false);
|
||||||
|
unlisten();
|
||||||
|
|
||||||
|
return authWindow.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// create new account
|
||||||
|
account = await remoteSigner.createAccount(
|
||||||
|
data.username,
|
||||||
|
domain,
|
||||||
|
data.email,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
unlisten();
|
||||||
|
setIsLoading(false);
|
||||||
|
|
||||||
|
authWindow.close();
|
||||||
|
|
||||||
|
return toast.error("Failed to create new account, try again later");
|
||||||
|
}
|
||||||
|
|
||||||
|
unlisten();
|
||||||
|
authWindow.close();
|
||||||
|
|
||||||
|
// add account to storage
|
||||||
|
await storage.createSetting("nsecbunker", "1");
|
||||||
|
const newAccount = await storage.createAccount({
|
||||||
|
pubkey: account,
|
||||||
|
privkey: localSigner.privateKey,
|
||||||
|
});
|
||||||
|
ark.account = newAccount;
|
||||||
|
|
||||||
|
// get final signer with newly created account
|
||||||
|
const finalSigner = new NDKNip46Signer(bunker, account, localSigner);
|
||||||
|
await finalSigner.blockUntilReady();
|
||||||
|
|
||||||
|
// update main ndk instance signer
|
||||||
|
ark.updateNostrSigner({ signer: finalSigner });
|
||||||
|
|
||||||
|
// remove default nsecbunker profile and contact list
|
||||||
|
// await ark.createEvent({ kind: NDKKind.Metadata, content: "", tags: [] });
|
||||||
|
await ark.createEvent({ kind: NDKKind.Contacts, content: "", tags: [] });
|
||||||
|
|
||||||
|
setIsLoading(false);
|
||||||
|
setOnboarding({ open: true, newUser: true });
|
||||||
|
|
||||||
|
return navigate("/auth/onboarding", { replace: true });
|
||||||
|
} catch (e) {
|
||||||
|
setIsLoading(false);
|
||||||
|
toast.error(String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex items-center justify-center w-full h-full">
|
||||||
|
<div className="flex flex-col w-full max-w-md gap-8 mx-auto">
|
||||||
|
<div className="flex flex-col gap-1 text-center items-center">
|
||||||
|
<h1 className="text-2xl font-semibold">
|
||||||
|
{t("signupWithProvider.title")}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
{!services ? (
|
||||||
|
<div className="flex items-center justify-center w-full">
|
||||||
|
<LoaderIcon className="size-5 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
className="flex flex-col gap-3 mb-0"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-6 p-5 bg-neutral-950 rounded-2xl">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label
|
||||||
|
htmlFor="username"
|
||||||
|
className="text-sm font-semibold uppercase text-neutral-600"
|
||||||
|
>
|
||||||
|
{t("signupWithProvider.username")}
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="flex items-center justify-between w-full gap-2 bg-neutral-900 rounded-xl">
|
||||||
|
<input
|
||||||
|
type={"text"}
|
||||||
|
{...register("username", {
|
||||||
|
required: true,
|
||||||
|
minLength: 1,
|
||||||
|
})}
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
autoCapitalize="off"
|
||||||
|
placeholder="alice"
|
||||||
|
className="flex-1 min-w-0 text-xl bg-transparent border-transparent outline-none focus:outline-none focus:ring-0 focus:border-none h-14 ring-0 placeholder:text-neutral-600"
|
||||||
|
/>
|
||||||
|
<Select.Root value={serviceId} onValueChange={setServiceId}>
|
||||||
|
<Select.Trigger className="inline-flex items-center justify-end gap-2 pr-3 text-xl font-semibold text-blue-500 w-max shrink-0">
|
||||||
|
<Select.Value>@{getDomainName(serviceId)}</Select.Value>
|
||||||
|
<Select.Icon>
|
||||||
|
<ChevronDownIcon className="size-5" />
|
||||||
|
</Select.Icon>
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Portal>
|
||||||
|
<Select.Content className="rounded-lg border border-white/20 bg-white/10 backdrop-blur-xl">
|
||||||
|
<Select.Viewport className="p-3">
|
||||||
|
<Select.Group>
|
||||||
|
<Select.Label className="mb-2 text-sm font-medium uppercase px-7 text-neutral-600">
|
||||||
|
{t("signupWithProvider.chooseProvider")}
|
||||||
|
</Select.Label>
|
||||||
|
{services.map((service) => (
|
||||||
|
<Item key={service.id} event={service} />
|
||||||
|
))}
|
||||||
|
</Select.Group>
|
||||||
|
</Select.Viewport>
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Portal>
|
||||||
|
</Select.Root>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-neutral-600">
|
||||||
|
{t("signupWithProvider.usernameFooter")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label
|
||||||
|
htmlFor="email"
|
||||||
|
className="text-sm font-semibold uppercase text-neutral-600"
|
||||||
|
>
|
||||||
|
{t("signupWithProvider.email")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type={"email"}
|
||||||
|
{...register("email", { required: false })}
|
||||||
|
spellCheck={false}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect="none"
|
||||||
|
className="px-3 text-xl border-transparent rounded-xl h-14 bg-neutral-900 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-800"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-neutral-600">
|
||||||
|
{t("signupWithProvider.emailFooter")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!isValid}
|
||||||
|
className="inline-flex items-center justify-center w-full text-lg h-12 font-medium text-white bg-blue-500 rounded-xl hover:bg-blue-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<LoaderIcon className="size-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
t("global.continue")
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
187
apps/desktop/src/routes/auth/create-keys.tsx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import { useArk } from "@lume/ark";
|
||||||
|
import { CheckIcon, EyeOffIcon, EyeOnIcon, LoaderIcon } from "@lume/icons";
|
||||||
|
import { useStorage } from "@lume/storage";
|
||||||
|
import { onboardingAtom } from "@lume/utils";
|
||||||
|
import { NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
|
||||||
|
import * as Checkbox from "@radix-ui/react-checkbox";
|
||||||
|
import { desktopDir } from "@tauri-apps/api/path";
|
||||||
|
import { save } from "@tauri-apps/plugin-dialog";
|
||||||
|
import { writeTextFile } from "@tauri-apps/plugin-fs";
|
||||||
|
import { useSetAtom } from "jotai";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
import { getPublicKey, nip19 } from "nostr-tools";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export function CreateAccountKeys() {
|
||||||
|
const ark = useArk();
|
||||||
|
const storage = useStorage();
|
||||||
|
const setOnboarding = useSetAtom(onboardingAtom);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [t] = useTranslation();
|
||||||
|
const [key, setKey] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [showKey, setShowKey] = useState(false);
|
||||||
|
const [confirm, setConfirm] = useState({ c1: false, c2: false, c3: false });
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const privkey = nip19.decode(key).data as string;
|
||||||
|
const signer = new NDKPrivateKeySigner(privkey);
|
||||||
|
const pubkey = getPublicKey(privkey);
|
||||||
|
|
||||||
|
ark.updateNostrSigner({ signer });
|
||||||
|
|
||||||
|
const downloadPath = await desktopDir();
|
||||||
|
const fileName = `nostr_keys_${nanoid(4)}.txt`;
|
||||||
|
const filePath = await save({
|
||||||
|
defaultPath: `${downloadPath}/${fileName}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!filePath) {
|
||||||
|
return toast.info("You need to save account keys before continue.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeTextFile(
|
||||||
|
filePath,
|
||||||
|
`Nostr Account\nGenerated by Lume (lume.nu)\n---\nPrivate key: ${key}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const newAccount = await storage.createAccount({
|
||||||
|
pubkey: pubkey,
|
||||||
|
privkey: privkey,
|
||||||
|
});
|
||||||
|
ark.account = newAccount;
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
setOnboarding({ open: true, newUser: true });
|
||||||
|
|
||||||
|
return navigate("/auth/onboarding", { replace: true });
|
||||||
|
} catch (e) {
|
||||||
|
setLoading(false);
|
||||||
|
toast.error(String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const privkey = NDKPrivateKeySigner.generate().privateKey;
|
||||||
|
setKey(nip19.nsecEncode(privkey));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex items-center justify-center w-full h-full">
|
||||||
|
<div className="flex flex-col w-full max-w-md gap-8 mx-auto">
|
||||||
|
<div className="flex flex-col gap-1 text-center items-center">
|
||||||
|
<h1 className="text-2xl font-semibold">
|
||||||
|
{t("signupWithSelfManage.title")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg font-medium leading-snug text-neutral-600 dark:text-neutral-500">
|
||||||
|
{t("signupWithSelfManage.subtitle")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-6 mb-0">
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
readOnly
|
||||||
|
value={key}
|
||||||
|
type={showKey ? "text" : "password"}
|
||||||
|
className="pl-3 pr-14 w-full resize-none text-xl border-transparent rounded-xl h-14 bg-neutral-900 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-800"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowKey((state) => !state)}
|
||||||
|
className="absolute right-2 top-2 size-10 inline-flex items-center justify-center rounded-lg text-white bg-neutral-800 hover:bg-neutral-700"
|
||||||
|
>
|
||||||
|
{showKey ? (
|
||||||
|
<EyeOnIcon className="size-5" />
|
||||||
|
) : (
|
||||||
|
<EyeOffIcon className="size-5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox.Root
|
||||||
|
checked={confirm.c1}
|
||||||
|
onCheckedChange={() =>
|
||||||
|
setConfirm((state) => ({ ...state, c1: !state.c1 }))
|
||||||
|
}
|
||||||
|
className="flex size-7 appearance-none items-center justify-center rounded-lg bg-neutral-900 outline-none"
|
||||||
|
id="confirm1"
|
||||||
|
>
|
||||||
|
<Checkbox.Indicator className="text-blue-500">
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</Checkbox.Indicator>
|
||||||
|
</Checkbox.Root>
|
||||||
|
<label
|
||||||
|
className="text-sm leading-none text-neutral-500"
|
||||||
|
htmlFor="confirm1"
|
||||||
|
>
|
||||||
|
{t("signupWithSelfManage.confirm1")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox.Root
|
||||||
|
checked={confirm.c2}
|
||||||
|
onCheckedChange={() =>
|
||||||
|
setConfirm((state) => ({ ...state, c2: !state.c2 }))
|
||||||
|
}
|
||||||
|
className="flex size-7 appearance-none items-center justify-center rounded-lg bg-neutral-900 outline-none"
|
||||||
|
id="confirm2"
|
||||||
|
>
|
||||||
|
<Checkbox.Indicator className="text-blue-500">
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</Checkbox.Indicator>
|
||||||
|
</Checkbox.Root>
|
||||||
|
<label
|
||||||
|
className="text-sm leading-none text-neutral-500"
|
||||||
|
htmlFor="confirm2"
|
||||||
|
>
|
||||||
|
{t("signupWithSelfManage.confirm2")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox.Root
|
||||||
|
checked={confirm.c3}
|
||||||
|
onCheckedChange={() =>
|
||||||
|
setConfirm((state) => ({ ...state, c3: !state.c3 }))
|
||||||
|
}
|
||||||
|
className="flex size-7 appearance-none items-center justify-center rounded-lg bg-neutral-900 outline-none"
|
||||||
|
id="confirm3"
|
||||||
|
>
|
||||||
|
<Checkbox.Indicator className="text-blue-500">
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</Checkbox.Indicator>
|
||||||
|
</Checkbox.Root>
|
||||||
|
<label
|
||||||
|
className="text-sm leading-none text-neutral-500"
|
||||||
|
htmlFor="confirm3"
|
||||||
|
>
|
||||||
|
{t("signupWithSelfManage.confirm3")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={submit}
|
||||||
|
disabled={!confirm.c1 || !confirm.c2 || !confirm.c3}
|
||||||
|
className="inline-flex items-center justify-center w-full h-12 text-lg font-medium text-white bg-blue-500 rounded-xl hover:bg-blue-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<LoaderIcon className="size-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
t("signupWithSelfManage.button")
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
apps/desktop/src/routes/auth/create.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { LoaderIcon } from "@lume/icons";
|
||||||
|
import { cn } from "@lume/utils";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
export function CreateAccountScreen() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [t] = useTranslation();
|
||||||
|
const [method, setMethod] = useState<"self" | "managed">("self");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const next = () => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
if (method === "self") {
|
||||||
|
navigate("/auth/create-keys");
|
||||||
|
} else {
|
||||||
|
navigate("/auth/create-address");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex items-center justify-center w-full h-full">
|
||||||
|
<div className="flex flex-col w-full max-w-md gap-8 mx-auto">
|
||||||
|
<div className="flex flex-col gap-1 text-center items-center">
|
||||||
|
<h1 className="text-2xl font-semibold">{t("signup.title")}</h1>
|
||||||
|
<p className="text-lg font-medium leading-snug text-neutral-600 dark:text-neutral-500">
|
||||||
|
{t("signup.subtitle")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMethod("self")}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-start px-4 py-3.5 bg-neutral-900 rounded-xl hover:bg-neutral-800",
|
||||||
|
method === "self"
|
||||||
|
? "ring-1 ring-offset-4 ring-offset-black ring-blue-500"
|
||||||
|
: "",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p className="font-semibold">{t("signup.selfManageMethod")}</p>
|
||||||
|
<p className="text-sm font-medium text-neutral-500">
|
||||||
|
{t("signup.selfManageMethodDescription")}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMethod("managed")}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-start px-4 py-3.5 bg-neutral-900 rounded-xl hover:bg-neutral-800",
|
||||||
|
method === "managed"
|
||||||
|
? "ring-1 ring-offset-4 ring-offset-black ring-blue-500"
|
||||||
|
: "",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="inline-flex items-center gap-2">
|
||||||
|
<p className="font-semibold">{t("signup.providerMethod")}</p>
|
||||||
|
<span className="text-xs font-medium px-2.5 py-0.5 rounded-full bg-gradient-to-tr from-blue-300 via-sky-500 to-teal-200">
|
||||||
|
Beta
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-neutral-500">
|
||||||
|
{t("signup.providerMethodDescription")}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={next}
|
||||||
|
className="inline-flex items-center justify-center w-full h-12 text-lg font-medium text-white bg-blue-500 rounded-xl hover:bg-blue-600"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<LoaderIcon className="size-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
t("global.continue")
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{method === "managed" ? (
|
||||||
|
<div className="flex flex-col gap-1 text-sm text-neutral-500">
|
||||||
|
<p className="text-sm font-semibold text-neutral-300">
|
||||||
|
Attention:
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
You're chosing Managed by Provider, this feature still in
|
||||||
|
"Beta".
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Some functions still missing or not work as expected, you
|
||||||
|
shouldn't create your main account with this method
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="https://github.com/kind-0/nsecbunkerd/blob/master/OAUTH-LIKE-FLOW.md"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-blue-500"
|
||||||
|
>
|
||||||
|
Learn more
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
apps/desktop/src/routes/auth/login-key.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { useArk } from "@lume/ark";
|
||||||
|
import { EyeOffIcon, EyeOnIcon, LoaderIcon } from "@lume/icons";
|
||||||
|
import { useStorage } from "@lume/storage";
|
||||||
|
import { getPublicKey, nip19 } from "nostr-tools";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export function LoginWithKey() {
|
||||||
|
const ark = useArk();
|
||||||
|
const storage = useStorage();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [showKey, setShowKey] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const { t } = useTranslation("loginWithPrivkey.subtitle");
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
setError,
|
||||||
|
formState: { errors, isValid },
|
||||||
|
} = useForm();
|
||||||
|
|
||||||
|
const onSubmit = async (data: { nsec: string }) => {
|
||||||
|
try {
|
||||||
|
if (!data.nsec.startsWith("nsec1"))
|
||||||
|
return toast.error("You need to enter a private key start with nsec1");
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const privkey = nip19.decode(data.nsec).data as string;
|
||||||
|
const pubkey = getPublicKey(privkey);
|
||||||
|
|
||||||
|
const account = await storage.createAccount({
|
||||||
|
pubkey: pubkey,
|
||||||
|
privkey: privkey,
|
||||||
|
});
|
||||||
|
ark.account = account;
|
||||||
|
|
||||||
|
return navigate("/auth/onboarding", { replace: true });
|
||||||
|
} catch (e) {
|
||||||
|
setLoading(false);
|
||||||
|
setError("nsec", {
|
||||||
|
type: "manual",
|
||||||
|
message: String(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex items-center justify-center w-full h-full">
|
||||||
|
<div className="flex flex-col w-full max-w-md gap-8 mx-auto">
|
||||||
|
<div className="flex flex-col gap-1 text-center items-center">
|
||||||
|
<h1 className="text-2xl font-semibold">
|
||||||
|
{t("loginWithPrivkey.title")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg font-medium whitespace-pre-line leading-snug text-neutral-600 dark:text-neutral-500">
|
||||||
|
<Trans t={t}>
|
||||||
|
Lume will put your private key to{" "}
|
||||||
|
<span className="text-teal-500">
|
||||||
|
{storage.platform === "macos"
|
||||||
|
? "Apple Keychain"
|
||||||
|
: storage.platform === "windows"
|
||||||
|
? "Credential Manager"
|
||||||
|
: "Secret Service"}
|
||||||
|
</span>
|
||||||
|
. It will be secured by your OS.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
className="flex flex-col gap-4 mb-0"
|
||||||
|
>
|
||||||
|
<div className="relative flex flex-col gap-1">
|
||||||
|
<input
|
||||||
|
type={showKey ? "text" : "password"}
|
||||||
|
{...register("nsec", { required: false })}
|
||||||
|
spellCheck={false}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect="none"
|
||||||
|
placeholder="nsec1..."
|
||||||
|
className="pl-3 pr-11 text-xl border-transparent rounded-xl h-14 bg-neutral-950 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-800"
|
||||||
|
/>
|
||||||
|
{errors.nsec && (
|
||||||
|
<p className="text-sm text-center text-red-600">
|
||||||
|
{errors.nsec.message as string}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowKey((state) => !state)}
|
||||||
|
className="absolute right-2 top-2 size-10 inline-flex items-center justify-center rounded-lg text-white bg-neutral-900 hover:bg-neutral-800"
|
||||||
|
>
|
||||||
|
{showKey ? (
|
||||||
|
<EyeOnIcon className="size-5" />
|
||||||
|
) : (
|
||||||
|
<EyeOffIcon className="size-5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!isValid || loading}
|
||||||
|
className="inline-flex items-center justify-center w-full text-lg h-12 font-medium text-white bg-blue-500 rounded-xl hover:bg-blue-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<LoaderIcon className="size-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
t("global.continue")
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
apps/desktop/src/routes/auth/login-nsecbunker.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { useArk } from "@lume/ark";
|
||||||
|
import { LoaderIcon } from "@lume/icons";
|
||||||
|
import { useStorage } from "@lume/storage";
|
||||||
|
import NDK, { NDKNip46Signer, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
|
||||||
|
import { nip19 } from "nostr-tools";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export function LoginWithNsecbunker() {
|
||||||
|
const ark = useArk();
|
||||||
|
const storage = useStorage();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
setError,
|
||||||
|
formState: { errors, isValid },
|
||||||
|
} = useForm();
|
||||||
|
|
||||||
|
const onSubmit = async (data: { npub: string }) => {
|
||||||
|
try {
|
||||||
|
if (!data.npub.startsWith("npub1"))
|
||||||
|
return toast.info("You need to enter a token start with npub1");
|
||||||
|
|
||||||
|
if (!data.npub.includes("#"))
|
||||||
|
return toast.info("Token must include #secret");
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const bunker = new NDK({
|
||||||
|
explicitRelayUrls: [
|
||||||
|
"wss://relay.nsecbunker.com",
|
||||||
|
"wss://nostr.vulpem.com",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await bunker.connect(2000);
|
||||||
|
|
||||||
|
const pubkey = nip19.decode(data.npub.split("#")[0]).data as string;
|
||||||
|
const localSigner = NDKPrivateKeySigner.generate();
|
||||||
|
const remoteSigner = new NDKNip46Signer(bunker, data.npub, localSigner);
|
||||||
|
await remoteSigner.blockUntilReady();
|
||||||
|
|
||||||
|
ark.updateNostrSigner({ signer: remoteSigner });
|
||||||
|
|
||||||
|
await storage.createSetting("nsecbunker", "1");
|
||||||
|
const account = await storage.createAccount({
|
||||||
|
pubkey: pubkey,
|
||||||
|
privkey: localSigner.privateKey,
|
||||||
|
});
|
||||||
|
ark.account = account;
|
||||||
|
|
||||||
|
return navigate("/auth/onboarding", { replace: true });
|
||||||
|
} catch (e) {
|
||||||
|
setLoading(false);
|
||||||
|
setError("npub", {
|
||||||
|
type: "manual",
|
||||||
|
message: String(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex items-center justify-center w-full h-full">
|
||||||
|
<div className="flex flex-col w-full max-w-md gap-8 mx-auto">
|
||||||
|
<div className="flex flex-col gap-1 text-center items-center">
|
||||||
|
<h1 className="text-2xl font-semibold">
|
||||||
|
{t("loginWithBunker.title")}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
className="flex flex-col gap-4 mb-0"
|
||||||
|
>
|
||||||
|
<div className="relative flex flex-col gap-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
{...register("npub", { required: false })}
|
||||||
|
spellCheck={false}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect="none"
|
||||||
|
placeholder="npub1...#..."
|
||||||
|
className="px-3 text-xl border-transparent rounded-xl h-14 bg-neutral-950 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-800"
|
||||||
|
/>
|
||||||
|
{errors.npub && (
|
||||||
|
<p className="text-sm text-center text-red-600">
|
||||||
|
{errors.npub.message as string}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!isValid || loading}
|
||||||
|
className="inline-flex items-center justify-center w-full text-lg h-12 font-medium text-white bg-blue-500 rounded-xl hover:bg-blue-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<LoaderIcon className="size-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
t("global.continue")
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
176
apps/desktop/src/routes/auth/login-oauth.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { useArk } from "@lume/ark";
|
||||||
|
import { LoaderIcon } from "@lume/icons";
|
||||||
|
import { useStorage } from "@lume/storage";
|
||||||
|
import { NIP05 } from "@lume/types";
|
||||||
|
import NDK, { NDKNip46Signer, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
|
||||||
|
import { Window } from "@tauri-apps/api/window";
|
||||||
|
import { fetch } from "@tauri-apps/plugin-http";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
const emailRegex = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,4}$/;
|
||||||
|
|
||||||
|
export function LoginWithOAuth() {
|
||||||
|
const ark = useArk();
|
||||||
|
const storage = useStorage();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
setError,
|
||||||
|
formState: { errors, isValid },
|
||||||
|
} = useForm();
|
||||||
|
|
||||||
|
const onSubmit = async (data: { nip05: string }) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
if (!emailRegex.test(data.nip05)) {
|
||||||
|
setLoading(false);
|
||||||
|
return toast.error(
|
||||||
|
"Cannot verify your NIP-05 address, please try again later.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const localPath = data.nip05.split("@")[0];
|
||||||
|
const service = data.nip05.split("@")[1];
|
||||||
|
|
||||||
|
const verifyURL = `https://${service}/.well-known/nostr.json?name=${localPath}`;
|
||||||
|
|
||||||
|
const req = await fetch(verifyURL, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!req.ok) {
|
||||||
|
setLoading(false);
|
||||||
|
return toast.error(
|
||||||
|
"Cannot verify your NIP-05 address, please try again later.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res: NIP05 = await req.json();
|
||||||
|
|
||||||
|
if (!res.names[localPath.toLowerCase()] || !res.names[localPath]) {
|
||||||
|
setLoading(false);
|
||||||
|
return toast.error(
|
||||||
|
"Cannot verify your NIP-05 address, please try again later.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pubkey =
|
||||||
|
(res.names[localPath] as string) ||
|
||||||
|
(res.names[localPath.toLowerCase()] as string);
|
||||||
|
|
||||||
|
if (!res.nip46[pubkey]) {
|
||||||
|
setLoading(false);
|
||||||
|
return toast.error("Cannot found NIP-46 with this address");
|
||||||
|
}
|
||||||
|
|
||||||
|
const nip46Relays = res.nip46[pubkey] as unknown as string[];
|
||||||
|
|
||||||
|
const bunker = new NDK({
|
||||||
|
explicitRelayUrls: nip46Relays || [
|
||||||
|
"wss://relay.nsecbunker.com",
|
||||||
|
"wss://nostr.vulpem.com",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await bunker.connect(2000);
|
||||||
|
|
||||||
|
const localSigner = NDKPrivateKeySigner.generate();
|
||||||
|
const remoteSigner = new NDKNip46Signer(bunker, pubkey, localSigner);
|
||||||
|
|
||||||
|
// handle auth url request
|
||||||
|
let authWindow: Window;
|
||||||
|
remoteSigner.addListener("authUrl", (authUrl: string) => {
|
||||||
|
authWindow = new Window(`auth-${pubkey}`, {
|
||||||
|
url: authUrl,
|
||||||
|
title: "Login",
|
||||||
|
titleBarStyle: "overlay",
|
||||||
|
width: 415,
|
||||||
|
height: 600,
|
||||||
|
center: true,
|
||||||
|
closable: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const remoteUser = await remoteSigner.blockUntilReady();
|
||||||
|
|
||||||
|
if (remoteUser) {
|
||||||
|
authWindow.close();
|
||||||
|
|
||||||
|
ark.updateNostrSigner({ signer: remoteSigner });
|
||||||
|
|
||||||
|
await storage.createSetting("nsecbunker", "1");
|
||||||
|
const account = await storage.createAccount({
|
||||||
|
pubkey,
|
||||||
|
privkey: localSigner.privateKey,
|
||||||
|
});
|
||||||
|
ark.account = account;
|
||||||
|
|
||||||
|
return navigate("/auth/onboarding", { replace: true });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setLoading(false);
|
||||||
|
setError("nip05", {
|
||||||
|
type: "manual",
|
||||||
|
message: String(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex items-center justify-center w-full h-full">
|
||||||
|
<div className="flex flex-col w-full max-w-md gap-8 mx-auto">
|
||||||
|
<div className="flex flex-col gap-1 text-center items-center">
|
||||||
|
<h1 className="text-2xl font-semibold">
|
||||||
|
{t("loginWithAddress.title")}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
className="flex flex-col gap-4 mb-0"
|
||||||
|
>
|
||||||
|
<div className="relative flex flex-col gap-1">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
{...register("nip05", { required: false })}
|
||||||
|
spellCheck={false}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect="none"
|
||||||
|
placeholder="satoshi@nostr.me"
|
||||||
|
className="px-3 text-xl border-transparent rounded-xl h-14 bg-neutral-950 placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-800"
|
||||||
|
/>
|
||||||
|
{errors.nip05 && (
|
||||||
|
<p className="text-sm text-center text-red-600">
|
||||||
|
{errors.nip05.message as string}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!isValid || loading}
|
||||||
|
className="inline-flex items-center justify-center w-full text-lg h-12 font-medium text-white bg-blue-500 rounded-xl hover:bg-blue-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<LoaderIcon className="size-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
t("global.continue")
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
apps/desktop/src/routes/auth/login.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
export function LoginScreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex items-center justify-center w-full h-full">
|
||||||
|
<div className="flex flex-col w-full max-w-md gap-8 mx-auto">
|
||||||
|
<div className="flex flex-col gap-1 text-center items-center">
|
||||||
|
<h1 className="text-2xl font-semibold">{t("login.title")}</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Link
|
||||||
|
to="/auth/login-oauth"
|
||||||
|
className="inline-flex items-center justify-center w-full h-12 text-lg font-medium text-white bg-blue-500 rounded-xl hover:bg-blue-600"
|
||||||
|
>
|
||||||
|
{t("login.loginWithAddress")}
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/auth/login-nsecbunker"
|
||||||
|
className="inline-flex items-center justify-center w-full h-12 text-lg font-medium text-neutral-50 rounded-xl bg-neutral-950 hover:bg-neutral-900"
|
||||||
|
>
|
||||||
|
{t("login.loginWithBunker")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<div className="w-full border-t border-neutral-900" />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center">
|
||||||
|
<span className="px-2 font-medium bg-black text-neutral-600">
|
||||||
|
{t("login.or")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Link
|
||||||
|
to="/auth/login-key"
|
||||||
|
className="mb-2 inline-flex items-center justify-center w-full h-12 text-lg font-medium text-neutral-50 rounded-xl bg-neutral-950 hover:bg-neutral-900"
|
||||||
|
>
|
||||||
|
{t("login.loginWithPrivkey")}
|
||||||
|
</Link>
|
||||||
|
<p className="text-sm text-center text-neutral-500">
|
||||||
|
{t("login.footer")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
198
apps/desktop/src/routes/auth/onboarding.tsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import { useArk } from "@lume/ark";
|
||||||
|
import { InfoIcon, LoaderIcon } from "@lume/icons";
|
||||||
|
import { useStorage } from "@lume/storage";
|
||||||
|
import { TranslateRegisterModal } from "@lume/ui";
|
||||||
|
import * as Switch from "@radix-ui/react-switch";
|
||||||
|
import {
|
||||||
|
isPermissionGranted,
|
||||||
|
requestPermission,
|
||||||
|
} from "@tauri-apps/plugin-notification";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export function OnboardingScreen() {
|
||||||
|
const ark = useArk();
|
||||||
|
const storage = useStorage();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [t] = useTranslation();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [apiKey, setAPIKey] = useState("");
|
||||||
|
const [settings, setSettings] = useState({
|
||||||
|
notification: false,
|
||||||
|
lowPower: false,
|
||||||
|
translation: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleLowPower = async () => {
|
||||||
|
await storage.createSetting("lowPower", String(+!settings.lowPower));
|
||||||
|
setSettings((state) => ({ ...state, lowPower: !settings.lowPower }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTranslation = async () => {
|
||||||
|
await storage.createSetting("translation", String(+!settings.translation));
|
||||||
|
setSettings((state) => ({ ...state, translation: !settings.translation }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleNofitication = async () => {
|
||||||
|
await requestPermission();
|
||||||
|
setSettings((state) => ({
|
||||||
|
...state,
|
||||||
|
notification: !settings.notification,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const completeAuth = async () => {
|
||||||
|
if (settings.translation) {
|
||||||
|
if (!apiKey.length)
|
||||||
|
return toast.warning(
|
||||||
|
"You need to provide Translate API if enable translation",
|
||||||
|
);
|
||||||
|
|
||||||
|
await storage.createSetting("translateApiKey", apiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// get account contacts
|
||||||
|
await ark.getUserContacts();
|
||||||
|
|
||||||
|
navigate("/", { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadSettings() {
|
||||||
|
// get notification permission
|
||||||
|
const permissionGranted = await isPermissionGranted();
|
||||||
|
setSettings((prev) => ({ ...prev, notification: permissionGranted }));
|
||||||
|
|
||||||
|
// get other settings
|
||||||
|
const data = await storage.getAllSettings();
|
||||||
|
for (const item of data) {
|
||||||
|
if (item.key === "lowPower")
|
||||||
|
setSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
lowPower: !!parseInt(item.value),
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (item.key === "translation")
|
||||||
|
setSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
translation: !!parseInt(item.value),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSettings();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex h-full w-full items-center justify-center">
|
||||||
|
<div className="mx-auto flex w-full max-w-md flex-col gap-8">
|
||||||
|
<div className="flex flex-col gap-1 text-center items-center">
|
||||||
|
<h1 className="text-2xl font-semibold">
|
||||||
|
{t("onboardingSettings.title")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg font-medium leading-snug text-neutral-600 dark:text-neutral-500">
|
||||||
|
{t("onboardingSettings.subtitle")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex w-full items-start justify-between gap-4 rounded-xl px-5 py-4 bg-neutral-950">
|
||||||
|
<Switch.Root
|
||||||
|
checked={settings.notification}
|
||||||
|
onClick={() => toggleNofitication()}
|
||||||
|
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full outline-none data-[state=checked]:bg-blue-500 bg-neutral-800"
|
||||||
|
>
|
||||||
|
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-neutral-50 transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||||
|
</Switch.Root>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-lg">
|
||||||
|
{t("onboardingSettings.notification.title")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-neutral-500">
|
||||||
|
{t("onboardingSettings.notification.subtitle")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full items-start justify-between gap-4 rounded-xl px-5 py-4 bg-neutral-950">
|
||||||
|
<Switch.Root
|
||||||
|
checked={settings.lowPower}
|
||||||
|
onClick={() => toggleLowPower()}
|
||||||
|
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full outline-none data-[state=checked]:bg-blue-500 bg-neutral-800"
|
||||||
|
>
|
||||||
|
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-neutral-50 transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||||
|
</Switch.Root>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-lg">
|
||||||
|
{t("onboardingSettings.lowPower.title")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-neutral-500">
|
||||||
|
{t("onboardingSettings.lowPower.subtitle")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full items-start justify-between gap-4 rounded-xl px-5 py-4 bg-neutral-950">
|
||||||
|
<Switch.Root
|
||||||
|
checked={settings.translation}
|
||||||
|
onClick={() => toggleTranslation()}
|
||||||
|
className="relative mt-1 h-7 w-12 shrink-0 cursor-default rounded-full outline-none data-[state=checked]:bg-blue-500 bg-neutral-800"
|
||||||
|
>
|
||||||
|
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-neutral-50 transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||||
|
</Switch.Root>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-lg">
|
||||||
|
{t("onboardingSettings.translation.title")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-neutral-500">
|
||||||
|
{t("onboardingSettings.translation.subtitle")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{settings.translation ? (
|
||||||
|
<div className="flex flex-col w-full items-start justify-between gap-2 rounded-xl px-5 py-4 bg-neutral-950">
|
||||||
|
<h3 className="font-semibold">Translate API Key</h3>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
spellCheck={false}
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(e) => setAPIKey(e.target.value)}
|
||||||
|
className="w-full text-xl border-transparent outline-none focus:outline-none focus:ring-0 focus:border-none h-11 rounded-lg ring-0 placeholder:text-neutral-600 bg-neutral-900"
|
||||||
|
/>
|
||||||
|
<div className="w-full mt-1">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<div className="w-full border-t border-neutral-900" />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center">
|
||||||
|
<span className="px-2 text-sm font-medium bg-neutral-950 text-neutral-600">
|
||||||
|
Don't have an API key?
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<TranslateRegisterModal setAPIKey={setAPIKey} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="flex items-center gap-2 rounded-xl px-5 py-3 text-sm bg-blue-950 text-blue-300">
|
||||||
|
<InfoIcon className="size-8" />
|
||||||
|
<p>{t("onboardingSettings.footer")}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={completeAuth}
|
||||||
|
className="inline-flex items-center justify-center w-full h-12 text-lg font-medium text-white bg-blue-500 rounded-xl hover:bg-blue-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<LoaderIcon className="size-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
t("global.continue")
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
apps/desktop/src/routes/auth/welcome.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
export function WelcomeScreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-between w-full h-full">
|
||||||
|
<div />
|
||||||
|
<div className="flex flex-col items-center w-full max-w-4xl gap-10 mx-auto">
|
||||||
|
<div className="flex flex-col items-center text-center">
|
||||||
|
<img
|
||||||
|
src="/heading.png"
|
||||||
|
srcSet="/heading@2x.png 2x"
|
||||||
|
alt="lume"
|
||||||
|
className="w-2/3"
|
||||||
|
/>
|
||||||
|
<p className="mt-5 text-lg whitespace-pre-line font-medium leading-snug text-neutral-600 dark:text-neutral-500">
|
||||||
|
{t("welcome.title")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col w-full max-w-xs gap-2 mx-auto">
|
||||||
|
<Link
|
||||||
|
to="/auth/create"
|
||||||
|
className="inline-flex items-center justify-center w-full h-12 text-lg font-medium text-white bg-blue-500 rounded-xl hover:bg-blue-600"
|
||||||
|
>
|
||||||
|
{t("welcome.signup")}
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/auth/login"
|
||||||
|
className="inline-flex items-center justify-center w-full h-12 text-lg font-medium text-neutral-50 rounded-xl bg-neutral-950 hover:bg-neutral-900"
|
||||||
|
>
|
||||||
|
{t("welcome.login")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-center h-11">
|
||||||
|
<p className="text-neutral-700">
|
||||||
|
{t("welcome.footer")}{" "}
|
||||||
|
<Link
|
||||||
|
to="https://nostr.com"
|
||||||
|
target="_blank"
|
||||||
|
className="text-blue-500"
|
||||||
|
>
|
||||||
|
here
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
apps/desktop/src/routes/depot/components/contact.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { useArk } from "@lume/ark";
|
||||||
|
import { LoaderIcon, RunIcon } from "@lume/icons";
|
||||||
|
import { useStorage } from "@lume/storage";
|
||||||
|
import { User } from "@lume/ui";
|
||||||
|
import { NDKKind } from "@nostr-dev-kit/ndk";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export function DepotContactCard() {
|
||||||
|
const ark = useArk();
|
||||||
|
const storage = useStorage();
|
||||||
|
|
||||||
|
const [status, setStatus] = useState(false);
|
||||||
|
|
||||||
|
const backupContact = async () => {
|
||||||
|
try {
|
||||||
|
setStatus(true);
|
||||||
|
|
||||||
|
const event = await ark.getEventByFilter({
|
||||||
|
filter: {
|
||||||
|
authors: [ark.account.pubkey],
|
||||||
|
kinds: [NDKKind.Contacts],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// broadcast to depot
|
||||||
|
const publish = await event.publish();
|
||||||
|
|
||||||
|
if (publish) {
|
||||||
|
setStatus(false);
|
||||||
|
toast.success("Backup contact list successfully.");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(false);
|
||||||
|
toast.error(String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-56 w-full flex-col gap-2 overflow-hidden rounded-xl bg-neutral-100 p-2 dark:bg-neutral-900">
|
||||||
|
<div className="flex flex-1 items-center justify-center rounded-lg bg-neutral-200 dark:bg-neutral-800">
|
||||||
|
<div className="isolate flex -space-x-2">
|
||||||
|
{ark.account.contacts?.slice(0, 8).map((item) => (
|
||||||
|
<User key={item} pubkey={item} variant="ministacked" />
|
||||||
|
))}
|
||||||
|
{ark.account.contacts?.length > 8 ? (
|
||||||
|
<div className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-neutral-300 text-neutral-900 ring-1 ring-white dark:bg-neutral-700 dark:text-neutral-100 dark:ring-black">
|
||||||
|
<span className="text-[8px] font-medium">
|
||||||
|
+{ark.account.contacts?.length - 8}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="inline-flex shrink-0 items-center justify-between">
|
||||||
|
<div className="text-sm font-medium">Contacts</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={backupContact}
|
||||||
|
className="inline-flex h-8 w-max items-center justify-center gap-2 rounded-md bg-blue-500 pl-2 pr-3 font-medium text-white shadow shadow-blue-500/50 hover:bg-blue-600"
|
||||||
|
>
|
||||||
|
{status ? (
|
||||||
|
<LoaderIcon className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RunIcon className="size-4" />
|
||||||
|
)}
|
||||||
|
Backup
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
151
apps/desktop/src/routes/depot/components/members.tsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { CancelIcon, PlusIcon, UserAddIcon, UserRemoveIcon } from "@lume/icons";
|
||||||
|
import { User } from "@lume/ui";
|
||||||
|
import * as Dialog from "@radix-ui/react-dialog";
|
||||||
|
import { resolveResource, resolve } from "@tauri-apps/api/path";
|
||||||
|
import { readTextFile, writeTextFile } from "@tauri-apps/plugin-fs";
|
||||||
|
import { nip19 } from "nostr-tools";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { parse, stringify } from "smol-toml";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { VITE_FLATPAK_RESOURCE } from "@lume/utils";
|
||||||
|
|
||||||
|
export function DepotMembers() {
|
||||||
|
const [members, setMembers] = useState<Set<string>>(null);
|
||||||
|
const [tmpMembers, setTmpMembers] = useState<Array<string>>([]);
|
||||||
|
const [newMember, setNewMember] = useState("");
|
||||||
|
|
||||||
|
const addMember = async () => {
|
||||||
|
if (!newMember.startsWith("npub1"))
|
||||||
|
return toast.error("You need to enter a valid npub");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pubkey = nip19.decode(newMember).data as string;
|
||||||
|
setTmpMembers((prev) => [...prev, pubkey]);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeMember = (member: string) => {
|
||||||
|
setTmpMembers((prev) => prev.filter((item) => item !== member));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateMembers = async () => {
|
||||||
|
setMembers(new Set(tmpMembers));
|
||||||
|
|
||||||
|
const defaultConfig = VITE_FLATPAK_RESOURCE !== null ? await resolve("/",VITE_FLATPAK_RESOURCE) : await resolveResource("resources/config.toml");
|
||||||
|
const config = await readTextFile(defaultConfig);
|
||||||
|
const configContent = parse(config);
|
||||||
|
|
||||||
|
// biome-ignore lint/complexity/useLiteralKeys: <explanation>
|
||||||
|
configContent.authorization["pubkey_whitelist"] = [...members];
|
||||||
|
|
||||||
|
const newConfig = stringify(configContent);
|
||||||
|
|
||||||
|
return await writeTextFile(defaultConfig, newConfig);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadConfig() {
|
||||||
|
const defaultConfig = VITE_FLATPAK_RESOURCE !== null ? await resolve("/",VITE_FLATPAK_RESOURCE) : await resolveResource("resources/config.toml");
|
||||||
|
const config = await readTextFile(defaultConfig);
|
||||||
|
const configContent = parse(config);
|
||||||
|
setTmpMembers(
|
||||||
|
// biome-ignore lint/complexity/useLiteralKeys: <explanation>
|
||||||
|
Array.from(configContent.authorization["pubkey_whitelist"]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadConfig();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Root>
|
||||||
|
<div className="flex items-center justify-between rounded-lg bg-neutral-50 p-5 dark:bg-neutral-950">
|
||||||
|
<div className="flex flex-col items-start">
|
||||||
|
<h3 className="text-lg font-semibold">Members</h3>
|
||||||
|
<p className="text-neutral-700 dark:text-neutral-300">
|
||||||
|
Only allowed users can publish event to your Depot
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="inline-flex items-center gap-2">
|
||||||
|
<div className="isolate flex -space-x-2">
|
||||||
|
{tmpMembers.slice(0, 5).map((item) => (
|
||||||
|
<User key={item} pubkey={item} variant="stacked" />
|
||||||
|
))}
|
||||||
|
{tmpMembers.length > 5 ? (
|
||||||
|
<div className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-neutral-200 text-neutral-900 ring-1 ring-neutral-300 dark:bg-neutral-800 dark:text-neutral-100 dark:ring-neutral-700">
|
||||||
|
<span className="text-xs font-medium">
|
||||||
|
+{tmpMembers.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<Dialog.Trigger className="inline-flex h-8 w-max items-center justify-center gap-1 rounded-lg bg-blue-500 px-3 text-white hover:bg-blue-600">
|
||||||
|
<UserAddIcon className="size-4" />
|
||||||
|
Manage
|
||||||
|
</Dialog.Trigger>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/10 backdrop-blur-sm dark:bg-black/10" />
|
||||||
|
<Dialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
|
||||||
|
<div className="relative h-min w-full max-w-xl overflow-hidden rounded-xl bg-white dark:bg-black">
|
||||||
|
<div className="inline-flex h-14 w-full shrink-0 items-center justify-between border-b border-neutral-100 px-5 dark:border-neutral-900">
|
||||||
|
<Dialog.Title className="text-center font-semibold">
|
||||||
|
Manage member
|
||||||
|
</Dialog.Title>
|
||||||
|
<div className="inline-flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={updateMembers}
|
||||||
|
className="inline-flex h-8 w-max items-center justify-center rounded-lg bg-blue-500 px-2.5 text-sm font-medium text-white hover:bg-blue-600"
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</button>
|
||||||
|
<Dialog.Close className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-neutral-100 hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800">
|
||||||
|
<CancelIcon className="size-4" />
|
||||||
|
</Dialog.Close>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="pb-3">
|
||||||
|
<div className="relative mb-2 mt-4 w-full px-5">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
spellCheck={false}
|
||||||
|
value={newMember}
|
||||||
|
onChange={(e) => setNewMember(e.target.value)}
|
||||||
|
placeholder="npub1..."
|
||||||
|
className="h-11 w-full rounded-lg border-transparent bg-neutral-100 pl-3 pr-20 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addMember}
|
||||||
|
className="absolute right-7 top-1/2 inline-flex h-7 w-max -translate-y-1/2 transform items-center justify-center gap-1 rounded-md bg-neutral-200 px-2.5 text-sm font-medium text-blue-500 hover:bg-neutral-200 dark:bg-neutral-800 dark:hover:bg-neutral-800"
|
||||||
|
>
|
||||||
|
<PlusIcon className="size-4" />
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{tmpMembers.map((member) => (
|
||||||
|
<div
|
||||||
|
key={member}
|
||||||
|
className="group flex items-center justify-between px-5 py-2 hover:bg-neutral-100 dark:hover:bg-neutral-900"
|
||||||
|
>
|
||||||
|
<User pubkey={member} variant="simple" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeMember(member)}
|
||||||
|
className="hidden size-6 items-center justify-center rounded-md bg-neutral-200 group-hover:inline-flex hover:bg-red-200 dark:bg-neutral-800 dark:hover:bg-red-800 dark:hover:text-red-200"
|
||||||
|
>
|
||||||
|
<UserRemoveIcon className="size-4 text-red-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
apps/desktop/src/routes/depot/components/profile.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { useArk } from "@lume/ark";
|
||||||
|
import { LoaderIcon, RunIcon } from "@lume/icons";
|
||||||
|
import { useStorage } from "@lume/storage";
|
||||||
|
import { User } from "@lume/ui";
|
||||||
|
import { NDKKind } from "@nostr-dev-kit/ndk";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export function DepotProfileCard() {
|
||||||
|
const ark = useArk();
|
||||||
|
const storage = useStorage();
|
||||||
|
|
||||||
|
const [status, setStatus] = useState(false);
|
||||||
|
|
||||||
|
const backupProfile = async () => {
|
||||||
|
try {
|
||||||
|
setStatus(true);
|
||||||
|
|
||||||
|
const event = await ark.getEventByFilter({
|
||||||
|
filter: {
|
||||||
|
authors: [ark.account.pubkey],
|
||||||
|
kinds: [NDKKind.Metadata],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// broadcast to depot
|
||||||
|
const publish = await event.publish();
|
||||||
|
|
||||||
|
if (publish) {
|
||||||
|
setStatus(false);
|
||||||
|
toast.success("Backup profile successfully.");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(false);
|
||||||
|
toast.error(JSON.stringify(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-56 w-full flex-col gap-2 overflow-hidden rounded-xl bg-neutral-100 p-2 dark:bg-neutral-900">
|
||||||
|
<div className="flex flex-1 items-center justify-center rounded-lg bg-neutral-200 dark:bg-neutral-800">
|
||||||
|
<User pubkey={ark.account.pubkey} variant="simple" />
|
||||||
|
</div>
|
||||||
|
<div className="inline-flex shrink-0 items-center justify-between">
|
||||||
|
<div className="text-sm font-medium">Profile</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={backupProfile}
|
||||||
|
className="inline-flex h-8 w-max items-center justify-center gap-2 rounded-md bg-blue-500 pl-2 pr-3 font-medium text-white shadow shadow-blue-500/50 hover:bg-blue-600"
|
||||||
|
>
|
||||||
|
{status ? (
|
||||||
|
<LoaderIcon className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RunIcon className="size-4" />
|
||||||
|
)}
|
||||||
|
Backup
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
apps/desktop/src/routes/depot/components/relays.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { useArk } from "@lume/ark";
|
||||||
|
import { LoaderIcon, RunIcon } from "@lume/icons";
|
||||||
|
import { useStorage } from "@lume/storage";
|
||||||
|
import { NDKKind } from "@nostr-dev-kit/ndk";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export function DepotRelaysCard() {
|
||||||
|
const ark = useArk();
|
||||||
|
const storage = useStorage();
|
||||||
|
|
||||||
|
const [status, setStatus] = useState(false);
|
||||||
|
const [relaySize, setRelaySize] = useState(0);
|
||||||
|
|
||||||
|
const backupRelays = async () => {
|
||||||
|
try {
|
||||||
|
setStatus(true);
|
||||||
|
|
||||||
|
const event = await ark.getEventByFilter({
|
||||||
|
filter: {
|
||||||
|
authors: [ark.account.pubkey],
|
||||||
|
kinds: [NDKKind.RelayList],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// broadcast to depot
|
||||||
|
const publish = await event.publish();
|
||||||
|
|
||||||
|
if (publish) {
|
||||||
|
setStatus(false);
|
||||||
|
toast.success("Backup profile successfully.");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(false);
|
||||||
|
toast.error(JSON.stringify(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadRelays() {
|
||||||
|
const event = await ark.getEventByFilter({
|
||||||
|
filter: {
|
||||||
|
authors: [ark.account.pubkey],
|
||||||
|
kinds: [NDKKind.RelayList],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (event) setRelaySize(event.tags.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadRelays();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-56 w-full flex-col gap-2 overflow-hidden rounded-xl bg-neutral-100 p-2 dark:bg-neutral-900">
|
||||||
|
<div className="flex flex-1 items-center justify-center rounded-lg bg-neutral-200 dark:bg-neutral-800">
|
||||||
|
<p className="text-lg font-semibold">{relaySize} relays</p>
|
||||||
|
</div>
|
||||||
|
<div className="inline-flex shrink-0 items-center justify-between">
|
||||||
|
<div className="text-sm font-medium">Relay List</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={backupRelays}
|
||||||
|
className="inline-flex h-8 w-max items-center justify-center gap-2 rounded-md bg-blue-500 pl-2 pr-3 font-medium text-white shadow shadow-blue-500/50 hover:bg-blue-600"
|
||||||
|
>
|
||||||
|
{status ? (
|
||||||
|
<LoaderIcon className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RunIcon className="size-4" />
|
||||||
|
)}
|
||||||
|
Backup
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
222
apps/desktop/src/routes/depot/index.tsx
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import { useArk } from "@lume/ark";
|
||||||
|
import { ChevronDownIcon, DepotIcon, GossipIcon } from "@lume/icons";
|
||||||
|
import { useStorage } from "@lume/storage";
|
||||||
|
import { NDKKind } from "@nostr-dev-kit/ndk";
|
||||||
|
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { appConfigDir } from "@tauri-apps/api/path";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { DepotContactCard } from "./components/contact";
|
||||||
|
import { DepotMembers } from "./components/members";
|
||||||
|
import { DepotProfileCard } from "./components/profile";
|
||||||
|
import { DepotRelaysCard } from "./components/relays";
|
||||||
|
|
||||||
|
export function DepotScreen() {
|
||||||
|
const ark = useArk();
|
||||||
|
const storage = useStorage();
|
||||||
|
|
||||||
|
const [dataPath, setDataPath] = useState("");
|
||||||
|
const [tunnelUrl, setTunnelUrl] = useState("");
|
||||||
|
|
||||||
|
const openFolder = async () => {
|
||||||
|
await invoke("show_in_folder", {
|
||||||
|
path: `${dataPath}/nostr.db`,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateRelayList = async () => {
|
||||||
|
try {
|
||||||
|
if (tunnelUrl.length < 1)
|
||||||
|
return toast.info("Please enter a valid relay url");
|
||||||
|
if (!tunnelUrl.startsWith("ws"))
|
||||||
|
return toast.info("Please enter a valid relay url");
|
||||||
|
|
||||||
|
const relayUrl = new URL(tunnelUrl.replace(/\s/g, ""));
|
||||||
|
if (!/^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/.test(relayUrl.host)) return;
|
||||||
|
|
||||||
|
const relayEvent = await ark.getEventByFilter({
|
||||||
|
filter: {
|
||||||
|
authors: [ark.account.pubkey],
|
||||||
|
kinds: [NDKKind.RelayList],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let publish: { id: string; seens: string[] };
|
||||||
|
|
||||||
|
if (!relayEvent) {
|
||||||
|
publish = await ark.createEvent({
|
||||||
|
kind: NDKKind.RelayList,
|
||||||
|
tags: [["r", tunnelUrl, ""]],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTags = relayEvent.tags ?? [];
|
||||||
|
newTags.push(["r", tunnelUrl, ""]);
|
||||||
|
|
||||||
|
publish = await ark.createEvent({
|
||||||
|
kind: NDKKind.RelayList,
|
||||||
|
tags: newTags,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (publish) {
|
||||||
|
await storage.createSetting("tunnel_url", tunnelUrl);
|
||||||
|
toast.success("Update relay list successfully.");
|
||||||
|
|
||||||
|
setTunnelUrl("");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
toast.error("Error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadConfig() {
|
||||||
|
const appDir = await appConfigDir();
|
||||||
|
setDataPath(appDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadConfig();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full rounded-xl shadow-[rgba(50,_50,_105,_0.15)_0px_2px_5px_0px,_rgba(0,_0,_0,_0.05)_0px_1px_1px_0px] dark:shadow-[inset_0_0_0.5px_1px_hsla(0,0%,100%,0.075),0_0_0_1px_hsla(0,0%,0%,0.05),0_0.3px_0.4px_hsla(0,0%,0%,0.02),0_0.9px_1.5px_hsla(0,0%,0%,0.045),0_3.5px_6px_hsla(0,0%,0%,0.09)]">
|
||||||
|
<div className="h-full w-72 shrink-0 rounded-l-xl bg-white/50 px-8 pt-8 backdrop-blur-xl dark:bg-black/50">
|
||||||
|
<div className="flex flex-col justify-center gap-4">
|
||||||
|
<div className="size-16 rounded-xl bg-gradient-to-bl from-teal-300 to-teal-600 p-1">
|
||||||
|
<div className="relative inline-flex h-full w-full items-center justify-center overflow-hidden rounded-lg bg-gradient-to-bl from-teal-400 to-teal-700 shadow-sm shadow-white/20">
|
||||||
|
<DepotIcon className="size-8 text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-semibold">Depot is running</h1>
|
||||||
|
</div>
|
||||||
|
<div className="mt-8 flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="text-sm font-medium">Relay URL</div>
|
||||||
|
<div className="inline-flex h-10 w-full select-text items-center rounded-lg bg-black/10 px-3 text-sm backdrop-blur-xl dark:bg-white/10">
|
||||||
|
ws://localhost:6090
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="text-sm font-medium">Database</div>
|
||||||
|
<div className="inline-flex h-10 w-full items-center gap-2 truncate rounded-lg bg-black/10 p-1 backdrop-blur-xl dark:bg-white/10">
|
||||||
|
<p className="shrink-0 pl-2 text-sm">nostr.db (SQLite)</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openFolder}
|
||||||
|
className="inline-flex h-full w-full items-center justify-center rounded-md bg-white text-sm font-medium shadow hover:bg-blue-500 hover:text-white dark:bg-black"
|
||||||
|
>
|
||||||
|
Open
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto rounded-r-xl bg-white pb-20 dark:bg-black">
|
||||||
|
<div className="mb-5 flex h-12 items-center border-b border-neutral-100 px-5 dark:border-neutral-900">
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||||
|
Actions
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-5 px-5">
|
||||||
|
<Collapsible.Root
|
||||||
|
defaultOpen
|
||||||
|
className="flex flex-col overflow-hidden rounded-xl border border-transparent bg-neutral-50 data-[state=open]:border-blue-500 dark:bg-neutral-950"
|
||||||
|
>
|
||||||
|
<Collapsible.Trigger className="flex h-20 items-center justify-between px-5 hover:bg-neutral-100 dark:hover:bg-neutral-900">
|
||||||
|
<div className="flex flex-col items-start">
|
||||||
|
<h3 className="text-lg font-semibold">Expose</h3>
|
||||||
|
<p className="text-neutral-700 dark:text-neutral-300">
|
||||||
|
Make your Depot visible in the Internet, everyone can connect
|
||||||
|
into it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ChevronDownIcon className="size-5 shrink-0" />
|
||||||
|
</Collapsible.Trigger>
|
||||||
|
<Collapsible.Content>
|
||||||
|
<div className="flex w-full flex-col gap-4 p-5">
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 font-medium">ngrok</p>
|
||||||
|
<input
|
||||||
|
readOnly
|
||||||
|
value="ngrok http --domain=<your_domain> 6090"
|
||||||
|
className="h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 font-medium">Cloudflare Tunnel</p>
|
||||||
|
<input
|
||||||
|
readOnly
|
||||||
|
value="cloudflared tunnel --url localhost:6090"
|
||||||
|
className="h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 font-medium">Local Tunnel</p>
|
||||||
|
<input
|
||||||
|
readOnly
|
||||||
|
value="lt --port 6090"
|
||||||
|
className="h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 border-t border-neutral-100 pt-4 dark:border-neutral-900">
|
||||||
|
<div className="inline-flex items-center gap-2">
|
||||||
|
<GossipIcon className="size-5 text-blue-500" />
|
||||||
|
<h3 className="mb-1 font-semibold">
|
||||||
|
Support Gossip Model (Recommended)
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full max-w-xl">
|
||||||
|
<p className=" text-balance">
|
||||||
|
By adding to Relay List, other Nostr Client which support
|
||||||
|
Gossip Model will automatically connect to your Depot and
|
||||||
|
improve the discoverability.
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 inline-flex w-full items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={tunnelUrl}
|
||||||
|
onChange={(e) => setTunnelUrl(e.target.value)}
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder="wss://"
|
||||||
|
className="h-10 flex-1 rounded-lg border-transparent bg-neutral-100 px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={updateRelayList}
|
||||||
|
className="inline-flex h-10 w-max shrink-0 items-center justify-center rounded-lg bg-blue-500 px-4 font-medium text-white hover:bg-blue-600"
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Collapsible.Content>
|
||||||
|
</Collapsible.Root>
|
||||||
|
<Collapsible.Root className="flex flex-col overflow-hidden rounded-xl border border-transparent bg-neutral-50 data-[state=open]:border-blue-500 dark:bg-neutral-950">
|
||||||
|
<Collapsible.Trigger className="flex h-20 items-center justify-between px-5 hover:bg-neutral-100 dark:hover:bg-neutral-900">
|
||||||
|
<div className="flex flex-col items-start">
|
||||||
|
<h3 className="text-lg font-semibold">Backup (Recommended)</h3>
|
||||||
|
<p className="text-neutral-700 dark:text-neutral-300">
|
||||||
|
Backup all your data to Depot, it always live on your machine.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ChevronDownIcon className="size-5 shrink-0" />
|
||||||
|
</Collapsible.Trigger>
|
||||||
|
<Collapsible.Content>
|
||||||
|
<div className="grid grid-cols-3 gap-4 px-5 py-5">
|
||||||
|
<DepotProfileCard />
|
||||||
|
<DepotContactCard />
|
||||||
|
<DepotRelaysCard />
|
||||||
|
</div>
|
||||||
|
</Collapsible.Content>
|
||||||
|
</Collapsible.Root>
|
||||||
|
<DepotMembers />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
apps/desktop/src/routes/depot/onboarding.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { useArk } from "@lume/ark";
|
||||||
|
import { LoaderIcon } from "@lume/icons";
|
||||||
|
import { delay, VITE_FLATPAK_RESOURCE } from "@lume/utils";
|
||||||
|
import { resolve, resolveResource } from "@tauri-apps/api/path";
|
||||||
|
import { useStorage } from "@lume/storage";
|
||||||
|
import { readTextFile, writeTextFile } from "@tauri-apps/plugin-fs";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { parse, stringify } from "smol-toml";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export function DepotOnboardingScreen() {
|
||||||
|
const ark = useArk();
|
||||||
|
const storage = useStorage();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const launchDepot = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// get default config
|
||||||
|
const defaultConfig =
|
||||||
|
VITE_FLATPAK_RESOURCE !== null
|
||||||
|
? await resolve("/", VITE_FLATPAK_RESOURCE)
|
||||||
|
: await resolveResource("resources/config.toml");
|
||||||
|
const config = await readTextFile(defaultConfig);
|
||||||
|
const parsedConfig = parse(config);
|
||||||
|
|
||||||
|
// add current user to whitelist
|
||||||
|
// biome-ignore lint/complexity/useLiteralKeys: <explanation>
|
||||||
|
parsedConfig.authorization["pubkey_whitelist"].push(ark.account.pubkey);
|
||||||
|
|
||||||
|
// update new config
|
||||||
|
const newConfig = stringify(parsedConfig);
|
||||||
|
await writeTextFile(defaultConfig, newConfig);
|
||||||
|
|
||||||
|
// launch depot
|
||||||
|
await storage.launchDepot();
|
||||||
|
await storage.createSetting("depot", "1");
|
||||||
|
await delay(2000); // delay 2s to make sure depot is running
|
||||||
|
|
||||||
|
// default depot url: ws://localhost:6090
|
||||||
|
// #TODO: user can custom depot url
|
||||||
|
const connect = await ark.connectDepot();
|
||||||
|
|
||||||
|
if (connect) {
|
||||||
|
toast.success("Your Depot is successfully launch.");
|
||||||
|
setLoading(false);
|
||||||
|
|
||||||
|
navigate("/depot/");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col items-center justify-center gap-10 rounded-xl bg-white shadow-[rgba(50,_50,_105,_0.15)_0px_2px_5px_0px,_rgba(0,_0,_0,_0.05)_0px_1px_1px_0px] dark:bg-black dark:shadow-[inset_0_0_0.5px_1px_hsla(0,0%,100%,0.075),0_0_0_1px_hsla(0,0%,0%,0.05),0_0.3px_0.4px_hsla(0,0%,0%,0.02),0_0.9px_1.5px_hsla(0,0%,0%,0.045),0_3.5px_6px_hsla(0,0%,0%,0.09)]">
|
||||||
|
<div className="flex flex-col items-center gap-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="mb-1 text-3xl font-semibold text-neutral-400 dark:text-neutral-600">
|
||||||
|
Run your Personal Nostr Relay inside Lume
|
||||||
|
</h1>
|
||||||
|
<h2 className="text-4xl font-semibold">Your Relay, Your Control.</h2>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl bg-blue-100 p-1.5 dark:bg-blue-900">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={launchDepot}
|
||||||
|
className="inline-flex h-11 w-36 transform items-center justify-center gap-2 rounded-lg bg-blue-500 font-medium text-white active:translate-y-1"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<LoaderIcon className="h-5 w-5 animate-spin" />
|
||||||
|
Launching...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
className="h-5 w-5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M12 2.25a.75.75 0 0 1 .75.75v9a.75.75 0 0 1-1.5 0V3a.75.75 0 0 1 .75-.75ZM6.166 5.106a.75.75 0 0 1 0 1.06 8.25 8.25 0 1 0 11.668 0 .75.75 0 1 1 1.06-1.06c3.808 3.807 3.808 9.98 0 13.788-3.807 3.808-9.98 3.808-13.788 0-3.808-3.807-3.808-9.98 0-13.788a.75.75 0 0 1 1.06 0Z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Launch
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
159
apps/desktop/src/routes/error.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { useArk } from "@lume/ark";
|
||||||
|
import { useStorage } from "@lume/storage";
|
||||||
|
import { downloadDir } from "@tauri-apps/api/path";
|
||||||
|
import { message, save } from "@tauri-apps/plugin-dialog";
|
||||||
|
import { writeTextFile } from "@tauri-apps/plugin-fs";
|
||||||
|
import { relaunch } from "@tauri-apps/plugin-process";
|
||||||
|
import { useRouteError } from "react-router-dom";
|
||||||
|
|
||||||
|
interface RouteError {
|
||||||
|
statusText: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorScreen() {
|
||||||
|
const ark = useArk();
|
||||||
|
const storage = useStorage();
|
||||||
|
const error = useRouteError() as RouteError;
|
||||||
|
|
||||||
|
const restart = async () => {
|
||||||
|
await relaunch();
|
||||||
|
};
|
||||||
|
|
||||||
|
const download = async () => {
|
||||||
|
try {
|
||||||
|
const downloadPath = await downloadDir();
|
||||||
|
const fileName = `nostr_keys_${new Date().toISOString()}.txt`;
|
||||||
|
const filePath = await save({
|
||||||
|
defaultPath: `${downloadPath}/${fileName}`,
|
||||||
|
});
|
||||||
|
const nsec = await storage.loadPrivkey(ark.account.pubkey);
|
||||||
|
|
||||||
|
if (filePath) {
|
||||||
|
if (nsec) {
|
||||||
|
await writeTextFile(
|
||||||
|
filePath,
|
||||||
|
`Nostr account, generated by Lume (lume.nu)\nPublic key: ${ark.account.id}\nPrivate key: ${nsec}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await writeTextFile(
|
||||||
|
filePath,
|
||||||
|
`Nostr account, generated by Lume (lume.nu)\nPublic key: ${ark.account.id}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} // else { user cancel action }
|
||||||
|
} catch (e) {
|
||||||
|
await message(e, {
|
||||||
|
title: "Cannot download account keys",
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-tauri-drag-region
|
||||||
|
className="relative flex h-screen w-screen items-center justify-center bg-blue-500 overflow-hidden rounded-xl"
|
||||||
|
>
|
||||||
|
<div className="flex w-full max-w-2xl flex-col items-start gap-8">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<h1 className="mb-3 text-4xl font-semibold text-blue-400">
|
||||||
|
Sorry, an unexpected error has occurred.
|
||||||
|
</h1>
|
||||||
|
<h3 className="text-3xl font-semibold leading-snug text-white">
|
||||||
|
Don't panic, your account is safe.
|
||||||
|
<br />
|
||||||
|
Here are what things you can do:
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full flex-col gap-3">
|
||||||
|
<div className="flex items-center justify-between rounded-xl bg-blue-700 px-3 py-4">
|
||||||
|
<div className="text-xl font-semibold text-white">
|
||||||
|
1. Try to close and re-open the app
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => restart()}
|
||||||
|
className="h-9 w-28 rounded-lg bg-blue-800 px-3 font-medium text-white hover:bg-blue-900"
|
||||||
|
>
|
||||||
|
Restart
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between rounded-xl bg-blue-700 px-3 py-4">
|
||||||
|
<div className="text-xl font-semibold text-white">
|
||||||
|
2. Backup Nostr account
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => download()}
|
||||||
|
className="h-9 w-28 rounded-lg bg-blue-800 px-3 font-medium text-white hover:bg-blue-900"
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl bg-blue-700 px-3 py-4">
|
||||||
|
<div className="flex w-full flex-col gap-2">
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
<div className="text-xl font-semibold text-white">
|
||||||
|
3. Report this issue to Lume
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="https://github.com/lumehq/lume/issues/new"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex h-9 w-28 items-center justify-center rounded-lg bg-blue-800 px-3 font-medium text-white hover:bg-blue-900"
|
||||||
|
>
|
||||||
|
Report
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="inline-flex h-16 items-center justify-center overflow-y-auto rounded-lg border border-dashed border-red-300 bg-blue-800 px-5">
|
||||||
|
<p className="select-text break-all text-red-400">
|
||||||
|
{error.statusText || error.message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl bg-blue-700 px-3 py-4">
|
||||||
|
<div className="flex w-full flex-col gap-1.5">
|
||||||
|
<div className="text-xl font-semibold text-white">
|
||||||
|
4. Use another Nostr client
|
||||||
|
</div>
|
||||||
|
<div className="select-text text-lg font-medium text-blue-300">
|
||||||
|
<p>
|
||||||
|
While waiting for Lume release the bug fixes, you always can
|
||||||
|
use other Nostr clients with your account:
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex flex-col gap-1 text-white">
|
||||||
|
<a
|
||||||
|
className="hover:!underline"
|
||||||
|
href="https://snort.social/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
snort.social
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
className="hover:!underline"
|
||||||
|
href="https://nostter.app/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
nostter
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
className="hover:!underline"
|
||||||
|
href="https://nostrudel.ninja/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
nostrudel.ninja
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
201
apps/desktop/src/routes/home/index.tsx
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import { Antenas } from "@columns/antenas";
|
||||||
|
import { Default } from "@columns/default";
|
||||||
|
import { ForYou } from "@columns/foryou";
|
||||||
|
import { Global } from "@columns/global";
|
||||||
|
import { Group } from "@columns/group";
|
||||||
|
import { Hashtag } from "@columns/hashtag";
|
||||||
|
import { Thread } from "@columns/thread";
|
||||||
|
import { Timeline } from "@columns/timeline";
|
||||||
|
import { TrendingNotes } from "@columns/trending-notes";
|
||||||
|
import { User } from "@columns/user";
|
||||||
|
import { Waifu } from "@columns/waifu";
|
||||||
|
import { useColumnContext } from "@lume/ark";
|
||||||
|
import {
|
||||||
|
ArrowLeftIcon,
|
||||||
|
ArrowRightIcon,
|
||||||
|
PlusIcon,
|
||||||
|
PlusSquareIcon,
|
||||||
|
} from "@lume/icons";
|
||||||
|
import { IColumn } from "@lume/types";
|
||||||
|
import { TutorialModal } from "@lume/ui/src/tutorial/modal";
|
||||||
|
import { COL_TYPES } from "@lume/utils";
|
||||||
|
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { VList } from "virtua";
|
||||||
|
|
||||||
|
export function HomeScreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { columns, vlistRef, addColumn } = useColumnContext();
|
||||||
|
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||||
|
|
||||||
|
const renderItem = (column: IColumn) => {
|
||||||
|
switch (column.kind) {
|
||||||
|
case COL_TYPES.default:
|
||||||
|
return <Default key={column.id} column={column} />;
|
||||||
|
case COL_TYPES.newsfeed:
|
||||||
|
return <Timeline key={column.id} column={column} />;
|
||||||
|
case COL_TYPES.foryou:
|
||||||
|
return <ForYou key={column.id} column={column} />;
|
||||||
|
case COL_TYPES.thread:
|
||||||
|
return <Thread key={column.id} column={column} />;
|
||||||
|
case COL_TYPES.user:
|
||||||
|
return <User key={column.id} column={column} />;
|
||||||
|
case COL_TYPES.hashtag:
|
||||||
|
return <Hashtag key={column.id} column={column} />;
|
||||||
|
case COL_TYPES.group:
|
||||||
|
return <Group key={column.id} column={column} />;
|
||||||
|
case COL_TYPES.antenas:
|
||||||
|
return <Antenas key={column.id} column={column} />;
|
||||||
|
case COL_TYPES.global:
|
||||||
|
return <Global key={column.id} column={column} />;
|
||||||
|
case COL_TYPES.trendingNotes:
|
||||||
|
return <TrendingNotes key={column.id} column={column} />;
|
||||||
|
case COL_TYPES.waifu:
|
||||||
|
return <Waifu key={column.id} column={column} />;
|
||||||
|
default:
|
||||||
|
return <Default key={column.id} column={column} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full h-full">
|
||||||
|
<VList
|
||||||
|
ref={vlistRef}
|
||||||
|
className="h-full w-full flex-nowrap overflow-x-auto !overflow-y-hidden scrollbar-none focus:outline-none"
|
||||||
|
itemSize={420}
|
||||||
|
tabIndex={0}
|
||||||
|
horizontal
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (!vlistRef.current) return;
|
||||||
|
switch (e.code) {
|
||||||
|
case "ArrowUp":
|
||||||
|
case "ArrowLeft": {
|
||||||
|
e.preventDefault();
|
||||||
|
const prevIndex = Math.max(selectedIndex - 1, 0);
|
||||||
|
setSelectedIndex(prevIndex);
|
||||||
|
vlistRef.current.scrollToIndex(prevIndex, {
|
||||||
|
align: "center",
|
||||||
|
smooth: true,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "ArrowDown":
|
||||||
|
case "ArrowRight": {
|
||||||
|
e.preventDefault();
|
||||||
|
const nextIndex = Math.min(selectedIndex + 1, columns.length - 1);
|
||||||
|
setSelectedIndex(nextIndex);
|
||||||
|
vlistRef.current.scrollToIndex(nextIndex, {
|
||||||
|
align: "center",
|
||||||
|
smooth: true,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{columns.map((column) => renderItem(column))}
|
||||||
|
<div className="w-[420px] h-full flex items-center justify-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () =>
|
||||||
|
await addColumn({
|
||||||
|
kind: COL_TYPES.default,
|
||||||
|
title: "",
|
||||||
|
content: "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="size-16 inline-flex items-center justify-center hover:bg-neutral-100 dark:hover:bg-neutral-900 rounded-2xl"
|
||||||
|
>
|
||||||
|
<PlusIcon className="size-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</VList>
|
||||||
|
<Tooltip.Provider>
|
||||||
|
<div className="absolute bottom-3 right-3">
|
||||||
|
<div className="flex items-center gap-1 p-1 bg-black/50 dark:bg-white/30 backdrop-blur-xl rounded-xl shadow-toolbar">
|
||||||
|
<Tooltip.Root delayDuration={150}>
|
||||||
|
<Tooltip.Trigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const prevIndex = Math.max(selectedIndex - 1, 0);
|
||||||
|
setSelectedIndex(prevIndex);
|
||||||
|
vlistRef.current.scrollToIndex(prevIndex, {
|
||||||
|
align: "center",
|
||||||
|
smooth: true,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center justify-center rounded-lg text-white/70 hover:text-white hover:bg-black/30 size-10"
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className="size-5" />
|
||||||
|
</button>
|
||||||
|
</Tooltip.Trigger>
|
||||||
|
<Tooltip.Portal>
|
||||||
|
<Tooltip.Content className="inline-flex h-7 select-none text-neutral-50 dark:text-neutral-950 items-center justify-center rounded-md bg-neutral-950 dark:bg-neutral-50 px-3.5 text-sm will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade">
|
||||||
|
{t("global.moveLeft")}
|
||||||
|
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||||
|
</Tooltip.Content>
|
||||||
|
</Tooltip.Portal>
|
||||||
|
</Tooltip.Root>
|
||||||
|
<Tooltip.Root delayDuration={150}>
|
||||||
|
<Tooltip.Trigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const nextIndex = Math.min(
|
||||||
|
selectedIndex + 1,
|
||||||
|
columns.length - 1,
|
||||||
|
);
|
||||||
|
setSelectedIndex(nextIndex);
|
||||||
|
vlistRef.current.scrollToIndex(nextIndex, {
|
||||||
|
align: "center",
|
||||||
|
smooth: true,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center justify-center rounded-lg text-white/70 hover:text-white hover:bg-black/30 size-10"
|
||||||
|
>
|
||||||
|
<ArrowRightIcon className="size-5" />
|
||||||
|
</button>
|
||||||
|
</Tooltip.Trigger>
|
||||||
|
<Tooltip.Portal>
|
||||||
|
<Tooltip.Content className="inline-flex h-7 select-none text-neutral-50 dark:text-neutral-950 items-center justify-center rounded-md bg-neutral-950 dark:bg-neutral-50 px-3.5 text-sm will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade">
|
||||||
|
{t("global.moveRight")}
|
||||||
|
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||||
|
</Tooltip.Content>
|
||||||
|
</Tooltip.Portal>
|
||||||
|
</Tooltip.Root>
|
||||||
|
<Tooltip.Root delayDuration={150}>
|
||||||
|
<Tooltip.Trigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () =>
|
||||||
|
await addColumn({
|
||||||
|
kind: COL_TYPES.default,
|
||||||
|
title: "",
|
||||||
|
content: "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="inline-flex items-center justify-center rounded-lg text-white/70 hover:text-white hover:bg-black/30 size-10"
|
||||||
|
>
|
||||||
|
<PlusSquareIcon className="size-5" />
|
||||||
|
</button>
|
||||||
|
</Tooltip.Trigger>
|
||||||
|
<Tooltip.Portal>
|
||||||
|
<Tooltip.Content className="inline-flex h-7 select-none text-neutral-50 dark:text-neutral-950 items-center justify-center rounded-md bg-neutral-950 dark:bg-neutral-50 px-3.5 text-sm will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade">
|
||||||
|
{t("global.newColumn")}
|
||||||
|
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
|
||||||
|
</Tooltip.Content>
|
||||||
|
</Tooltip.Portal>
|
||||||
|
</Tooltip.Root>
|
||||||
|
<div className="w-px h-6 bg-white/10" />
|
||||||
|
<TutorialModal />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tooltip.Provider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
apps/desktop/src/routes/relays/components/relayEventList.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { NoteSkeleton, RepostNote, TextNote, useArk } from "@lume/ark";
|
||||||
|
import { ArrowRightCircleIcon, LoaderIcon } from "@lume/icons";
|
||||||
|
import { FETCH_LIMIT } from "@lume/utils";
|
||||||
|
import { NDKEvent, NDKKind } from "@nostr-dev-kit/ndk";
|
||||||
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { VList } from "virtua";
|
||||||
|
|
||||||
|
export function RelayEventList({ relayUrl }: { relayUrl: string }) {
|
||||||
|
const ark = useArk();
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
|
||||||
|
useInfiniteQuery({
|
||||||
|
queryKey: ["relay-events", relayUrl],
|
||||||
|
initialPageParam: 0,
|
||||||
|
queryFn: async ({
|
||||||
|
signal,
|
||||||
|
pageParam,
|
||||||
|
}: {
|
||||||
|
signal: AbortSignal;
|
||||||
|
pageParam: number;
|
||||||
|
}) => {
|
||||||
|
const url = `wss://${relayUrl}`;
|
||||||
|
const events = await ark.getRelayEvents({
|
||||||
|
relayUrl: url,
|
||||||
|
filter: {
|
||||||
|
kinds: [NDKKind.Text, NDKKind.Repost],
|
||||||
|
},
|
||||||
|
limit: FETCH_LIMIT,
|
||||||
|
pageParam,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
return events;
|
||||||
|
},
|
||||||
|
getNextPageParam: (lastPage) => {
|
||||||
|
const lastEvent = lastPage.at(-1);
|
||||||
|
if (!lastEvent) return;
|
||||||
|
return lastEvent.created_at - 1;
|
||||||
|
},
|
||||||
|
select: (data) => data?.pages.flatMap((page) => page),
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderItem = useCallback(
|
||||||
|
(event: NDKEvent) => {
|
||||||
|
switch (event.kind) {
|
||||||
|
case NDKKind.Text:
|
||||||
|
return <TextNote key={event.id} event={event} className="mt-3" />;
|
||||||
|
case NDKKind.Repost:
|
||||||
|
return <RepostNote key={event.id} event={event} className="mt-3" />;
|
||||||
|
default:
|
||||||
|
return <TextNote key={event.id} event={event} className="mt-3" />;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[data],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VList className="mx-auto h-full w-full max-w-[500px] px-3 scrollbar-none">
|
||||||
|
{status === "pending" ? (
|
||||||
|
<NoteSkeleton />
|
||||||
|
) : (
|
||||||
|
data.map((item) => renderItem(item))
|
||||||
|
)}
|
||||||
|
<div className="flex h-16 items-center justify-center px-3 pb-3">
|
||||||
|
{hasNextPage ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fetchNextPage()}
|
||||||
|
disabled={!hasNextPage || isFetchingNextPage}
|
||||||
|
className="inline-flex h-10 w-max items-center justify-center gap-2 rounded-full bg-blue-500 px-6 font-medium text-white hover:bg-blue-600 focus:outline-none"
|
||||||
|
>
|
||||||
|
{isFetchingNextPage ? (
|
||||||
|
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ArrowRightCircleIcon className="h-5 w-5" />
|
||||||
|
{t("global.loading")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</VList>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
apps/desktop/src/routes/relays/components/relayForm.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { useRelaylist } from "@lume/ark";
|
||||||
|
import { PlusIcon } from "@lume/icons";
|
||||||
|
import { normalizeRelayUrl } from "nostr-fetch";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export function RelayForm() {
|
||||||
|
const { connectRelay } = useRelaylist();
|
||||||
|
|
||||||
|
const [relay, setRelay] = useState<{
|
||||||
|
url: WebSocket["url"];
|
||||||
|
purpose: "read" | "write" | undefined;
|
||||||
|
}>({ url: "", purpose: undefined });
|
||||||
|
|
||||||
|
const create = () => {
|
||||||
|
if (relay.url.length < 1) return toast.info("Please enter relay url");
|
||||||
|
try {
|
||||||
|
const relayUrl = new URL(relay.url.replace(/\s/g, ""));
|
||||||
|
|
||||||
|
if (relayUrl.protocol === "wss:" || relayUrl.protocol === "ws:") {
|
||||||
|
connectRelay.mutate(normalizeRelayUrl(relay.url));
|
||||||
|
setRelay({ url: "", purpose: undefined });
|
||||||
|
} else {
|
||||||
|
return toast.error(
|
||||||
|
"URL is invalid, a relay must use websocket protocol (start with wss:// or ws://). Please check again",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return toast.error("Relay URL is not valid. Please check again");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
className="h-11 w-full rounded-lg border-transparent px-3 placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 bg-white/50 dark:bg-black/50 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
||||||
|
placeholder="wss://"
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
autoCapitalize="off"
|
||||||
|
value={relay.url}
|
||||||
|
onChange={(e) => setRelay((prev) => ({ ...prev, url: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => create()}
|
||||||
|
className="inline-flex size-11 shrink-0 items-center justify-center rounded-lg bg-blue-500 text-white hover:bg-blue-600"
|
||||||
|
>
|
||||||
|
<PlusIcon className="size-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
apps/desktop/src/routes/relays/components/relayItem.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { User, useRelaylist } from "@lume/ark";
|
||||||
|
import { PlusIcon, SearchIcon } from "@lume/icons";
|
||||||
|
import { normalizeRelayUrl } from "nostr-fetch";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
export function RelayItem({ url, users }: { url: string; users?: string[] }) {
|
||||||
|
const domain = new URL(url).hostname;
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { connectRelay } = useRelaylist();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-14 w-full items-center justify-between border-b border-neutral-100 px-5 dark:border-neutral-950">
|
||||||
|
<div className="inline-flex items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold text-neutral-500 dark:text-neutral-400">
|
||||||
|
{t("global.relay")}:{" "}
|
||||||
|
</span>
|
||||||
|
<span className="max-w-[200px] truncate text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||||
|
{url}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="inline-flex items-center gap-2">
|
||||||
|
{users ? (
|
||||||
|
<div className="isolate flex -space-x-2 mr-4">
|
||||||
|
{users.slice(0, 4).map((item) => (
|
||||||
|
<User.Provider pubkey={item}>
|
||||||
|
<User.Root>
|
||||||
|
<User.Avatar className="size-8 inline-block rounded-full ring-1 ring-neutral-100 dark:ring-neutral-900" />
|
||||||
|
</User.Root>
|
||||||
|
</User.Provider>
|
||||||
|
))}
|
||||||
|
{users.length > 4 ? (
|
||||||
|
<div className="inline-flex size-8 items-center justify-center rounded-full bg-neutral-100 text-neutral-900 ring-1 ring-neutral-200 dark:bg-neutral-900 dark:text-neutral-100 dark:ring-neutral-800">
|
||||||
|
<span className="text-xs font-medium">+{users.length - 4}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<Link
|
||||||
|
to={`/relays/${domain}/`}
|
||||||
|
className="inline-flex h-8 items-center justify-center gap-2 rounded-lg bg-neutral-100 px-2 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||||
|
>
|
||||||
|
<SearchIcon className="size-4" />
|
||||||
|
{t("global.inspect")}
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => connectRelay.mutate(normalizeRelayUrl(url))}
|
||||||
|
className="inline-flex size-8 items-center justify-center rounded-lg bg-blue-100 text-blue-500 hover:bg-blue-200 dark:bg-blue-900 hover:dark:bg-blue-800"
|
||||||
|
>
|
||||||
|
<PlusIcon className="size-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
apps/desktop/src/routes/relays/components/sidebar.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { useArk, useRelaylist } from "@lume/ark";
|
||||||
|
import { CancelIcon, LoaderIcon, RefreshIcon } from "@lume/icons";
|
||||||
|
import { cn } from "@lume/utils";
|
||||||
|
import { NDKKind, NDKSubscriptionCacheUsage } from "@nostr-dev-kit/ndk";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { RelayForm } from "./relayForm";
|
||||||
|
|
||||||
|
export function RelaySidebar({ className }: { className?: string }) {
|
||||||
|
const ark = useArk();
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { removeRelay } = useRelaylist();
|
||||||
|
const { status, data, isRefetching, refetch } = useQuery({
|
||||||
|
queryKey: ["relay-personal"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const event = await ark.getEventByFilter({
|
||||||
|
filter: {
|
||||||
|
kinds: [NDKKind.RelayList],
|
||||||
|
authors: [ark.account.pubkey],
|
||||||
|
},
|
||||||
|
cache: NDKSubscriptionCacheUsage.ONLY_RELAY,
|
||||||
|
});
|
||||||
|
if (!event) return [];
|
||||||
|
return event.tags.filter((tag) => tag[0] === "r");
|
||||||
|
},
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentRelays = new Set(
|
||||||
|
ark.ndk.pool.connectedRelays().map((item) => item.url),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-l-xl bg-white/50 backdrop-blur-xl dark:bg-black/50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="inline-flex items-center justify-between w-full h-14 px-3 border-b border-black/10 dark:border-white/10">
|
||||||
|
<h3 className="font-semibold">{t("relays.sidebar.title")}</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => refetch()}
|
||||||
|
className="inline-flex items-center justify-center w-6 h-6 rounded-md shrink-0 hover:bg-neutral-100 dark:hover:bg-neutral-900"
|
||||||
|
>
|
||||||
|
<RefreshIcon
|
||||||
|
className={cn("size-4", isRefetching ? "animate-spin" : "")}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2 px-3 mt-3">
|
||||||
|
{status === "pending" ? (
|
||||||
|
<div className="flex items-center justify-center w-full h-20 rounded-lg bg-black/10 dark:bg-white/10">
|
||||||
|
<LoaderIcon className="size-5 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : !data.length ? (
|
||||||
|
<div className="flex items-center justify-center w-full h-20 rounded-lg bg-black/10 dark:bg-white/10">
|
||||||
|
<p className="text-sm font-medium">{t("relays.sidebar.empty")}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
data.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item[1]}
|
||||||
|
className="flex items-center justify-between px-3 rounded-lg group h-11 bg-white/50 dark:bg-black/50"
|
||||||
|
>
|
||||||
|
<div className="inline-flex items-baseline gap-2">
|
||||||
|
{currentRelays.has(item[1]) ? (
|
||||||
|
<span className="relative flex w-2 h-2">
|
||||||
|
<span className="absolute inline-flex w-full h-full bg-green-400 rounded-full opacity-75 animate-ping" />
|
||||||
|
<span className="relative inline-flex w-2 h-2 bg-teal-500 rounded-full" />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="relative flex w-2 h-2">
|
||||||
|
<span className="absolute inline-flex w-full h-full bg-red-400 rounded-full opacity-75 animate-ping" />
|
||||||
|
<span className="relative inline-flex w-2 h-2 bg-red-500 rounded-full" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<p className="max-w-[20rem] truncate text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||||
|
{item[1]
|
||||||
|
.replace("wss://", "")
|
||||||
|
.replace("ws://", "")
|
||||||
|
.replace("/", "")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="inline-flex items-center gap-2">
|
||||||
|
{item[2]?.length ? (
|
||||||
|
<div className="inline-flex items-center justify-center h-6 px-2 text-xs font-medium capitalize rounded w-max bg-neutral-200 dark:bg-neutral-800">
|
||||||
|
{item[2]}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeRelay.mutate(item[1])}
|
||||||
|
className="items-center justify-center hidden size-6 rounded group-hover:inline-flex hover:bg-neutral-300 dark:hover:bg-neutral-700"
|
||||||
|
>
|
||||||
|
<CancelIcon className="size-4 text-neutral-900 dark:text-neutral-100" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<RelayForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
apps/desktop/src/routes/relays/follows.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { useArk } from "@lume/ark";
|
||||||
|
import { LoaderIcon } from "@lume/icons";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { VList } from "virtua";
|
||||||
|
import { RelayItem } from "./components/relayItem";
|
||||||
|
|
||||||
|
export function RelayFollowsScreen() {
|
||||||
|
const ark = useArk();
|
||||||
|
const {
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
data: relays,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["relay-follows"],
|
||||||
|
queryFn: async ({ signal }: { signal: AbortSignal }) => {
|
||||||
|
const data = await ark.getAllRelaysFromContacts({ signal });
|
||||||
|
if (!data) throw new Error("Failed to get relay list from contacts");
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
|
<LoaderIcon className="size-5 animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !relays) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
|
<p>Error</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VList itemSize={49}>
|
||||||
|
{[...relays].map(([key, value]) => (
|
||||||
|
<RelayItem key={key} url={key} users={value} />
|
||||||
|
))}
|
||||||
|
</VList>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
apps/desktop/src/routes/relays/global.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { LoaderIcon } from "@lume/icons";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { fetch } from "@tauri-apps/plugin-http";
|
||||||
|
import { VList } from "virtua";
|
||||||
|
import { RelayItem } from "./components/relayItem";
|
||||||
|
|
||||||
|
export function RelayGlobalScreen() {
|
||||||
|
const { isLoading, data: relays } = useQuery({
|
||||||
|
queryKey: ["relay-global"],
|
||||||
|
queryFn: async ({ signal }: { signal: AbortSignal }) => {
|
||||||
|
const res = await fetch("https://api.nostr.watch/v1/online", { signal });
|
||||||
|
if (!res.ok) throw new Error("Failed to get online relays");
|
||||||
|
return (await res.json()) as string[];
|
||||||
|
},
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
|
<LoaderIcon className="size-5 animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VList itemSize={49}>
|
||||||
|
{relays.map((item: string) => (
|
||||||
|
<RelayItem key={item} url={item} />
|
||||||
|
))}
|
||||||
|
</VList>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
apps/desktop/src/routes/relays/index.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { cn } from "@lume/utils";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { NavLink, Outlet } from "react-router-dom";
|
||||||
|
import { RelaySidebar } from "./components/sidebar";
|
||||||
|
|
||||||
|
export function RelaysScreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid h-full w-full lg:grid-cols-4 xl:grid-cols-5 rounded-xl shadow-[rgba(50,_50,_105,_0.15)_0px_2px_5px_0px,_rgba(0,_0,_0,_0.05)_0px_1px_1px_0px] dark:shadow-none dark:ring-1 dark:ring-white/10">
|
||||||
|
<RelaySidebar className="col-span-1" />
|
||||||
|
<div className="col-span-3 xl:col-span-4 flex flex-col rounded-r-xl bg-white dark:bg-black">
|
||||||
|
<div className="h-14 shrink-0 flex px-5 items-center gap-6 border-b border-neutral-100 dark:border-neutral-950">
|
||||||
|
<NavLink
|
||||||
|
end
|
||||||
|
to={"/relays/"}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cn(
|
||||||
|
"h-9 w-24 rounded-lg inline-flex items-center justify-center font-medium",
|
||||||
|
isActive
|
||||||
|
? "bg-neutral-100 hover:bg-neutral-200 dark:bg-neutral-950 dark:hover:bg-neutral-900"
|
||||||
|
: "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("relays.global")}
|
||||||
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
to={"/relays/follows/"}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cn(
|
||||||
|
"h-9 w-24 rounded-lg inline-flex items-center justify-center font-medium",
|
||||||
|
isActive
|
||||||
|
? "bg-neutral-100 hover:bg-neutral-200 dark:bg-neutral-950 dark:hover:bg-neutral-900"
|
||||||
|
: "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("relays.follows")}
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
160
apps/desktop/src/routes/relays/url.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { LoaderIcon } from "@lume/icons";
|
||||||
|
import { NIP11 } from "@lume/types";
|
||||||
|
import { User } from "@lume/ui";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Await, useLoaderData, useParams } from "react-router-dom";
|
||||||
|
import { RelayEventList } from "./components/relayEventList";
|
||||||
|
|
||||||
|
export function RelayUrlScreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { url } = useParams();
|
||||||
|
|
||||||
|
const data: { relay?: { [key: string]: string } } = useLoaderData();
|
||||||
|
|
||||||
|
const getSoftwareName = (url: string) => {
|
||||||
|
const filename = url.substring(url.lastIndexOf("/") + 1);
|
||||||
|
return filename.replace(".git", "");
|
||||||
|
};
|
||||||
|
|
||||||
|
const titleCase = (s: string) => {
|
||||||
|
return s
|
||||||
|
.replace(/^[-_]*(.)/, (_, c) => c.toUpperCase())
|
||||||
|
.replace(/[-_]+(.)/g, (_, c) => ` ${c.toUpperCase()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid h-full w-full grid-cols-3">
|
||||||
|
<div className="col-span-2 border-r border-neutral-100 dark:border-neutral-900">
|
||||||
|
<RelayEventList relayUrl={url} />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-1 px-3 py-3">
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||||
|
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||||
|
{t("global.loading")}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Await
|
||||||
|
resolve={data.relay}
|
||||||
|
errorElement={
|
||||||
|
<div className="text-sm font-medium">
|
||||||
|
<p>{t("relays.relayView.empty")}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(resolvedRelay: NIP11) => (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold">{resolvedRelay.name}</h3>
|
||||||
|
<p className="text-sm text-neutral-600 dark:text-neutral-500">
|
||||||
|
{resolvedRelay.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{resolvedRelay.pubkey ? (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h5 className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
||||||
|
{t("relays.relayView.owner")}:
|
||||||
|
</h5>
|
||||||
|
<div className="w-full rounded-lg bg-neutral-100 px-2 py-2 dark:bg-neutral-900">
|
||||||
|
<User pubkey={resolvedRelay.pubkey} variant="simple" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{resolvedRelay.contact ? (
|
||||||
|
<div>
|
||||||
|
<h5 className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
||||||
|
{t("relays.relayView.contact")}:
|
||||||
|
</h5>
|
||||||
|
<a
|
||||||
|
href={`mailto:${resolvedRelay.contact}`}
|
||||||
|
target="_blank"
|
||||||
|
className="truncate underline after:content-['_↗'] hover:text-blue-500"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
{resolvedRelay.contact}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div>
|
||||||
|
<h5 className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
||||||
|
{t("relays.relayView.software")}:
|
||||||
|
</h5>
|
||||||
|
<a
|
||||||
|
href={resolvedRelay.software}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="underline after:content-['_↗'] hover:text-blue-500"
|
||||||
|
>
|
||||||
|
{`${getSoftwareName(resolvedRelay.software)} - ${
|
||||||
|
resolvedRelay.version
|
||||||
|
}`}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h5 className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
||||||
|
{t("relays.relayView.nips")}:
|
||||||
|
</h5>
|
||||||
|
<div className="mt-2 grid grid-cols-7 gap-2">
|
||||||
|
{resolvedRelay.supported_nips.map((item) => (
|
||||||
|
<a
|
||||||
|
key={item}
|
||||||
|
href={`https://nips.be/${item}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex aspect-square h-auto w-full items-center justify-center rounded bg-neutral-100 text-sm font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-900"
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{resolvedRelay.limitation ? (
|
||||||
|
<div>
|
||||||
|
<h5 className="text-sm font-semibold text-neutral-600 dark:text-neutral-400">
|
||||||
|
{t("relays.relayView.limit")}
|
||||||
|
</h5>
|
||||||
|
<div className="flex flex-col gap-2 divide-y divide-white/5">
|
||||||
|
{Object.keys(resolvedRelay.limitation).map((key) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="flex items-baseline justify-between pt-2"
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||||
|
{titleCase(key)}:
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-medium text-neutral-600 dark:text-neutral-400">
|
||||||
|
{resolvedRelay.limitation[key].toString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{resolvedRelay.payments_url ? (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<a
|
||||||
|
href={resolvedRelay.payments_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex h-10 w-full items-center justify-center rounded-lg bg-blue-500 text-sm font-medium hover:bg-blue-600"
|
||||||
|
>
|
||||||
|
{t("relays.relayView.payment")}
|
||||||
|
</a>
|
||||||
|
<span className="text-center text-xs text-neutral-600 dark:text-neutral-400">
|
||||||
|
{t("relays.relayView.paymentNote")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Await>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
apps/desktop/src/routes/settings/about.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { getVersion } from "@tauri-apps/api/app";
|
||||||
|
import { relaunch } from "@tauri-apps/plugin-process";
|
||||||
|
import { Update, check } from "@tauri-apps/plugin-updater";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export function AboutScreen() {
|
||||||
|
const [t] = useTranslation();
|
||||||
|
const [version, setVersion] = useState("");
|
||||||
|
const [newUpdate, setNewUpdate] = useState<Update>(null);
|
||||||
|
|
||||||
|
const checkUpdate = async () => {
|
||||||
|
const update = await check();
|
||||||
|
if (!update) toast.info("There is no update available");
|
||||||
|
setNewUpdate(update);
|
||||||
|
};
|
||||||
|
|
||||||
|
const installUpdate = async () => {
|
||||||
|
await newUpdate.downloadAndInstall();
|
||||||
|
await relaunch();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadVersion() {
|
||||||
|
const appVersion = await getVersion();
|
||||||
|
setVersion(appVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadVersion();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-lg">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<h1 className="leading-tight text-xl font-semibold">Lume</h1>
|
||||||
|
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||||
|
{t("settings.about.version")} {version}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="mx-auto mt-4 flex w-full max-w-xs flex-col gap-2">
|
||||||
|
{!newUpdate ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => checkUpdate()}
|
||||||
|
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-blue-500 text-sm font-medium text-white hover:bg-blue-600"
|
||||||
|
>
|
||||||
|
{t("settings.about.checkUpdate")}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => installUpdate()}
|
||||||
|
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-blue-500 text-sm font-medium text-white hover:bg-blue-600"
|
||||||
|
>
|
||||||
|
{t("settings.about.installUpdate")} {newUpdate.version}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
to="https://lume.nu"
|
||||||
|
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-neutral-100 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||||
|
>
|
||||||
|
Website
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="https://github.com/lumehq/lume/issues"
|
||||||
|
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-neutral-100 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||||
|
>
|
||||||
|
Report a issue
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
apps/desktop/src/routes/settings/advanced.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { useStorage } from "@lume/storage";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export function AdvancedSettingScreen() {
|
||||||
|
const storage = useStorage();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const clearCache = async () => {
|
||||||
|
await storage.clearCache();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-lg">
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
<div className="flex items-center gap-8">
|
||||||
|
<div className="w-24 shrink-0 text-end text-sm font-semibold">
|
||||||
|
{t("settings.advanced.cache.title")}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
{t("settings.advanced.cache.subtitle")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => clearCache()}
|
||||||
|
className="h-8 w-max rounded-lg px-3 text-sm font-semibold text-blue-500 bg-blue-100 hover:bg-blue-200"
|
||||||
|
>
|
||||||
|
{t("settings.advanced.cache.button")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
apps/desktop/src/routes/settings/backup.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { useArk } from "@lume/ark";
|
||||||
|
import { EyeOffIcon } from "@lume/icons";
|
||||||
|
import { useStorage } from "@lume/storage";
|
||||||
|
import { nip19 } from "nostr-tools";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export function BackupSettingScreen() {
|
||||||
|
const ark = useArk();
|
||||||
|
const storage = useStorage();
|
||||||
|
|
||||||
|
const [t] = useTranslation();
|
||||||
|
const [privkey, setPrivkey] = useState(null);
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
|
const removePrivkey = async () => {
|
||||||
|
await storage.removePrivkey(ark.account.pubkey);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadPrivkey() {
|
||||||
|
const key = await storage.loadPrivkey(ark.account.pubkey);
|
||||||
|
if (key) setPrivkey(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadPrivkey();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-lg">
|
||||||
|
<div>
|
||||||
|
{privkey ? (
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 text-sm font-semibold">
|
||||||
|
{t("settings.backup.privkey.title")}
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
readOnly
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
value={nip19.nsecEncode(privkey)}
|
||||||
|
className="relative h-11 w-full resize-none rounded-lg border-none bg-neutral-200 py-1 pl-3 pr-11 text-neutral-900 !outline-none placeholder:text-neutral-600 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder:text-neutral-400"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-1.5 top-1/2 inline-flex h-8 w-8 -translate-y-1/2 transform items-center justify-center rounded-lg bg-neutral-50 dark:bg-neutral-950"
|
||||||
|
>
|
||||||
|
<EyeOffIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removePrivkey()}
|
||||||
|
className="mt-2 inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg bg-red-200 dark:bg-red-800 px-6 font-medium text-red-500 hover:bg-red-500 hover:text-white focus:outline-none dark:hover:text-white"
|
||||||
|
>
|
||||||
|
{t("settings.backup.privkey.button")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
apps/desktop/src/routes/settings/components/avatarUpload.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { useArk } from "@lume/ark";
|
||||||
|
import { LoaderIcon } from "@lume/icons";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export function AvatarUpload({ setPicture }) {
|
||||||
|
const ark = useArk();
|
||||||
|
|
||||||
|
const [t] = useTranslation();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const upload = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// upload image to nostr.build server
|
||||||
|
// #TODO: support multiple server
|
||||||
|
const image = await ark.upload({ fileExts: [] });
|
||||||
|
|
||||||
|
if (!image)
|
||||||
|
toast.error("Failed to upload image, please try again later.");
|
||||||
|
|
||||||
|
setPicture(image);
|
||||||
|
setLoading(false);
|
||||||
|
} catch (e) {
|
||||||
|
setLoading(false);
|
||||||
|
toast.error("Failed to upload image, please try again later.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={upload}
|
||||||
|
disabled={loading}
|
||||||
|
className="inline-flex items-center justify-center w-36 font-medium rounded-lg h-8 bg-blue-500 hover:bg-blue-600 text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<LoaderIcon className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
t("user.avatarButton")
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
apps/desktop/src/routes/settings/components/coverUpload.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { useArk } from "@lume/ark";
|
||||||
|
import { LoaderIcon } from "@lume/icons";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export function CoverUpload({ setBanner }) {
|
||||||
|
const ark = useArk();
|
||||||
|
|
||||||
|
const [t] = useTranslation();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const upload = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// upload image to nostr.build server
|
||||||
|
// #TODO: support multiple server
|
||||||
|
const image = await ark.upload({ fileExts: [] });
|
||||||
|
|
||||||
|
if (!image)
|
||||||
|
toast.error("Failed to upload image, please try again later.");
|
||||||
|
|
||||||
|
setBanner(image);
|
||||||
|
setLoading(false);
|
||||||
|
} catch (e) {
|
||||||
|
setLoading(false);
|
||||||
|
toast.error("Failed to upload image, please try again later.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={upload}
|
||||||
|
disabled={loading}
|
||||||
|
className="inline-flex items-center justify-center w-32 font-medium rounded-lg h-8 bg-blue-500 hover:bg-blue-600 text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<LoaderIcon className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
t("user.coverButton")
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
320
apps/desktop/src/routes/settings/general.tsx
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
import { DarkIcon, LightIcon, SystemModeIcon } from "@lume/icons";
|
||||||
|
import { useStorage } from "@lume/storage";
|
||||||
|
import { cn } from "@lume/utils";
|
||||||
|
import * as Switch from "@radix-ui/react-switch";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { getCurrent } from "@tauri-apps/api/window";
|
||||||
|
import { disable, enable, isEnabled } from "@tauri-apps/plugin-autostart";
|
||||||
|
import {
|
||||||
|
isPermissionGranted,
|
||||||
|
requestPermission,
|
||||||
|
} from "@tauri-apps/plugin-notification";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export function GeneralSettingScreen() {
|
||||||
|
const storage = useStorage();
|
||||||
|
|
||||||
|
const [t] = useTranslation();
|
||||||
|
const [apiKey, setAPIKey] = useState("");
|
||||||
|
const [settings, setSettings] = useState({
|
||||||
|
...storage.settings,
|
||||||
|
notification: false,
|
||||||
|
autolaunch: false,
|
||||||
|
appearance: "system",
|
||||||
|
});
|
||||||
|
|
||||||
|
const changeTheme = async (theme: "light" | "dark" | "auto") => {
|
||||||
|
await invoke("plugin:theme|set_theme", { theme });
|
||||||
|
// update state
|
||||||
|
setSettings((prev) => ({ ...prev, appearance: theme }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleLowPower = async () => {
|
||||||
|
await storage.createSetting("lowPower", String(+!settings.lowPower));
|
||||||
|
setSettings((state) => ({ ...state, lowPower: !settings.lowPower }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAutolaunch = async () => {
|
||||||
|
if (!settings.autolaunch) {
|
||||||
|
await enable();
|
||||||
|
// update state
|
||||||
|
setSettings((prev) => ({ ...prev, autolaunch: true }));
|
||||||
|
} else {
|
||||||
|
await disable();
|
||||||
|
// update state
|
||||||
|
setSettings((prev) => ({ ...prev, autolaunch: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMedia = async () => {
|
||||||
|
await storage.createSetting("media", String(+!settings.media));
|
||||||
|
storage.settings.media = !settings.media;
|
||||||
|
// update state
|
||||||
|
setSettings((prev) => ({ ...prev, media: !settings.media }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleHashtag = async () => {
|
||||||
|
await storage.createSetting("hashtag", String(+!settings.hashtag));
|
||||||
|
storage.settings.hashtag = !settings.hashtag;
|
||||||
|
// update state
|
||||||
|
setSettings((prev) => ({ ...prev, hashtag: !settings.hashtag }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAutoupdate = async () => {
|
||||||
|
await storage.createSetting("autoupdate", String(+!settings.autoupdate));
|
||||||
|
storage.settings.autoupdate = !settings.autoupdate;
|
||||||
|
// update state
|
||||||
|
setSettings((prev) => ({ ...prev, autoupdate: !settings.autoupdate }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleNofitication = async () => {
|
||||||
|
if (settings.notification) return;
|
||||||
|
|
||||||
|
await requestPermission();
|
||||||
|
// update state
|
||||||
|
setSettings((prev) => ({ ...prev, notification: !settings.notification }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTranslation = async () => {
|
||||||
|
await storage.createSetting("translation", String(+!settings.translation));
|
||||||
|
storage.settings.translation = !settings.translation;
|
||||||
|
// update state
|
||||||
|
setSettings((prev) => ({ ...prev, translation: !settings.translation }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveApi = async () => {
|
||||||
|
await storage.createSetting("translateApiKey", apiKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadSettings() {
|
||||||
|
const theme = await getCurrent().theme();
|
||||||
|
setSettings((prev) => ({ ...prev, appearance: theme }));
|
||||||
|
|
||||||
|
const autostart = await isEnabled();
|
||||||
|
setSettings((prev) => ({ ...prev, autolaunch: autostart }));
|
||||||
|
|
||||||
|
const permissionGranted = await isPermissionGranted();
|
||||||
|
setSettings((prev) => ({ ...prev, notification: permissionGranted }));
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSettings();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-lg">
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
<div className="flex items-center gap-8">
|
||||||
|
<div className="w-36 shrink-0 text-end text-sm font-semibold">
|
||||||
|
{t("settings.general.update.title")}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
{t("settings.general.update.subtitle")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch.Root
|
||||||
|
checked={settings.autoupdate}
|
||||||
|
onClick={() => toggleAutoupdate()}
|
||||||
|
className="relative h-7 w-12 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
|
||||||
|
>
|
||||||
|
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||||
|
</Switch.Root>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
<div className="flex items-center gap-8">
|
||||||
|
<div className="w-36 shrink-0 text-end text-sm font-semibold">
|
||||||
|
{t("settings.general.lowPower.title")}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
{t("settings.general.lowPower.subtitle")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch.Root
|
||||||
|
checked={settings.lowPower}
|
||||||
|
onClick={() => toggleLowPower()}
|
||||||
|
className="relative h-7 w-12 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
|
||||||
|
>
|
||||||
|
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||||
|
</Switch.Root>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
<div className="flex items-center gap-8">
|
||||||
|
<div className="w-36 shrink-0 text-end text-sm font-semibold">
|
||||||
|
{t("settings.general.startup.title")}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
{t("settings.general.startup.subtitle")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch.Root
|
||||||
|
checked={settings.autolaunch}
|
||||||
|
onClick={() => toggleAutolaunch()}
|
||||||
|
className="relative h-7 w-12 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
|
||||||
|
>
|
||||||
|
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||||
|
</Switch.Root>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
<div className="flex items-center gap-8">
|
||||||
|
<div className="w-36 shrink-0 text-end text-sm font-semibold">
|
||||||
|
{t("settings.general.media.title")}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
{t("settings.general.media.subtitle")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch.Root
|
||||||
|
checked={settings.media}
|
||||||
|
onClick={() => toggleMedia()}
|
||||||
|
className="relative h-7 w-12 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
|
||||||
|
>
|
||||||
|
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||||
|
</Switch.Root>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
<div className="flex items-center gap-8">
|
||||||
|
<div className="w-36 shrink-0 text-end text-sm font-semibold">
|
||||||
|
{t("settings.general.hashtag.title")}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
{t("settings.general.hashtag.subtitle")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch.Root
|
||||||
|
checked={settings.hashtag}
|
||||||
|
onClick={() => toggleHashtag()}
|
||||||
|
className="relative h-7 w-12 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
|
||||||
|
>
|
||||||
|
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||||
|
</Switch.Root>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
<div className="flex items-center gap-8">
|
||||||
|
<div className="w-36 shrink-0 text-end text-sm font-semibold">
|
||||||
|
{t("settings.general.notification.title")}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
{t("settings.general.notification.subtitle")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch.Root
|
||||||
|
checked={settings.notification}
|
||||||
|
disabled={settings.notification}
|
||||||
|
onClick={() => toggleNofitication()}
|
||||||
|
className="relative h-7 w-12 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
|
||||||
|
>
|
||||||
|
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||||
|
</Switch.Root>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
<div className="flex items-center gap-8">
|
||||||
|
<div className="w-36 shrink-0 text-end text-sm font-semibold">
|
||||||
|
{t("settings.general.translation.title")}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
{t("settings.general.translation.subtitle")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch.Root
|
||||||
|
checked={settings.translation}
|
||||||
|
onClick={() => toggleTranslation()}
|
||||||
|
className="relative h-7 w-12 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
|
||||||
|
>
|
||||||
|
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||||
|
</Switch.Root>
|
||||||
|
</div>
|
||||||
|
{settings.translation ? (
|
||||||
|
<div className="flex w-full items-center gap-8">
|
||||||
|
<div className="w-36 shrink-0 text-end text-sm font-semibold">
|
||||||
|
{t("global.apiKey")}
|
||||||
|
</div>
|
||||||
|
<div className="relative w-full">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
spellCheck={false}
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(e) => setAPIKey(e.target.value)}
|
||||||
|
className="w-full border-transparent outline-none focus:outline-none focus:ring-0 focus:border-none h-9 rounded-lg ring-0 placeholder:text-neutral-600 bg-neutral-100 dark:bg-neutral-900"
|
||||||
|
/>
|
||||||
|
<div className="h-9 absolute right-0 top-0 inline-flex items-center justify-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={saveApi}
|
||||||
|
className="mr-1 h-7 w-16 text-sm font-medium shrink-0 inline-flex items-center justify-center rounded-md bg-neutral-200 dark:bg-neutral-800 hover:bg-neutral-300 dark:hover:bg-neutral-700"
|
||||||
|
>
|
||||||
|
{t("global.save")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="flex w-full items-start gap-8">
|
||||||
|
<div className="w-36 shrink-0 text-end text-sm font-semibold">
|
||||||
|
{t("settings.general.appearance.title")}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 gap-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => changeTheme("light")}
|
||||||
|
className="flex flex-col items-center justify-center gap-0.5"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-11 w-11 items-center justify-center rounded-lg",
|
||||||
|
settings.appearance === "light"
|
||||||
|
? "bg-blue-500 text-white"
|
||||||
|
: "bg-neutral-100 dark:bg-neutral-900",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<LightIcon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||||
|
{t("settings.general.appearance.light")}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => changeTheme("dark")}
|
||||||
|
className="flex flex-col items-center justify-center gap-0.5"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-11 w-11 items-center justify-center rounded-lg",
|
||||||
|
settings.appearance === "dark"
|
||||||
|
? "bg-blue-500 text-white"
|
||||||
|
: "bg-neutral-100 dark:bg-neutral-900",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<DarkIcon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||||
|
{t("settings.general.appearance.dark")}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => changeTheme("auto")}
|
||||||
|
className="flex flex-col items-center justify-center gap-0.5"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-11 w-11 items-center justify-center rounded-lg",
|
||||||
|
settings.appearance === "auto"
|
||||||
|
? "bg-blue-500 text-white"
|
||||||
|
: "bg-neutral-100 dark:bg-neutral-900",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SystemModeIcon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||||
|
{t("settings.general.appearance.system")}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
157
apps/desktop/src/routes/settings/nwc.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { useArk } from "@lume/ark";
|
||||||
|
import { useStorage } from "@lume/storage";
|
||||||
|
import * as Switch from "@radix-ui/react-switch";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export function NWCScreen() {
|
||||||
|
const ark = useArk();
|
||||||
|
const storage = useStorage();
|
||||||
|
|
||||||
|
const [t] = useTranslation();
|
||||||
|
const [settings, setSettings] = useState({
|
||||||
|
nwc: false,
|
||||||
|
instantZap: storage.settings.instantZap,
|
||||||
|
});
|
||||||
|
const [walletConnectURL, setWalletConnectURL] = useState<null | string>(null);
|
||||||
|
const [amount, setAmount] = useState("21");
|
||||||
|
|
||||||
|
const saveNWC = async () => {
|
||||||
|
try {
|
||||||
|
if (!walletConnectURL.startsWith("nostr+walletconnect:")) {
|
||||||
|
return toast.error(
|
||||||
|
"Connect URI is required and must start with format nostr+walletconnect:, please check again",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uriObj = new URL(walletConnectURL);
|
||||||
|
const params = new URLSearchParams(uriObj.search);
|
||||||
|
|
||||||
|
if (params.has("relay") && params.has("secret")) {
|
||||||
|
await storage.createPrivkey(
|
||||||
|
`${ark.account.pubkey}.nwc`,
|
||||||
|
walletConnectURL,
|
||||||
|
);
|
||||||
|
|
||||||
|
storage.nwc = walletConnectURL;
|
||||||
|
|
||||||
|
setWalletConnectURL(walletConnectURL);
|
||||||
|
setSettings((state) => ({ ...state, nwc: true }));
|
||||||
|
} else {
|
||||||
|
return toast.error("Connect URI is not valid, please check again");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleInstantZap = async () => {
|
||||||
|
await storage.createSetting("instantZap", String(+!settings.instantZap));
|
||||||
|
setSettings((state) => ({ ...state, instantZap: !settings.instantZap }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveAmount = async () => {
|
||||||
|
await storage.createSetting("zapAmount", amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async () => {
|
||||||
|
await storage.removePrivkey(`${ark.account.pubkey}.nwc`);
|
||||||
|
|
||||||
|
setWalletConnectURL("");
|
||||||
|
setSettings((state) => ({ ...state, nwc: false }));
|
||||||
|
storage.nwc = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (storage.nwc) {
|
||||||
|
setSettings((state) => ({ ...state, nwc: true }));
|
||||||
|
setWalletConnectURL(storage.nwc);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-lg">
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
<div className="flex w-full items-start gap-8">
|
||||||
|
<div className="w-36 shrink-0 text-end text-sm font-semibold">
|
||||||
|
{t("settings.zap.nwc")}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-2 w-full">
|
||||||
|
<textarea
|
||||||
|
spellCheck={false}
|
||||||
|
value={walletConnectURL}
|
||||||
|
onChange={(e) => setWalletConnectURL(e.target.value)}
|
||||||
|
className="w-full h-24 resize-none border-transparent outline-none focus:outline-none focus:ring-0 focus:border-none rounded-lg ring-0 placeholder:text-neutral-600 bg-neutral-100 dark:bg-neutral-900"
|
||||||
|
/>
|
||||||
|
{!settings.nwc ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={saveNWC}
|
||||||
|
className="h-8 w-16 text-sm font-medium shrink-0 inline-flex items-center justify-center rounded-md bg-neutral-200 dark:bg-neutral-800 hover:bg-neutral-300 dark:hover:bg-neutral-700"
|
||||||
|
>
|
||||||
|
{t("global.save")}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={remove}
|
||||||
|
className="h-8 w-16 text-sm font-medium shrink-0 inline-flex items-center justify-center rounded-md bg-neutral-200 dark:bg-neutral-800 hover:bg-neutral-300 dark:hover:bg-neutral-700"
|
||||||
|
>
|
||||||
|
{t("global.delete")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{settings.nwc ? (
|
||||||
|
<>
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
<div className="flex items-center gap-8">
|
||||||
|
<div className="w-36 shrink-0 text-end text-sm font-semibold">
|
||||||
|
{t("settings.zap.instant.title")}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
{t("settings.zap.instant.subtitle")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch.Root
|
||||||
|
checked={settings.instantZap}
|
||||||
|
onClick={() => toggleInstantZap()}
|
||||||
|
className="relative h-7 w-12 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
|
||||||
|
>
|
||||||
|
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
|
||||||
|
</Switch.Root>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
<div className="flex w-full items-center gap-8">
|
||||||
|
<div className="w-36 shrink-0 text-end text-sm font-semibold">
|
||||||
|
{t("settings.zap.defaultAmount")}
|
||||||
|
</div>
|
||||||
|
<div className="relative w-full">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
spellCheck={false}
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
className="w-full border-transparent outline-none focus:outline-none focus:ring-0 focus:border-none h-9 rounded-lg ring-0 placeholder:text-neutral-600 bg-neutral-100 dark:bg-neutral-900"
|
||||||
|
/>
|
||||||
|
<div className="h-9 absolute right-0 top-0 inline-flex items-center justify-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={saveAmount}
|
||||||
|
className="mr-1 h-7 w-16 text-sm font-medium shrink-0 inline-flex items-center justify-center rounded-md bg-neutral-200 dark:bg-neutral-800 hover:bg-neutral-300 dark:hover:bg-neutral-700"
|
||||||
|
>
|
||||||
|
{t("global.save")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
248
apps/desktop/src/routes/settings/profile.tsx
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import { useArk } from "@lume/ark";
|
||||||
|
import { CheckCircleIcon, LoaderIcon, UnverifiedIcon } from "@lume/icons";
|
||||||
|
import { useStorage } from "@lume/storage";
|
||||||
|
import { NDKKind, NDKUserProfile } from "@nostr-dev-kit/ndk";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { AvatarUpload } from "./components/avatarUpload";
|
||||||
|
import { CoverUpload } from "./components/coverUpload";
|
||||||
|
|
||||||
|
export function ProfileSettingScreen() {
|
||||||
|
const ark = useArk();
|
||||||
|
const storage = useStorage();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [picture, setPicture] = useState("");
|
||||||
|
const [banner, setBanner] = useState("");
|
||||||
|
const [nip05, setNIP05] = useState({ verified: true, text: "" });
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
setError,
|
||||||
|
formState: { isValid, errors },
|
||||||
|
} = useForm({
|
||||||
|
defaultValues: async () => {
|
||||||
|
const res: NDKUserProfile = queryClient.getQueryData([
|
||||||
|
"user",
|
||||||
|
ark.account.pubkey,
|
||||||
|
]);
|
||||||
|
if (res.image) {
|
||||||
|
setPicture(res.image);
|
||||||
|
}
|
||||||
|
if (res.banner) {
|
||||||
|
setBanner(res.banner);
|
||||||
|
}
|
||||||
|
if (res.nip05) {
|
||||||
|
setNIP05((prev) => ({ ...prev, text: res.nip05 }));
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: NDKUserProfile) => {
|
||||||
|
// start loading
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
let content = {
|
||||||
|
...data,
|
||||||
|
picture,
|
||||||
|
banner,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (data.nip05) {
|
||||||
|
const verify = ark.validateNIP05({
|
||||||
|
pubkey: ark.account.pubkey,
|
||||||
|
nip05: data.nip05,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (verify) {
|
||||||
|
content = { ...content, nip05: data.nip05 };
|
||||||
|
} else {
|
||||||
|
setNIP05((prev) => ({ ...prev, verified: false }));
|
||||||
|
setError("nip05", {
|
||||||
|
type: "manual",
|
||||||
|
message: "Can't verify your NIP-05, please check again",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const publish = await ark.createEvent({
|
||||||
|
kind: NDKKind.Metadata,
|
||||||
|
tags: [],
|
||||||
|
content: JSON.stringify(content),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (publish) {
|
||||||
|
// invalid cache
|
||||||
|
await storage.clearProfileCache(ark.account.pubkey);
|
||||||
|
await queryClient.setQueryData(["user", ark.account.pubkey], () => {
|
||||||
|
return content;
|
||||||
|
});
|
||||||
|
|
||||||
|
// notify
|
||||||
|
toast.success("You've updated profile successfully.");
|
||||||
|
|
||||||
|
// reset state
|
||||||
|
setPicture(null);
|
||||||
|
setBanner(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-lg">
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="mb-0">
|
||||||
|
<div className="mb-5 flex flex-col items-center justify-center bg-neutral-100 dark:bg-neutral-900 rounded-xl">
|
||||||
|
<div className="relative h-44 w-full">
|
||||||
|
{banner ? (
|
||||||
|
<img
|
||||||
|
src={banner}
|
||||||
|
alt="user's banner"
|
||||||
|
className="h-full w-full rounded-t-xl object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="h-full w-full rounded-t-xl bg-neutral-200 dark:bg-neutral-800" />
|
||||||
|
)}
|
||||||
|
<div className="absolute right-4 top-4">
|
||||||
|
<CoverUpload setBanner={setBanner} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="-mt-7 mb-5 px-4 flex flex-col gap-4 items-center z-10 relative">
|
||||||
|
<div className="size-14 overflow-hidden rounded-xl ring-2 ring-white dark:ring-black">
|
||||||
|
<img
|
||||||
|
src={picture}
|
||||||
|
alt="user's avatar"
|
||||||
|
className="h-14 w-14 rounded-xl object-cover bg-white dark:bg-black"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<AvatarUpload setPicture={setPicture} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label
|
||||||
|
htmlFor="displayName"
|
||||||
|
className="text-sm font-semibold uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
{t("user.displayName")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type={"text"}
|
||||||
|
{...register("display_name")}
|
||||||
|
spellCheck={false}
|
||||||
|
className="relative h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 py-1 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:text-neutral-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label
|
||||||
|
htmlFor="name"
|
||||||
|
className="text-sm font-semibold uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
{t("user.name")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type={"text"}
|
||||||
|
{...register("name")}
|
||||||
|
spellCheck={false}
|
||||||
|
className="relative h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 py-1 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:text-neutral-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label
|
||||||
|
htmlFor="nip05"
|
||||||
|
className="text-sm font-semibold uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
NIP-05
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
{...register("nip05")}
|
||||||
|
spellCheck={false}
|
||||||
|
className="relative h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 py-1 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:text-neutral-100"
|
||||||
|
/>
|
||||||
|
<div className="absolute right-2 top-1/2 -translate-y-1/2 transform">
|
||||||
|
{nip05.verified ? (
|
||||||
|
<span className="inline-flex h-6 items-center gap-1 rounded-full bg-teal-500 px-1 pr-1.5 text-xs font-medium text-white">
|
||||||
|
<CheckCircleIcon className="h-4 w-4" />
|
||||||
|
{t("user.verified")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex h-6 items-center gap-1 rounded bg-red-500 pl-1 pr-1.5 text-xs font-medium text-white">
|
||||||
|
<UnverifiedIcon className="h-4 w-4" />
|
||||||
|
{t("user.unverified")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{errors.nip05 && (
|
||||||
|
<p className="mt-1 text-sm text-red-400">
|
||||||
|
{errors.nip05.message.toString()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label
|
||||||
|
htmlFor="website"
|
||||||
|
className="text-sm font-semibold uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
{t("user.website")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type={"text"}
|
||||||
|
{...register("website", { required: false })}
|
||||||
|
spellCheck={false}
|
||||||
|
className="relative h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 py-1 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:text-neutral-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label
|
||||||
|
htmlFor="website"
|
||||||
|
className="text-sm font-semibold uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
{t("user.lna")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type={"text"}
|
||||||
|
{...register("lud16", { required: false })}
|
||||||
|
spellCheck={false}
|
||||||
|
className="relative h-11 w-full rounded-lg border-transparent bg-neutral-100 px-3 py-1 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:text-neutral-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label
|
||||||
|
htmlFor="about"
|
||||||
|
className="text-sm font-semibold uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
{t("user.bio")}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
{...register("about")}
|
||||||
|
spellCheck={false}
|
||||||
|
className="relative h-36 w-full resize-none rounded-lg border-transparent bg-neutral-100 px-3 py-2 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:text-neutral-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="absolute right-4 bottom-4">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!isValid || loading}
|
||||||
|
className="inline-flex items-center justify-center w-24 pb-[2px] font-semibold border-t rounded-lg border-neutral-900 dark:border-neutral-800 h-9 bg-neutral-950 text-neutral-50 dark:bg-neutral-900 hover:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<LoaderIcon className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
t("global.update")
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
apps/desktop/tailwind.config.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import sharedConfig from "@lume/tailwindcss";
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
content: [
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
"../../packages/@columns/**/*{.js,.ts,.jsx,.tsx}",
|
||||||
|
"../../packages/ark/**/*{.js,.ts,.jsx,.tsx}",
|
||||||
|
"../../packages/ui/**/*{.js,.ts,.jsx,.tsx}",
|
||||||
|
"index.html",
|
||||||
|
],
|
||||||
|
presets: [sharedConfig],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
8
apps/desktop/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "@lume/tsconfig/base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
36
apps/desktop/vite.config.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import react from "@vitejs/plugin-react-swc";
|
||||||
|
import million from "million/compiler";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
import topLevelAwait from "vite-plugin-top-level-await";
|
||||||
|
import viteTsconfigPaths from "vite-tsconfig-paths";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
million.vite({
|
||||||
|
auto: {
|
||||||
|
threshold: 0.05,
|
||||||
|
},
|
||||||
|
mute: true,
|
||||||
|
}),
|
||||||
|
react(),
|
||||||
|
viteTsconfigPaths(),
|
||||||
|
topLevelAwait({
|
||||||
|
// The export name of top-level await promise for each chunk module
|
||||||
|
promiseExportName: "__tla",
|
||||||
|
// The function to generate import names of top-level await promise in each chunk module
|
||||||
|
promiseImportName: (i) => `__tla_${i}`,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
envPrefix: ["VITE_", "TAURI_"],
|
||||||
|
build: {
|
||||||
|
target: process.env.TAURI_PLATFORM === "windows" ? "chrome105" : "safari13",
|
||||||
|
minify: !process.env.TAURI_DEBUG ? "esbuild" : false,
|
||||||
|
sourcemap: !!process.env.TAURI_DEBUG,
|
||||||
|
outDir: "../../dist",
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
strictPort: true,
|
||||||
|
port: 3000,
|
||||||
|
},
|
||||||
|
clearScreen: false,
|
||||||
|
});
|
||||||
21
apps/web/.gitignore
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# build output
|
||||||
|
dist/
|
||||||
|
# generated types
|
||||||
|
.astro/
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
|
||||||
|
# environment variables
|
||||||
|
.env
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# macOS-specific files
|
||||||
|
.DS_Store
|
||||||
47
apps/web/README.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Astro Starter Kit: Minimal
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm create astro@latest -- --template minimal
|
||||||
|
```
|
||||||
|
|
||||||
|
[](https://stackblitz.com/github/withastro/astro/tree/latest/examples/minimal)
|
||||||
|
[](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/minimal)
|
||||||
|
[](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/minimal/devcontainer.json)
|
||||||
|
|
||||||
|
> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
|
||||||
|
|
||||||
|
## 🚀 Project Structure
|
||||||
|
|
||||||
|
Inside of your Astro project, you'll see the following folders and files:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/
|
||||||
|
├── public/
|
||||||
|
├── src/
|
||||||
|
│ └── pages/
|
||||||
|
│ └── index.astro
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
|
||||||
|
|
||||||
|
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
|
||||||
|
|
||||||
|
Any static assets, like images, can be placed in the `public/` directory.
|
||||||
|
|
||||||
|
## 🧞 Commands
|
||||||
|
|
||||||
|
All commands are run from the root of the project, from a terminal:
|
||||||
|
|
||||||
|
| Command | Action |
|
||||||
|
| :------------------------ | :----------------------------------------------- |
|
||||||
|
| `npm install` | Installs dependencies |
|
||||||
|
| `npm run dev` | Starts local dev server at `localhost:4321` |
|
||||||
|
| `npm run build` | Build your production site to `./dist/` |
|
||||||
|
| `npm run preview` | Preview your build locally, before deploying |
|
||||||
|
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||||
|
| `npm run astro -- --help` | Get help using the Astro CLI |
|
||||||
|
|
||||||
|
## 👀 Want to learn more?
|
||||||
|
|
||||||
|
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
|
||||||