Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ea3b353127 | |||
| 53e62cee80 |
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
|
||||
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 }}
|
||||
67
.github/workflows/main.yml
vendored
@@ -1,54 +1,53 @@
|
||||
name: "Publish"
|
||||
name: 'Publish'
|
||||
on: workflow_dispatch
|
||||
|
||||
env:
|
||||
CARGO_INCREMENTAL: 0
|
||||
RUST_BACKTRACE: short
|
||||
RUSTFLAGS: "-W unreachable-pub -W rust-2021-compatibility"
|
||||
RUSTFLAGS: '-W unreachable-pub -W rust-2021-compatibility'
|
||||
|
||||
jobs:
|
||||
publish-tauri:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: "macos-latest" # for Arm based macs (M1 and above).
|
||||
args: "--target aarch64-apple-darwin"
|
||||
- platform: "macos-latest" # for Intel based macs.
|
||||
args: "--target x86_64-apple-darwin"
|
||||
- platform: "macos-latest" # for Intel & Arm based macs.
|
||||
args: "--target universal-apple-darwin"
|
||||
settings:
|
||||
- platform: 'macos-latest'
|
||||
args: '--target universal-apple-darwin'
|
||||
- platform: 'ubuntu-22.04'
|
||||
args: ''
|
||||
- platform: 'windows-latest'
|
||||
args: '--target x86_64-pc-windows-msvc'
|
||||
runs-on: ${{ matrix.platform }}
|
||||
runs-on: ${{ matrix.settings.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v3
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "lts/*"
|
||||
|
||||
- name: Install PNPM
|
||||
node-version: 20
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin
|
||||
- name: install dependencies (ubuntu only)
|
||||
if: matrix.settings.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential libssl-dev javascriptcoregtk-4.1 libayatana-appindicator3-dev libsoup-3.0-dev libgtk-3-dev libwebkit2gtk-4.1-dev webkit2gtk-4.1 librsvg2-dev patchelf
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8.x.x
|
||||
run_install: false
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
- name: Setup node and cache for package data
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
- name: Install dependencies (ubuntu only)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential libssl-dev javascriptcoregtk-4.1 libayatana-appindicator3-dev libsoup-3.0-dev libgtk-3-dev libwebkit2gtk-4.1-dev webkit2gtk-4.1 librsvg2-dev patchelf
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install
|
||||
|
||||
node-version: 'lts/*'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
cache-on-failure: true
|
||||
- run: pnpm install
|
||||
- uses: tauri-apps/tauri-action@dev
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -63,9 +62,9 @@ jobs:
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
with:
|
||||
tagName: v__VERSION__
|
||||
releaseName: "v__VERSION__"
|
||||
releaseBody: "See the assets to download this version and install."
|
||||
releaseName: 'v__VERSION__'
|
||||
releaseBody: 'See the assets to download this version and install.'
|
||||
releaseDraft: true
|
||||
prerelease: false
|
||||
args: ${{ matrix.args }}
|
||||
args: ${{ matrix.settings.args }}
|
||||
includeDebug: false
|
||||
|
||||
54
.gitignore
vendored
@@ -1,26 +1,36 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# Dependencies
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
# 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/
|
||||
|
||||
|
||||
# Debug
|
||||
*.log*
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
src/router.gen.ts
|
||||
*.pem
|
||||
|
||||
32
README.md
@@ -1,26 +1,20 @@
|
||||
## Introduction
|
||||
### Introduction
|
||||
|
||||
Lume is a Nostr client for desktop include Linux, Windows and macOS. It is free and open source, you can look at source code on Github. Lume is actively improving the app and adding new features, you can expect new update every month.
|
||||
Lume is a nostr client
|
||||
|
||||
## Usage
|
||||
### Usage
|
||||
|
||||
Download Lume v4 for your platform here: [https://github.com/lumehq/lume/releases](https://github.com/lumehq/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 are coming soon.
|
||||
Supported platform: macOS, Windows and Linux
|
||||
|
||||
Windows and Linux are availabel on v3 and below.
|
||||
### Prerequisites
|
||||
|
||||
## Prerequisites
|
||||
- PNPM or Bun (experiment)
|
||||
|
||||
- Node.js >= 18: https://nodejs.org/en
|
||||
- Tauri: https://tauri.app/v1/guides/getting-started/prerequisites#setting-up-macos
|
||||
|
||||
- Rust: https://rustup.rs/
|
||||
|
||||
- PNPM: https://pnpm.io
|
||||
|
||||
- Tauri v2: https://beta.tauri.app/guides/prerequisites/
|
||||
|
||||
## Develop
|
||||
### Develop
|
||||
|
||||
Clone project
|
||||
|
||||
@@ -46,7 +40,7 @@ Generate production build
|
||||
pnpm tauri build
|
||||
```
|
||||
|
||||
## Nix
|
||||
#### Nix
|
||||
|
||||
Requirements:
|
||||
|
||||
@@ -59,8 +53,8 @@ Requirements:
|
||||
|
||||
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 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 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 General Public License for more details.
|
||||
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 General Public License along with this program. If not, see https://www.gnu.org/licenses/.
|
||||
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/.
|
||||
|
||||
13
apps/desktop/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Lume</title>
|
||||
</head>
|
||||
<body
|
||||
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>
|
||||
<script type="module" src="./src/main.jsx"></script>
|
||||
</body>
|
||||
</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"
|
||||
}
|
||||
}
|
||||
BIN
apps/desktop/public/anime.jpg
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
apps/desktop/public/art.jpg
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
apps/desktop/public/clapping_hands.png
Normal file
|
After Width: | Height: | Size: 398 KiB |
BIN
apps/desktop/public/clown_face.png
Normal file
|
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 |
BIN
apps/desktop/public/crying_face.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
apps/desktop/public/face_with_open_mouth.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
apps/desktop/public/face_with_tongue.png
Normal file
|
After Width: | Height: | Size: 986 KiB |
BIN
apps/desktop/public/fallback-image.jpg
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
apps/desktop/public/gaming.jpg
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
apps/desktop/public/ghost.png
Normal file
|
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 |
BIN
apps/desktop/public/icon.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
apps/desktop/public/movie.jpg
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
apps/desktop/public/music.jpg
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
apps/desktop/public/nsfw.jpg
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
apps/desktop/public/photography.jpg
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
apps/desktop/public/technology.jpg
Normal file
|
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 |
BIN
apps/desktop/public/zap.png
Normal file
|
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>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
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) => {
|
||||
@@ -13,7 +16,7 @@ i18n
|
||||
)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
lng: "en",
|
||||
lng: currentLocale,
|
||||
fallbackLng: "en",
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
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).
|
||||
8
apps/web/astro.config.mjs
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'astro/config';
|
||||
|
||||
import tailwind from "@astrojs/tailwind";
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
integrations: [tailwind()]
|
||||
});
|
||||
26
apps/web/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@lume/web",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
"build": "astro check && astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.4.1",
|
||||
"@astrojs/tailwind": "^5.1.0",
|
||||
"@fontsource/geist-mono": "^5.0.1",
|
||||
"astro": "^4.2.6",
|
||||
"astro-seo-meta": "^4.1.0",
|
||||
"astro-seo-schema": "^4.0.0",
|
||||
"schema-dts": "^1.1.2",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.10"
|
||||
}
|
||||
}
|
||||
37
apps/web/public/favicon.svg
Normal file
@@ -0,0 +1,37 @@
|
||||
<svg width="824" height="824" viewBox="0 0 824 824" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_564_71)">
|
||||
<rect width="824" height="824" rx="184" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
<circle cx="267" cy="594" r="42" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
<circle cx="267" cy="594" r="42" fill="url(#paint0_radial_564_71)" fill-opacity="0.5" style=""/>
|
||||
<circle cx="267" cy="594" r="42" fill="url(#paint1_radial_564_71)" fill-opacity="0.3" style=""/>
|
||||
<circle cx="557" cy="594" r="42" fill="black" style="fill:black;fill-opacity:1;"/>
|
||||
<circle cx="557" cy="594" r="42" fill="url(#paint2_radial_564_71)" fill-opacity="0.5" style=""/>
|
||||
<circle cx="557" cy="594" r="42" fill="url(#paint3_radial_564_71)" fill-opacity="0.3" style=""/>
|
||||
<path d="M412 691C382.859 691 353.717 686.063 337.654 682.804C333.024 681.865 329.866 686.676 333.074 690.144C345.098 703.138 370.814 724 412 724C453.186 724 478.902 703.138 490.926 690.144C494.134 686.676 490.976 681.865 486.346 682.804C470.283 686.063 441.141 691 412 691Z" fill="url(#paint4_linear_564_71)" style=""/>
|
||||
</g>
|
||||
<defs>
|
||||
<radialGradient id="paint0_radial_564_71" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(241.807 578.038) rotate(112.103) scale(88.2816 69.6512)">
|
||||
<stop stop-color="white" style="stop-color:white;stop-opacity:1;"/>
|
||||
<stop offset="1" stop-opacity="0" style="stop-color:none;stop-opacity:0;"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="paint1_radial_564_71" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(288.309 621.165) rotate(126.504) scale(25.5816 15.4047)">
|
||||
<stop stop-color="white" style="stop-color:white;stop-opacity:1;"/>
|
||||
<stop offset="1" stop-opacity="0" style="stop-color:none;stop-opacity:0;"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="paint2_radial_564_71" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(531.807 578.038) rotate(112.103) scale(88.2816 69.6512)">
|
||||
<stop stop-color="white" style="stop-color:white;stop-opacity:1;"/>
|
||||
<stop offset="1" stop-opacity="0" style="stop-color:none;stop-opacity:0;"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="paint3_radial_564_71" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(578.309 621.165) rotate(126.504) scale(25.5816 15.4047)">
|
||||
<stop stop-color="white" style="stop-color:white;stop-opacity:1;"/>
|
||||
<stop offset="1" stop-opacity="0" style="stop-color:none;stop-opacity:0;"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="paint4_linear_564_71" x1="293.565" y1="686.595" x2="316.497" y2="774.784" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FF9F5A" style="stop-color:#FF9F5A;stop-color:color(display-p3 1.0000 0.6235 0.3529);stop-opacity:1;"/>
|
||||
<stop offset="1" stop-color="#FF9F5A" style="stop-color:#FF9F5A;stop-color:color(display-p3 1.0000 0.6235 0.3529);stop-opacity:1;"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_564_71">
|
||||
<rect width="824" height="824" fill="white" style="fill:white;fill-opacity:1;"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
BIN
apps/web/public/og-image.jpg
Normal file
|
After Width: | Height: | Size: 1.2 MiB |