Compare commits
309 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e5d0f0e76 | ||
|
|
f0712e5961 | ||
|
|
3fbd66dece | ||
|
|
1283432632 | ||
|
|
59eaaec903 | ||
|
|
4f0f210076 | ||
|
|
e4a317f038 | ||
|
|
9779d020c7 | ||
|
|
f8280ec8ee | ||
|
|
6c26f8967b | ||
|
|
e1424b851c | ||
|
|
d14e609579 | ||
|
|
8c0627ff27 | ||
|
|
18c133d096 | ||
|
|
0061ecea78 | ||
|
|
d01cf8319d | ||
|
|
843895d876 | ||
|
|
7c99ed39e4 | ||
|
|
71be59b2e9 | ||
|
|
1c20512ecc | ||
|
|
90342c552f | ||
|
|
b396c8a695 | ||
|
|
6996e30889 | ||
|
|
7ba793fad8 | ||
|
|
f11f836518 | ||
|
|
04fe0fcec8 | ||
|
|
799835a629 | ||
|
|
4e7da4108b | ||
|
|
7c7b082b3a | ||
|
|
38d6c51921 | ||
|
|
1738cbdd97 | ||
|
|
2e885b76a1 | ||
|
|
f94680e487 | ||
|
|
c682a58842 | ||
|
|
921cf871ee | ||
|
|
d5b1593aca | ||
|
|
6676b4e2a4 | ||
|
|
5f30ddcfca | ||
|
|
41d0de539d | ||
|
|
e254ee3203 | ||
|
|
6d42360549 | ||
|
|
70c5143445 | ||
|
|
41b66b18f5 | ||
|
|
dda0720ed4 | ||
|
|
4b60b39119 | ||
|
|
d2e5122d5a | ||
|
|
32f3315344 | ||
|
|
5ca9444358 | ||
|
|
4dc13385a5 | ||
|
|
b90ad1421f | ||
|
|
bba324ea53 | ||
|
|
7449000f5f | ||
|
|
dc7762ca11 | ||
|
|
3a3f960dde | ||
|
|
12e066ff2e | ||
|
|
fe4f965ed5 | ||
|
|
5d3f2264e9 | ||
|
|
407fe40b67 | ||
|
|
1f38eba2cc | ||
|
|
9b5867f80c | ||
|
|
cac774a0c1 | ||
|
|
82689bf3c3 | ||
|
|
f60e438a64 | ||
|
|
ca06f2b6ed | ||
|
|
99d9c70826 | ||
|
|
60afbf090b | ||
|
|
10ca4e6ff4 | ||
|
|
b0f387d029 | ||
|
|
1a8f750640 | ||
|
|
25523229a2 | ||
|
|
47835ed857 | ||
|
|
d84647bc6b | ||
|
|
7724eccd72 | ||
|
|
8ea2335225 | ||
|
|
b60d4db0df | ||
|
|
f1e17ff3c4 | ||
|
|
32954f17b6 | ||
|
|
cf70b0f882 | ||
|
|
135d0918b3 | ||
|
|
e1fbcf0460 | ||
|
|
99aaf3da82 | ||
|
|
3ef13e43f1 | ||
|
|
8939196ae4 | ||
|
|
571d4b4004 | ||
|
|
73f80f27fb | ||
|
|
b46a5cf68f | ||
|
|
8c0d03aed0 | ||
|
|
777eb15b4f | ||
|
|
c8e1b8b8bd | ||
|
|
437cd71f7e | ||
|
|
afb7c87fa3 | ||
|
|
c843626bca | ||
|
|
28337e5915 | ||
|
|
a4aef25adb | ||
|
|
61d1f095d4 | ||
|
|
f027eae52d | ||
|
|
174a3cc74e | ||
|
|
c00a7749b4 | ||
|
|
c755b8d137 | ||
| 17766d29d6 | |||
| 3b13dfeed8 | |||
| 17ba79e01b | |||
| bafad544e9 | |||
| 89c36423ae | |||
| cd31b99559 | |||
| f3c52237fa | |||
| 413d8d82df | |||
| 2eb2010d43 | |||
| 94d400cab2 | |||
| 09b143cb08 | |||
| e3ede34108 | |||
| ed6aca41ea | |||
| 89f577fbef | |||
| a14aeaeb55 | |||
|
|
35cf0abda4 | ||
| 8a7b246315 | |||
|
|
d98c6d0709 | ||
| bda20e8fe8 | |||
| c342daecc8 | |||
| 5e6692cd6d | |||
| 420be77b5c | |||
| 999073f84c | |||
| 174b28f1a7 | |||
| 763cb10e85 | |||
| 89bb8d88f6 | |||
| 09aa2ecafc | |||
| 7271e9ea87 | |||
| a02e496b29 | |||
| cbbf5eaf50 | |||
| d3fa59d2b1 | |||
| aa23e39334 | |||
| c8e2018fd0 | |||
| 6e489f1c49 | |||
| a49b88ab35 | |||
| 31839531ea | |||
| ec0f3fabc0 | |||
| dd7155a3a6 | |||
| cb565ff35b | |||
| 5d59040224 | |||
| 7fabf949c6 | |||
| ea5120e2f0 | |||
| 14f07dfea8 | |||
|
|
1de48cc640 | ||
|
|
f72eb456e8 | ||
| 05b52564e0 | |||
| c8e014f33e | |||
| 46cc01e0ee | |||
| 16e6d234e5 | |||
| 3005d27403 | |||
| 1be84f3139 | |||
|
|
04bde7dd43 | ||
| f1504d99ac | |||
| e928f2ee37 | |||
|
|
ccab78ca11 | ||
| 5b13c3e45c | |||
| e6b97ab9ae | |||
| bb6badfed6 | |||
|
|
0c4b309a11 | ||
| f4400d711f | |||
| a3e46aa96b | |||
| a4fdcfdf0b | |||
| 25d07303a3 | |||
| 0e1e7524c9 | |||
|
|
32d770eb91 | ||
| e93cbf78d5 | |||
| 71a2290b8f | |||
| 95294a80cb | |||
| 8eaf47f6d2 | |||
| 86183d799a | |||
| 2dbfff3c17 | |||
|
|
d1f5c372ae | ||
| 13281455ed | |||
| 9cfb32be4d | |||
| cb0672c22a | |||
| ca0e041731 | |||
| cfcb9bc6ed | |||
| 09df8672d0 | |||
| 2403231ac4 | |||
| 98ef1927f2 | |||
| 63db8b1423 | |||
| 2c8dd71792 | |||
|
|
fe73e1428b | ||
| 88a6c3c81f | |||
| 84584a4d1f | |||
| 64286aa354 | |||
| e9ce932646 | |||
|
|
4c28b4879c | ||
| 9127d5c5ea | |||
| 090e54ec5a | |||
| d0c9f93ebb | |||
| e2cdc5b576 | |||
| bfe35ad885 | |||
| 0f06522c28 | |||
| f47eba5af7 | |||
| f28a7ae82f | |||
| 296b11b7b8 | |||
| a0d9a729dd | |||
| 1de8c7240d | |||
| 70126ef1b3 | |||
| cdf29f8a54 | |||
| 6171b9bed1 | |||
| 60fd09000b | |||
| 4292def206 | |||
| 90f149b09c | |||
| ed52105c02 | |||
| 1950cb59a2 | |||
|
|
9753e6d6b4 | ||
|
|
3488f05960 | ||
| c809ab6b4e | |||
|
|
c6674c4a2d | ||
| 35c5b5fb78 | |||
| 739ba63e6c | |||
| ec78cf8bf7 | |||
| 17052aeeaa | |||
| d7bbda6e7b | |||
| 6a08c1de10 | |||
| 3c4bd39384 | |||
| a4069dae99 | |||
|
|
55cd556cd6 | ||
| a21da11a91 | |||
| 08fa7de01d | |||
| 2a58326cd1 | |||
| 8a17c36a5b | |||
| 7bd6f6a8db | |||
| 3ba870be4b | |||
| bd2b6a3759 | |||
| a3a8f57bfc | |||
| fd393a4d30 | |||
| e8bd48e51b | |||
| 0191180f31 | |||
| 60ed56b1b9 | |||
|
|
da722afed3 | ||
| d8eb51e49c | |||
| c700a45ab6 | |||
| b806a34edb | |||
| 21989e6fa5 | |||
| 0539c5649d | |||
| ad488ff72d | |||
| 02e0309a41 | |||
| b7f4af7883 | |||
| cc48a4f36b | |||
| 46ed3330fc | |||
| 1fa1872ca6 | |||
|
|
c389a23365 | ||
| eaf9bda077 | |||
| 84a248a5a9 | |||
|
|
711c1d561a | ||
| 21210b4336 | |||
| 3bd480b75e | |||
| 2b19650e46 | |||
| 23482531c5 | |||
| cfda9ba899 | |||
| 698bd78684 | |||
| b97676dd3e | |||
| 25ae4f2201 | |||
|
|
59435ccd13 | ||
|
|
e81912c5e9 | ||
| af1b4e60d3 | |||
| 648cbf6f80 | |||
| 7b06a82ee7 | |||
|
|
d18de93c60 | ||
| df15eb7a03 | |||
|
|
06674df6cc | ||
|
|
8295625a44 | ||
| b11e2a4291 | |||
| 353c18bb76 | |||
|
|
02b0c9e48a | ||
|
|
ff73c8ac88 | ||
|
|
bc48391a1a | ||
| b0a443c002 | |||
| bef1f136ad | |||
| 9ba584bf14 | |||
|
|
43509fc943 | ||
|
|
4a99eb94e2 | ||
| 74426e13c8 | |||
| bd45c36072 | |||
| c13aefcd15 | |||
| 167caee8bc | |||
| d527078d5c | |||
|
|
763ace5ddf | ||
| 057c57b70f | |||
| cb71786ac1 | |||
|
|
67afeac198 | ||
| f4ee25de8e | |||
| 445a218a9e | |||
| f09139ffbe | |||
| 446721729b | |||
|
|
e0250d7f5c | ||
| 9fcdac4edb | |||
| b726ae3c7c | |||
| a3460418f6 | |||
| f65175f11e | |||
| 16efd495a0 | |||
| ed6423e4aa | |||
| 0e9418949b | |||
| 240fe8bc7c | |||
| c3482cddd8 | |||
| d13e7b3ef6 | |||
| 47800bd2ff | |||
| c0305db5fc | |||
| 0b745cb40e | |||
| a20f5ca15d | |||
| c29b4e173e | |||
| 33dd8b1d8a | |||
| 1503d90bd5 | |||
| 6581ffb92b | |||
| 939dfd9cc1 | |||
|
|
a98ffd4887 | ||
|
|
2e23b3ae06 |
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 }}
|
||||
75
.github/workflows/main.yml
vendored
@@ -1,53 +1,56 @@
|
||||
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:
|
||||
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.settings.platform }}
|
||||
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 based macs.
|
||||
args: "--target universal-apple-darwin"
|
||||
#- platform: 'ubuntu-22.04'
|
||||
# args: ''
|
||||
#- platform: 'windows-latest'
|
||||
# args: '--target x86_64-pc-windows-msvc'
|
||||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
- 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
|
||||
node-version: "lts/*"
|
||||
|
||||
- name: Install PNPM
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8.x.x
|
||||
run_install: false
|
||||
- name: Setup node and cache for package data
|
||||
uses: actions/setup-node@v3
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
cache-on-failure: true
|
||||
- run: pnpm install
|
||||
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
|
||||
|
||||
- uses: tauri-apps/tauri-action@dev
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -62,9 +65,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.settings.args }}
|
||||
includeDebug: true
|
||||
args: ${{ matrix.args }}
|
||||
includeDebug: false
|
||||
|
||||
16
.gitignore
vendored
@@ -13,26 +13,26 @@ node_modules
|
||||
.env.production.local
|
||||
|
||||
# Testing
|
||||
coverage
|
||||
coverage/
|
||||
|
||||
# Turbo
|
||||
.turbo
|
||||
.turbo/
|
||||
|
||||
# Vercel
|
||||
.vercel
|
||||
.vercel/
|
||||
|
||||
# Build Outputs
|
||||
.next/
|
||||
out/
|
||||
build
|
||||
dist
|
||||
build/
|
||||
dist/
|
||||
|
||||
|
||||
# Debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
*.log.*
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
3
.gitmodules
vendored
@@ -1,3 +0,0 @@
|
||||
[submodule "modules/depot"]
|
||||
path = modules/depot
|
||||
url = https://github.com/luminous-devs/depot.git
|
||||
38
README.md
@@ -1,25 +1,31 @@
|
||||
### Introduction
|
||||
## Introduction
|
||||
|
||||
Lume is a nostr client
|
||||
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.
|
||||
|
||||
### Usage
|
||||
## Usage
|
||||
|
||||
Download Lume for your platform here: [https://github.com/luminous-devs/lume/releases](https://github.com/luminous-devs/lume/releases)
|
||||
Download Lume v4 for your platform here: [https://github.com/lumehq/lume/releases](https://github.com/lumehq/lume/releases)
|
||||
|
||||
Supported platform: macOS, Windows and Linux
|
||||
Supported platform: macOS. Windows and Linux are coming soon.
|
||||
|
||||
### Prerequisites
|
||||
Windows and Linux are availabel on v3 and below.
|
||||
|
||||
- PNPM or Bun (experiment)
|
||||
## Prerequisites
|
||||
|
||||
- Tauri: https://tauri.app/v1/guides/getting-started/prerequisites#setting-up-macos
|
||||
- Node.js >= 18: https://nodejs.org/en
|
||||
|
||||
### Develop
|
||||
- Rust: https://rustup.rs/
|
||||
|
||||
- PNPM: https://pnpm.io
|
||||
|
||||
- Tauri v2: https://beta.tauri.app/guides/prerequisites/
|
||||
|
||||
## Develop
|
||||
|
||||
Clone project
|
||||
|
||||
```
|
||||
git clone https://github.com/luminous-devs/lume.git && cd lume
|
||||
git clone https://github.com/lumehq/lume.git && cd lume
|
||||
```
|
||||
|
||||
Install packages
|
||||
@@ -40,7 +46,7 @@ Generate production build
|
||||
pnpm tauri build
|
||||
```
|
||||
|
||||
#### Nix
|
||||
## Nix
|
||||
|
||||
Requirements:
|
||||
|
||||
@@ -48,3 +54,13 @@ Requirements:
|
||||
1. [Setup `direnv`](https://zero-to-flakes.com/direnv)
|
||||
|
||||
`cd` into the root folder of the project to enter `nix develop` shell. Run `direnv allow` (only once). Then run `pnpm` or `bun` (experimental) commands as described above.
|
||||
|
||||
## License
|
||||
|
||||
Copyright (C) 2023-2024 Ren Amamiya & other Lume contributors (see AUTHORS.md)
|
||||
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU 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.
|
||||
|
||||
You should have received a copy of the GNU General Public License along with this program. If not, see https://www.gnu.org/licenses/.
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
<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>
|
||||
@@ -1,86 +0,0 @@
|
||||
{
|
||||
"name": "lume",
|
||||
"private": true,
|
||||
"version": "3.0.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@columns/antenas": "workspace:^",
|
||||
"@columns/default": "workspace:^",
|
||||
"@columns/group": "workspace:^",
|
||||
"@columns/hashtag": "workspace:^",
|
||||
"@columns/thread": "workspace:^",
|
||||
"@columns/timeline": "workspace:^",
|
||||
"@columns/user": "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.2",
|
||||
"@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-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.9",
|
||||
"@tauri-apps/api": "2.0.0-alpha.13",
|
||||
"@tauri-apps/plugin-autostart": "2.0.0-alpha.5",
|
||||
"@tauri-apps/plugin-clipboard-manager": "2.0.0-alpha.5",
|
||||
"@tauri-apps/plugin-dialog": "2.0.0-alpha.5",
|
||||
"@tauri-apps/plugin-fs": "2.0.0-alpha.6",
|
||||
"@tauri-apps/plugin-http": "2.0.0-alpha.6",
|
||||
"@tauri-apps/plugin-notification": "2.0.0-alpha.5",
|
||||
"@tauri-apps/plugin-os": "2.0.0-alpha.6",
|
||||
"@tauri-apps/plugin-process": "2.0.0-alpha.5",
|
||||
"@tauri-apps/plugin-shell": "2.0.0-alpha.5",
|
||||
"@tauri-apps/plugin-sql": "2.0.0-alpha.5",
|
||||
"@tauri-apps/plugin-updater": "2.0.0-alpha.5",
|
||||
"@tauri-apps/plugin-upload": "2.0.0-alpha.5",
|
||||
"@vidstack/react": "^1.9.8",
|
||||
"framer-motion": "^10.18.0",
|
||||
"jotai": "^2.6.1",
|
||||
"minidenticons": "^4.2.0",
|
||||
"nanoid": "^5.0.4",
|
||||
"nostr-fetch": "^0.15.0",
|
||||
"nostr-tools": "^1.17.0",
|
||||
"qrcode.react": "^3.1.0",
|
||||
"react": "^18.2.0",
|
||||
"react-currency-input-field": "^3.6.14",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.49.3",
|
||||
"react-router-dom": "^6.21.2",
|
||||
"smol-toml": "^1.1.3",
|
||||
"sonner": "^1.3.1",
|
||||
"unique-names-generator": "^4.7.1",
|
||||
"virtua": "^0.20.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lume/tailwindcss": "workspace:^",
|
||||
"@lume/tsconfig": "workspace:^",
|
||||
"@lume/types": "workspace:^",
|
||||
"@types/node": "^20.11.0",
|
||||
"@types/react": "^18.2.47",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"cross-env": "^7.0.3",
|
||||
"encoding": "^0.1.13",
|
||||
"postcss": "^8.4.33",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.11",
|
||||
"vite-plugin-top-level-await": "^1.4.1",
|
||||
"vite-tsconfig-paths": "^4.2.3"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 398 KiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 428 KiB |
|
Before Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 416 KiB |
|
Before Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 381 KiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 986 KiB |
|
Before Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 217 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 473 KiB |
|
Before Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 2.8 MiB |
|
Before Width: | Height: | Size: 341 KiB |
@@ -1,41 +0,0 @@
|
||||
/* Vidstack */
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { ColumnProvider, LumeProvider } from "@lume/ark";
|
||||
import { StorageProvider } from "@lume/storage";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Toaster } from "sonner";
|
||||
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 (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Toaster position="top-center" theme="system" closeButton />
|
||||
<StorageProvider>
|
||||
<LumeProvider>
|
||||
<ColumnProvider>
|
||||
<Router />
|
||||
</ColumnProvider>
|
||||
</LumeProvider>
|
||||
</StorageProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
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 />);
|
||||
@@ -1,323 +0,0 @@
|
||||
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 { UserSettingScreen } = await import(
|
||||
"./routes/settings"
|
||||
);
|
||||
return { Component: UserSettingScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "edit-profile",
|
||||
async lazy() {
|
||||
const { EditProfileScreen } = await import(
|
||||
"./routes/settings/editProfile"
|
||||
);
|
||||
return { Component: EditProfileScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "edit-contact",
|
||||
async lazy() {
|
||||
const { EditContactScreen } = await import(
|
||||
"./routes/settings/editContact"
|
||||
);
|
||||
return { Component: EditContactScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "general",
|
||||
async lazy() {
|
||||
const { GeneralSettingScreen } = await import(
|
||||
"./routes/settings/general"
|
||||
);
|
||||
return { Component: GeneralSettingScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
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",
|
||||
loader: async () => {
|
||||
return await ark.getOAuthServices();
|
||||
},
|
||||
async lazy() {
|
||||
const { CreateAccountScreen } = await import(
|
||||
"./routes/auth/create"
|
||||
);
|
||||
return { Component: CreateAccountScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
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 };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "tutorials/note",
|
||||
async lazy() {
|
||||
const { TutorialNoteScreen } = await import(
|
||||
"./routes/auth/tutorials/note"
|
||||
);
|
||||
return { Component: TutorialNoteScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "tutorials/widget",
|
||||
async lazy() {
|
||||
const { TutorialWidgetScreen } = await import(
|
||||
"./routes/auth/tutorials/widget"
|
||||
);
|
||||
return { Component: TutorialWidgetScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "tutorials/posting",
|
||||
async lazy() {
|
||||
const { TutorialPostingScreen } = await import(
|
||||
"./routes/auth/tutorials/posting"
|
||||
);
|
||||
return { Component: TutorialPostingScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "tutorials/finish",
|
||||
async lazy() {
|
||||
const { TutorialFinishScreen } = await import(
|
||||
"./routes/auth/tutorials/finish"
|
||||
);
|
||||
return { Component: TutorialFinishScreen };
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { User } from "@lume/ark";
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export function ActivityRepost({ event }: { event: NDKEvent }) {
|
||||
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">reposted</p>
|
||||
</div>
|
||||
</div>
|
||||
<User.Time
|
||||
time={event.created_at}
|
||||
className="text-neutral-500 dark:text-neutral-400"
|
||||
/>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { User } from "@lume/ark";
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export function ActivityText({ event }: { event: NDKEvent }) {
|
||||
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">mention you</p>
|
||||
</div>
|
||||
</div>
|
||||
<User.Time
|
||||
time={event.created_at}
|
||||
className="text-neutral-500 dark:text-neutral-400"
|
||||
/>
|
||||
</User.Root>
|
||||
</User.Provider>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { User } from "@lume/ark";
|
||||
import { compactNumber } from "@lume/utils";
|
||||
import { NDKEvent, zapInvoiceFromEvent } from "@nostr-dev-kit/ndk";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export function ActivityZap({ event }: { event: NDKEvent }) {
|
||||
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">
|
||||
zapped {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>
|
||||
);
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
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 { ActivityRepost } from "./activityRepost";
|
||||
import { ActivityText } from "./activityText";
|
||||
import { ActivityZap } from "./activityZap";
|
||||
|
||||
export function ActivityList() {
|
||||
const ark = useArk();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
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">Yo! Nothing new yet.</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" />
|
||||
Load more
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { User } from "@lume/ark";
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
import { ActivityRootNote } from "./rootNote";
|
||||
|
||||
export function ActivitySingleRepost({ event }: { event: NDKEvent }) {
|
||||
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">Boost</h3>
|
||||
<p className="text-sm text-blue-500 font-medium leading-tight">
|
||||
@ Someone has reposted 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">Reposted</h3>
|
||||
<div className="h-4 w-px bg-blue-500" />
|
||||
</div>
|
||||
<ActivityRootNote eventId={repostId} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { Note, useArk } from "@lume/ark";
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
import { ActivityRootNote } from "./rootNote";
|
||||
|
||||
export function ActivitySingleText({ event }: { event: NDKEvent }) {
|
||||
const ark = useArk();
|
||||
const thread = ark.getEventThread({
|
||||
content: event.content,
|
||||
tags: event.tags,
|
||||
});
|
||||
|
||||
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="overflow-y-auto">
|
||||
<div className="max-w-xl mx-auto py-6">
|
||||
{thread ? (
|
||||
<div className="flex flex-col gap-3 mb-1">
|
||||
<ActivityRootNote eventId={thread.rootEventId} />
|
||||
<ActivityRootNote eventId={thread.replyEventId} />
|
||||
</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">New reply</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>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
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} />;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { ActivityList } from "./components/list";
|
||||
|
||||
export function ActivityScreen() {
|
||||
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">
|
||||
Activity
|
||||
</div>
|
||||
<ActivityList />
|
||||
</div>
|
||||
<div className="flex-1 rounded-r-xl bg-white pb-20 dark:bg-black">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,302 +0,0 @@
|
||||
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 { downloadDir } from "@tauri-apps/api/path";
|
||||
import { Window } from "@tauri-apps/api/window";
|
||||
import { save } from "@tauri-apps/plugin-dialog";
|
||||
import { writeTextFile } from "@tauri-apps/plugin-fs";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { getPublicKey, nip19 } from "nostr-tools";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
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 CreateAccountScreen() {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
const navigate = useNavigate();
|
||||
const services = useLoaderData() as NDKEvent[];
|
||||
const setOnboarding = useSetAtom(onboardingAtom);
|
||||
|
||||
const [serviceId, setServiceId] = useState(services?.[0]?.id);
|
||||
const [loading, setIsLoading] = useState(false);
|
||||
|
||||
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 generateNostrKeys = async () => {
|
||||
const signer = NDKPrivateKeySigner.generate();
|
||||
const pubkey = getPublicKey(signer.privateKey);
|
||||
|
||||
const npub = nip19.npubEncode(pubkey);
|
||||
const nsec = nip19.nsecEncode(signer.privateKey);
|
||||
|
||||
ark.updateNostrSigner({ signer });
|
||||
|
||||
const downloadPath = await downloadDir();
|
||||
const fileName = `nostr_keys_${new Date().getTime().toString(36)}.txt`;
|
||||
const filePath = await save({
|
||||
defaultPath: `${downloadPath}/${fileName}`,
|
||||
});
|
||||
|
||||
if (filePath) {
|
||||
await writeTextFile(
|
||||
filePath,
|
||||
`Nostr account, generated by Lume (lume.nu)\nPublic key: ${npub}\nPrivate key: ${nsec}`,
|
||||
);
|
||||
} // else { user cancel action }
|
||||
|
||||
await storage.createAccount({
|
||||
pubkey: pubkey,
|
||||
privkey: signer.privateKey,
|
||||
});
|
||||
|
||||
setOnboarding(true);
|
||||
|
||||
return navigate("/auth/onboarding", { replace: true });
|
||||
};
|
||||
|
||||
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 authWindow: Window;
|
||||
remoteSigner.addListener("authUrl", (authUrl: string) => {
|
||||
authWindow = new Window(`auth-${serviceId}`, {
|
||||
url: authUrl,
|
||||
title: domain,
|
||||
titleBarStyle: "overlay",
|
||||
width: 415,
|
||||
height: 600,
|
||||
center: true,
|
||||
closable: false,
|
||||
});
|
||||
});
|
||||
|
||||
// create new account
|
||||
const account = await remoteSigner.createAccount(
|
||||
data.username,
|
||||
domain,
|
||||
data.email,
|
||||
);
|
||||
|
||||
if (!account) {
|
||||
authWindow.close();
|
||||
setIsLoading(false);
|
||||
|
||||
return toast.error("Failed to create new account, try again later");
|
||||
}
|
||||
|
||||
authWindow.close();
|
||||
|
||||
// add account to storage
|
||||
await storage.createSetting("nsecbunker", "1");
|
||||
const dbAccount = await storage.createAccount({
|
||||
pubkey: account,
|
||||
privkey: localSigner.privateKey,
|
||||
});
|
||||
ark.account = dbAccount;
|
||||
|
||||
// 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: [] });
|
||||
|
||||
setOnboarding(true);
|
||||
setIsLoading(false);
|
||||
|
||||
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">
|
||||
Let's get you set up on Nostr.
|
||||
</h1>
|
||||
<p className="text-lg font-medium leading-snug text-neutral-600 dark:text-neutral-500">
|
||||
With an account on Nostr, you'll be able to travel across all nostr
|
||||
clients, all your data are synced.
|
||||
</p>
|
||||
</div>
|
||||
{!services ? (
|
||||
<div className="flex items-center justify-center w-full">
|
||||
<LoaderIcon className="size-5 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-6">
|
||||
<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"
|
||||
>
|
||||
Username *
|
||||
</label>
|
||||
<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}
|
||||
placeholder="satoshi"
|
||||
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="border rounded-lg bg-neutral-950 border-neutral-900">
|
||||
<Select.Viewport className="p-3">
|
||||
<Select.Group>
|
||||
<Select.Label className="mb-2 text-sm font-medium uppercase px-7 text-neutral-600">
|
||||
Public handles
|
||||
</Select.Label>
|
||||
{services.map((service) => (
|
||||
<Item key={service.id} event={service} />
|
||||
))}
|
||||
</Select.Group>
|
||||
</Select.Viewport>
|
||||
</Select.Content>
|
||||
</Select.Portal>
|
||||
</Select.Root>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="text-sm font-semibold uppercase text-neutral-600"
|
||||
>
|
||||
Backup Email (Optional)
|
||||
</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>
|
||||
</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" />
|
||||
) : (
|
||||
"Create Account"
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
<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">
|
||||
Or
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={generateNostrKeys}
|
||||
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"
|
||||
>
|
||||
Generate Nostr Keys
|
||||
</button>
|
||||
<p className="text-sm text-center text-neutral-500">
|
||||
If you are using this option, please make sure keep your keys
|
||||
in safe place. You{" "}
|
||||
<span className="text-red-600">cannot recover</span> if it
|
||||
lost, all your data will be{" "}
|
||||
<span className="text-red-600">lost forever.</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
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 { 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 {
|
||||
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">Enter your Private Key</h1>
|
||||
<p className="text-lg font-medium leading-snug text-neutral-600 dark:text-neutral-500">
|
||||
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>
|
||||
.
|
||||
<br />
|
||||
It will be secured by your OS.
|
||||
</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" />
|
||||
) : (
|
||||
"Continue"
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
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 { 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 {
|
||||
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">
|
||||
Enter your nsecbunker token
|
||||
</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" />
|
||||
) : (
|
||||
"Continue"
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
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 { 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 {
|
||||
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">Enter your NIP-05 address</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" />
|
||||
) : (
|
||||
"Continue"
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export function LoginScreen() {
|
||||
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">
|
||||
Continue your experience on Nostr
|
||||
</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"
|
||||
>
|
||||
Login with address
|
||||
</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"
|
||||
>
|
||||
Login with nsecbunker
|
||||
</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">
|
||||
Or (Not recommend)
|
||||
</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"
|
||||
>
|
||||
Login with Private Key
|
||||
</Link>
|
||||
<p className="text-sm text-center text-neutral-500">
|
||||
Lume will store your Private Key in{" "}
|
||||
<span className="text-teal-600">OS Secure Storage</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
import { useArk } from "@lume/ark";
|
||||
import { InfoIcon, LoaderIcon } from "@lume/icons";
|
||||
import { useStorage } from "@lume/storage";
|
||||
import { TranslateRegisterModal } from "@lume/ui";
|
||||
import { FETCH_LIMIT } from "@lume/utils";
|
||||
import { NDKKind } from "@nostr-dev-kit/ndk";
|
||||
import * as Switch from "@radix-ui/react-switch";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
isPermissionGranted,
|
||||
requestPermission,
|
||||
} from "@tauri-apps/plugin-notification";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function OnboardingScreen() {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
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();
|
||||
|
||||
// refetch newsfeed
|
||||
await queryClient.prefetchInfiniteQuery({
|
||||
queryKey: ["timeline-9999"],
|
||||
initialPageParam: 0,
|
||||
queryFn: async ({
|
||||
signal,
|
||||
pageParam,
|
||||
}: {
|
||||
signal: AbortSignal;
|
||||
pageParam: number;
|
||||
}) => {
|
||||
const events = await ark.getInfiniteEvents({
|
||||
filter: {
|
||||
kinds: [NDKKind.Text, NDKKind.Repost],
|
||||
authors: ark.account.contacts,
|
||||
},
|
||||
limit: FETCH_LIMIT,
|
||||
pageParam,
|
||||
signal,
|
||||
});
|
||||
|
||||
return events;
|
||||
},
|
||||
});
|
||||
|
||||
navigate("/");
|
||||
};
|
||||
|
||||
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-10">
|
||||
<div className="flex flex-col gap-1 text-center items-center">
|
||||
<h1 className="text-2xl font-semibold">
|
||||
You're almost ready to use Lume.
|
||||
</h1>
|
||||
<p className="text-lg font-medium leading-snug text-neutral-600 dark:text-neutral-500">
|
||||
Let's start personalizing your experience.
|
||||
</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">Push notification</h3>
|
||||
<p className="text-neutral-500">
|
||||
Enabling push notifications will allow you to receive
|
||||
notifications from Lume.
|
||||
</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">Low Power Mode</h3>
|
||||
<p className="text-neutral-500">
|
||||
Limited relay connection and hide all media, sustainable for low
|
||||
network environment.
|
||||
</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">
|
||||
Translation (nostr.wine)
|
||||
</h3>
|
||||
<p className="text-neutral-500">
|
||||
Translate text to your preferred language, powered by Nostr
|
||||
Wine.
|
||||
</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">
|
||||
Not have API ?
|
||||
</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>
|
||||
There are many more settings you can configure from the
|
||||
"Settings" screen. Be sure to visit it later.
|
||||
</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" />
|
||||
) : (
|
||||
"Continue"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export function TutorialFinishScreen() {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="mx-auto flex w-full max-w-md flex-col gap-10">
|
||||
<div className="text-center">
|
||||
<img
|
||||
src="/icon.png"
|
||||
alt="Lume's logo"
|
||||
className="mx-auto mb-1 h-auto w-16"
|
||||
/>
|
||||
<h1 className="text-2xl font-light">
|
||||
Yo, you've understood basic features 🎉
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600"
|
||||
>
|
||||
Start using Lume
|
||||
</Link>
|
||||
<Link
|
||||
to="https://nostr.how/"
|
||||
target="_blank"
|
||||
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-neutral-100 font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||
>
|
||||
Learn more about Nostr
|
||||
</Link>
|
||||
<p className="text-center text-sm font-medium text-neutral-500 dark:text-neutral-600">
|
||||
If you've trouble when user Lume, you can report the issue{" "}
|
||||
<a
|
||||
href="github.com/luminous-devs/lume"
|
||||
target="_blank"
|
||||
className="text-blue-500 !underline"
|
||||
rel="noreferrer"
|
||||
>
|
||||
here
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { TextNote } from "@lume/ark";
|
||||
import {
|
||||
EditIcon,
|
||||
ReactionIcon,
|
||||
ReplyIcon,
|
||||
RepostIcon,
|
||||
ZapIcon,
|
||||
} from "@lume/icons";
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export function TutorialNoteScreen() {
|
||||
const exampleEvent = new NDKEvent(undefined, {
|
||||
id: "a3527670dd9b178bf7c2a9ea673b63bc8bfe774942b196691145343623c45821",
|
||||
pubkey: "04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9",
|
||||
created_at: 1701355223,
|
||||
kind: 1,
|
||||
tags: [],
|
||||
content: "good morning nostr, stay humble and stack sats 🫡",
|
||||
sig: "9e0bd67ec25598744f20bff0fe360fdf190c4240edb9eea260e50f77e07f94ea767ececcc6270819b7f64e5e7ca1fe20b4971f46dc120e6db43114557f3a6dae",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full select-text items-center justify-center">
|
||||
<div className="mx-auto flex w-full max-w-md flex-col gap-10">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="inline-flex h-11 w-11 items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
|
||||
<EditIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-light">
|
||||
What is a <span className="font-bold">Note?</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="px-3">
|
||||
Posts on Nostr based Social Network client are usually called
|
||||
'Notes.' Notes are arranged chronologically on the
|
||||
timeline and are updated in real-time.
|
||||
</p>
|
||||
<p className="px-3 font-semibold">Here is one example:</p>
|
||||
<TextNote event={exampleEvent} />
|
||||
<p className="px-3 font-semibold">
|
||||
Here are how you can interact with a note:
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 px-3">
|
||||
<div className="inline-flex gap-3">
|
||||
<div className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
|
||||
<ReplyIcon className="h-5 w-5 text-blue-500" />
|
||||
</div>
|
||||
<p>
|
||||
Reply - Click on this button to reply to a note. It's also
|
||||
possible to reply to replies, continuing the conversation like a
|
||||
thread.
|
||||
</p>
|
||||
</div>
|
||||
<div className="inline-flex gap-3">
|
||||
<div className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
|
||||
<ReactionIcon className="h-5 w-5 text-red-500" />
|
||||
</div>
|
||||
<p>
|
||||
Reaction - You can add reactions to the Note to express your
|
||||
concern.
|
||||
</p>
|
||||
</div>
|
||||
<div className="inline-flex gap-3">
|
||||
<div className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
|
||||
<RepostIcon className="h-5 w-5 text-teal-500" />
|
||||
</div>
|
||||
<p>
|
||||
Repost - You can share that note to your own timeline. You can
|
||||
also quote them with your comments.
|
||||
</p>
|
||||
</div>
|
||||
<div className="inline-flex gap-3">
|
||||
<div className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
|
||||
<ZapIcon className="h-5 w-5 text-orange-500" />
|
||||
</div>
|
||||
<p>
|
||||
Zap - You can send tip in Bitcoin to that note owner with
|
||||
zero-fees
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex gap-2 px-3">
|
||||
<Link
|
||||
to="/auth/finish"
|
||||
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-neutral-100 font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||
>
|
||||
Back
|
||||
</Link>
|
||||
<Link
|
||||
to="/auth/tutorials/widget"
|
||||
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600"
|
||||
>
|
||||
Continue
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export function TutorialPostingScreen() {
|
||||
return <div></div>;
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { BellIcon, HomeIcon, PlusIcon } from "@lume/icons";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export function TutorialWidgetScreen() {
|
||||
return (
|
||||
<div className="flex h-full w-full select-text items-center justify-center">
|
||||
<div className="mx-auto flex w-full max-w-md flex-col gap-10">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="inline-flex h-11 w-11 items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
|
||||
<HomeIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-light">
|
||||
The concept of <span className="font-bold">Widgets</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 px-3">
|
||||
<p>
|
||||
Lume provides multiple widgets based on usage. You always can
|
||||
control what you need to show on your Home.
|
||||
</p>
|
||||
<p className="font-semibold">Default widgets:</p>
|
||||
<div className="inline-flex gap-3">
|
||||
<div className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
|
||||
<HomeIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<p>Newsfeed - You can view notes from accounts you follow.</p>
|
||||
</div>
|
||||
<div className="inline-flex gap-3">
|
||||
<div className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
|
||||
<BellIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<p>
|
||||
Notification - You can view all notifications related to your
|
||||
account.
|
||||
</p>
|
||||
</div>
|
||||
<p>
|
||||
If you want to add more widget, you can click to this button on Home
|
||||
Screen.
|
||||
</p>
|
||||
<div className="flex h-24 w-full items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-14 w-14 items-center justify-center rounded-full bg-neutral-200 text-neutral-900 hover:bg-neutral-300 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-5 flex gap-2">
|
||||
<Link
|
||||
to="/auth/tutorials/note"
|
||||
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-neutral-100 font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||
>
|
||||
Back
|
||||
</Link>
|
||||
<Link
|
||||
to="/auth/tutorials/finish"
|
||||
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600"
|
||||
>
|
||||
Continue
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { LoaderIcon } from "@lume/icons";
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
|
||||
export function WelcomeScreen() {
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const gotoCreateAccount = () => {
|
||||
setLoading(true);
|
||||
navigate("/auth/create");
|
||||
};
|
||||
|
||||
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 font-medium leading-snug text-neutral-600 dark:text-neutral-500">
|
||||
Lume is your safe Nostr client to meet, explore and
|
||||
<br />
|
||||
freely sharing your though to everyone in nostrverse
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col w-full max-w-xs gap-2 mx-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={gotoCreateAccount}
|
||||
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" />
|
||||
) : (
|
||||
"Create New Account"
|
||||
)}
|
||||
</button>
|
||||
<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"
|
||||
>
|
||||
Login
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center h-11">
|
||||
<p className="text-neutral-800">
|
||||
Before joining Nostr, you can take time to learn more about Nostr{" "}
|
||||
<Link
|
||||
to="https://nostr.com"
|
||||
target="_blank"
|
||||
className="text-blue-500"
|
||||
>
|
||||
here
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
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(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>
|
||||
);
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
import { CancelIcon, PlusIcon, UserAddIcon, UserRemoveIcon } from "@lume/icons";
|
||||
import { User } from "@lume/ui";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import { resolveResource } 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";
|
||||
|
||||
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 = 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 = 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>
|
||||
);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import { useArk } from "@lume/ark";
|
||||
import { LoaderIcon } from "@lume/icons";
|
||||
import { useStorage } from "@lume/storage";
|
||||
import { delay } from "@lume/utils";
|
||||
import { resolveResource } from "@tauri-apps/api/path";
|
||||
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 = 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(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>
|
||||
);
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
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 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-600 overflow-hidden rounded-t-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's Devs
|
||||
</div>
|
||||
<a
|
||||
href="https://github.com/luminous-devs/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's Devs to 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://primal.net"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
primal.net
|
||||
</a>
|
||||
<a
|
||||
className="hover:!underline"
|
||||
href="https://nostrudel.ninja"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
nostrudel.ninja
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import { Antenas } from "@columns/antenas";
|
||||
import { Default } from "@columns/default";
|
||||
import { Group } from "@columns/group";
|
||||
import { Hashtag } from "@columns/hashtag";
|
||||
import { Thread } from "@columns/thread";
|
||||
import { Timeline } from "@columns/timeline";
|
||||
import { User } from "@columns/user";
|
||||
import { useColumnContext } from "@lume/ark";
|
||||
import { ArrowLeftIcon, ArrowRightIcon, PlusSquareIcon } from "@lume/icons";
|
||||
import { IColumn } from "@lume/types";
|
||||
import { TutorialModal } from "@lume/ui/src/tutorial/modal";
|
||||
import { COL_TYPES } from "@lume/utils";
|
||||
import { useRef, useState } from "react";
|
||||
import { VList, VListHandle } from "virtua";
|
||||
|
||||
export function HomeScreen() {
|
||||
const ref = useRef<VListHandle>(null);
|
||||
const { addColumn, columns } = 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.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} />;
|
||||
default:
|
||||
return <Default key={column.id} column={column} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
<VList
|
||||
id="timeline"
|
||||
ref={ref}
|
||||
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 (!ref.current) return;
|
||||
switch (e.code) {
|
||||
case "ArrowUp":
|
||||
case "ArrowLeft": {
|
||||
e.preventDefault();
|
||||
const prevIndex = Math.max(selectedIndex - 1, 0);
|
||||
setSelectedIndex(prevIndex);
|
||||
ref.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);
|
||||
ref.current.scrollToIndex(nextIndex, {
|
||||
align: "center",
|
||||
smooth: true,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{columns.map((column) => renderItem(column))}
|
||||
<div className="w-[420px]" />
|
||||
</VList>
|
||||
<div className="absolute bottom-3 right-3">
|
||||
<div className="flex items-center gap-1 p-1 bg-black/30 dark:bg-white/30 backdrop-blur-xl rounded-xl">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const prevIndex = Math.max(selectedIndex - 1, 0);
|
||||
setSelectedIndex(prevIndex);
|
||||
ref.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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const nextIndex = Math.min(selectedIndex + 1, columns.length - 1);
|
||||
setSelectedIndex(nextIndex);
|
||||
ref.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>
|
||||
<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>
|
||||
<div className="w-px h-6 bg-white/10" />
|
||||
<TutorialModal />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
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 { VList } from "virtua";
|
||||
|
||||
export function RelayEventList({ relayUrl }: { relayUrl: string }) {
|
||||
const ark = useArk();
|
||||
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;
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const allEvents = useMemo(
|
||||
() => (data ? data.pages.flatMap((page) => page) : []),
|
||||
[data],
|
||||
);
|
||||
|
||||
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 />
|
||||
) : (
|
||||
allEvents.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" />
|
||||
Load more
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</VList>
|
||||
);
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { useRelaylist } from "@lume/ark";
|
||||
import { PlusIcon } from "@lume/icons";
|
||||
import { NDKRelayUrl } from "@nostr-dev-kit/ndk";
|
||||
import { normalizeRelayUrl } from "nostr-fetch";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const domainRegex = /^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/;
|
||||
|
||||
export function RelayForm() {
|
||||
const { connectRelay } = useRelaylist();
|
||||
|
||||
const [relay, setRelay] = useState<{
|
||||
url: NDKRelayUrl;
|
||||
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 (
|
||||
domainRegex.test(relayUrl.host) &&
|
||||
(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>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { useRelaylist } from "@lume/ark";
|
||||
import { PlusIcon, ShareIcon } from "@lume/icons";
|
||||
import { normalizeRelayUrl } from "nostr-fetch";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export function RelayItem({ url }: { url: string }) {
|
||||
const domain = new URL(url).hostname;
|
||||
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">
|
||||
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">
|
||||
<Link
|
||||
to={`/relays/${domain}/`}
|
||||
className="inline-flex h-6 items-center justify-center gap-1 rounded bg-neutral-100 px-1.5 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<ShareIcon className="h-3 w-3" />
|
||||
Inspect
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => connectRelay.mutate(normalizeRelayUrl(url))}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded bg-blue-100 text-blue-500 hover:bg-blue-200 dark:bg-blue-900 hover:dark:bg-blue-800"
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { useArk, useRelay } from "@lume/ark";
|
||||
import { LoaderIcon, PlusIcon, ShareIcon } from "@lume/icons";
|
||||
import { User } from "@lume/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { VList } from "virtua";
|
||||
|
||||
export function RelayList() {
|
||||
const ark = useArk();
|
||||
const { connectRelay } = useRelay();
|
||||
const { status, data } = useQuery({
|
||||
queryKey: ["relays"],
|
||||
queryFn: async () => {
|
||||
return await ark.getAllRelaysFromContacts();
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const inspectRelay = (relayUrl: string) => {
|
||||
const url = new URL(relayUrl);
|
||||
navigate(`/relays/${url.hostname}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="col-span-2 bg-white">
|
||||
{status === "pending" ? (
|
||||
<div className="flex h-full w-full items-center justify-center pb-10">
|
||||
<div className="inline-flex flex-col items-center justify-center gap-2">
|
||||
<LoaderIcon className="h-5 w-5 animate-spin text-neutral-900 dark:text-neutral-100" />
|
||||
<p>Loading relay...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<VList className="h-full">
|
||||
<div className="inline-flex h-16 w-full items-center border-b border-neutral-100 px-3 dark:border-neutral-900">
|
||||
<h3 className="font-semibold">Relay discovery</h3>
|
||||
</div>
|
||||
{[...data].map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex h-14 w-full items-center justify-between border-b border-neutral-100 px-3 dark:border-neutral-900"
|
||||
>
|
||||
<div className="inline-flex items-center gap-2 divide-x divide-neutral-100 dark:divide-neutral-900">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inspectRelay(key)}
|
||||
className="inline-flex h-6 items-center justify-center gap-1 rounded bg-neutral-200 px-1.5 text-sm font-medium text-neutral-900 hover:bg-neutral-300 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<ShareIcon className="h-3 w-3" />
|
||||
Inspect
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => connectRelay.mutate(key)}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded text-neutral-900 hover:bg-neutral-200 dark:text-neutral-100 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<PlusIcon className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-2 pl-3">
|
||||
<span className="text-sm font-semibold text-neutral-500 dark:text-neutral-400">
|
||||
Relay:{" "}
|
||||
</span>
|
||||
<span className="max-w-[200px] truncate text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{key}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="isolate flex -space-x-2">
|
||||
{value.slice(0, 4).map((item) => (
|
||||
<User key={item} pubkey={item} variant="stacked" />
|
||||
))}
|
||||
{value.length > 4 ? (
|
||||
<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">+{value.length}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</VList>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import { useArk } from "@lume/ark";
|
||||
import { CancelIcon, RefreshIcon } from "@lume/icons";
|
||||
import { cn } from "@lume/utils";
|
||||
import { NDKKind } from "@nostr-dev-kit/ndk";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { RelayForm } from "./relayForm";
|
||||
|
||||
export function RelaySidebar({ className }: { className?: string }) {
|
||||
const ark = useArk();
|
||||
|
||||
const { status, data, refetch } = useQuery({
|
||||
queryKey: ["relay-personal"],
|
||||
queryFn: async () => {
|
||||
const event = await ark.getEventByFilter({
|
||||
filter: {
|
||||
kinds: [NDKKind.RelayList],
|
||||
authors: [ark.account.pubkey],
|
||||
},
|
||||
});
|
||||
|
||||
if (!event) return [];
|
||||
return event.tags.filter((tag) => tag[0] === "r");
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
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">Connected relays</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="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 px-3 mt-3">
|
||||
{status === "pending" ? (
|
||||
<p>Loading...</p>
|
||||
) : !data.length ? (
|
||||
<div className="flex items-center justify-center w-full h-20 rounded-xl bg-neutral-50 dark:bg-neutral-950">
|
||||
<p className="text-sm font-medium">
|
||||
You not have personal relay list yet
|
||||
</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://", "")}
|
||||
</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"
|
||||
className="items-center justify-center hidden w-6 h-6 rounded group-hover:inline-flex hover:bg-neutral-300 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<CancelIcon className="w-4 h-4 text-neutral-900 dark:text-neutral-100" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<RelayForm />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { useArk } from "@lume/ark";
|
||||
import { LoaderIcon, PlusIcon, ShareIcon } 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, data: relays } = useQuery({
|
||||
queryKey: ["relay-follows"],
|
||||
queryFn: async ({ signal }: { signal: AbortSignal }) => {
|
||||
return await ark.getAllRelaysFromContacts();
|
||||
},
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: 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>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
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,
|
||||
});
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { cn } from "@lume/utils";
|
||||
import { NavLink, Outlet } from "react-router-dom";
|
||||
import { RelaySidebar } from "./components/sidebar";
|
||||
|
||||
export function RelaysScreen() {
|
||||
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"
|
||||
: "",
|
||||
)
|
||||
}
|
||||
>
|
||||
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"
|
||||
: "",
|
||||
)
|
||||
}
|
||||
>
|
||||
Follows
|
||||
</NavLink>
|
||||
</div>
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
import { ArrowLeftIcon, LoaderIcon } from "@lume/icons";
|
||||
import { NIP11 } from "@lume/types";
|
||||
import { User } from "@lume/ui";
|
||||
import { Suspense } from "react";
|
||||
import { Await, useLoaderData, useNavigate, useParams } from "react-router-dom";
|
||||
import { RelayEventList } from "./components/relayEventList";
|
||||
|
||||
export function RelayUrlScreen() {
|
||||
const { url } = useParams();
|
||||
|
||||
const data: { relay?: { [key: string]: string } } = useLoaderData();
|
||||
const navigate = useNavigate();
|
||||
|
||||
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" />
|
||||
Loading...
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Await
|
||||
resolve={data.relay}
|
||||
errorElement={
|
||||
<div className="text-sm font-medium">
|
||||
<p>Could not load relay information 😬</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">
|
||||
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">
|
||||
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">
|
||||
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">
|
||||
Supported 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">
|
||||
Limitation
|
||||
</h5>
|
||||
<div className="flex flex-col gap-2 divide-y divide-white/5">
|
||||
{Object.keys(resolvedRelay.limitation).map(
|
||||
(key, index) => {
|
||||
return (
|
||||
<div
|
||||
key={key + index}
|
||||
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"
|
||||
>
|
||||
Open payment website
|
||||
</a>
|
||||
<span className="text-center text-xs text-neutral-600 dark:text-neutral-400">
|
||||
You need to make a payment to connect this relay
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</Await>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
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 { Link } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function AboutScreen() {
|
||||
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 items-center justify-center gap-2">
|
||||
<img src="/icon.png" alt="Lume's logo" className="w-16 shrink-0" />
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Lume</h1>
|
||||
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
Version {version}
|
||||
</p>
|
||||
</div>
|
||||
</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"
|
||||
>
|
||||
Check for update
|
||||
</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"
|
||||
>
|
||||
Install {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/luminous-devs/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>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { useStorage } from "@lume/storage";
|
||||
|
||||
export function AdvancedSettingScreen() {
|
||||
const storage = useStorage();
|
||||
|
||||
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">
|
||||
Cache
|
||||
</div>
|
||||
<div className="text-sm">Use for boost up nostr connection</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"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { EyeOffIcon } from "@lume/icons";
|
||||
import { useStorage } from "@lume/storage";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function BackupSettingScreen() {
|
||||
const storage = useStorage();
|
||||
|
||||
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 className="mb-2 text-sm font-semibold">Private key</div>
|
||||
<div>
|
||||
{!privkey ? (
|
||||
<div className="inline-flex h-24 w-full items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
|
||||
You've stored private key on Lume
|
||||
</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-9 w-full items-center justify-center gap-2 rounded-lg bg-red-200 px-6 font-medium text-red-500 hover:bg-red-500 hover:text-white focus:outline-none dark:hover:text-white"
|
||||
>
|
||||
Remove private key
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { useArk } from "@lume/ark";
|
||||
import { EditIcon, LoaderIcon } from "@lume/icons";
|
||||
import { compactNumber } from "@lume/utils";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export function ContactCard() {
|
||||
const ark = useArk();
|
||||
const { status, data } = useQuery({
|
||||
queryKey: ["contacts"],
|
||||
queryFn: async () => {
|
||||
const contacts = await ark.getUserContacts();
|
||||
return contacts;
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="col-span-1 h-44 rounded-2xl bg-neutral-100 transition-all duration-150 ease-smooth hover:scale-105 dark:bg-neutral-900">
|
||||
{status === "pending" ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-col justify-between p-4">
|
||||
<h3 className="pt-1 text-5xl font-semibold tabular-nums text-neutral-900 dark:text-neutral-100">
|
||||
{compactNumber.format(data.length)}
|
||||
</h3>
|
||||
<div className="mt-auto flex h-6 w-full items-center justify-between">
|
||||
<p className="text-xl font-medium leading-none text-neutral-600 dark:text-neutral-400">
|
||||
Contacts
|
||||
</p>
|
||||
<Link
|
||||
to="/settings/edit-contact"
|
||||
className="inline-flex h-6 w-max items-center gap-1 rounded-full bg-neutral-200 px-2.5 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<EditIcon className="h-3 w-3" />
|
||||
Edit
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { useArk } from "@lume/ark";
|
||||
import { LoaderIcon } from "@lume/icons";
|
||||
import { compactNumber } from "@lume/utils";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetch } from "@tauri-apps/plugin-http";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export function PostCard() {
|
||||
const ark = useArk();
|
||||
const { status, data } = useQuery({
|
||||
queryKey: ["user-stats", ark.account.pubkey],
|
||||
queryFn: async ({ signal }: { signal: AbortSignal }) => {
|
||||
const res = await fetch(
|
||||
`https://api.nostr.band/v0/stats/profile/${ark.account.pubkey}`,
|
||||
{
|
||||
signal,
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("Error");
|
||||
}
|
||||
|
||||
return await res.json();
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="col-span-1 h-44 rounded-2xl bg-neutral-100 transition-all duration-150 ease-smooth hover:scale-105 dark:bg-neutral-900">
|
||||
{status === "pending" ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-col justify-between p-4">
|
||||
<h3 className="pt-1 text-5xl font-semibold tabular-nums text-neutral-900 dark:text-neutral-100">
|
||||
{compactNumber.format(
|
||||
data.stats[ark.account.pubkey].pub_note_count,
|
||||
)}
|
||||
</h3>
|
||||
<div className="mt-auto flex h-6 w-full items-center justify-between">
|
||||
<p className="text-xl font-medium leading-none text-neutral-600 dark:text-neutral-400">
|
||||
Posts
|
||||
</p>
|
||||
<Link
|
||||
to={`/users/${ark.account.pubkey}`}
|
||||
className="inline-flex h-6 w-max items-center gap-1 rounded-full bg-neutral-200 px-2.5 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700"
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { useArk, useProfile } from "@lume/ark";
|
||||
import { EditIcon, LoaderIcon } from "@lume/icons";
|
||||
import { displayNpub } from "@lume/utils";
|
||||
import * as Avatar from "@radix-ui/react-avatar";
|
||||
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||
import { minidenticon } from "minidenticons";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export function ProfileCard() {
|
||||
const ark = useArk();
|
||||
const svgURI = `data:image/svg+xml;utf8,${encodeURIComponent(
|
||||
minidenticon(ark.account.pubkey, 90, 50),
|
||||
)}`;
|
||||
|
||||
const { isLoading, user } = useProfile(ark.account.pubkey);
|
||||
|
||||
const copyNpub = async () => {
|
||||
return await writeText(nip19.npubEncode(ark.account.pubkey));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-4 h-56 w-full rounded-2xl bg-neutral-100 transition-all duration-150 ease-smooth hover:scale-105 dark:bg-neutral-900">
|
||||
{isLoading ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-col justify-between p-4">
|
||||
<div className="flex h-10 w-full justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyNpub}
|
||||
className="inline-flex h-8 w-28 transform items-center justify-center gap-1.5 rounded-full bg-neutral-200 text-sm font-medium hover:bg-neutral-400 active:translate-y-1 dark:bg-neutral-800 dark:hover:bg-neutral-600"
|
||||
>
|
||||
Copy NPUB
|
||||
</button>
|
||||
<Link
|
||||
to="/settings/edit-profile"
|
||||
className="inline-flex h-8 w-20 items-center justify-center gap-1.5 rounded-full bg-neutral-200 text-sm font-medium hover:bg-neutral-400 dark:bg-neutral-800 dark:hover:bg-neutral-600"
|
||||
>
|
||||
<EditIcon className="h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<Avatar.Root className="shrink-0">
|
||||
<Avatar.Image
|
||||
src={user?.picture || user?.image}
|
||||
alt={ark.account.pubkey}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ contentVisibility: "auto" }}
|
||||
className="h-16 w-16 rounded-xl border border-neutral-200/50 shadow-[rgba(17,_17,_26,_0.1)_0px_0px_16px] dark:border-neutral-800/50"
|
||||
/>
|
||||
<Avatar.Fallback delayMs={300}>
|
||||
<img
|
||||
src={svgURI}
|
||||
alt={ark.account.pubkey}
|
||||
className="h-16 w-16 rounded-xl bg-black dark:bg-white"
|
||||
/>
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div>
|
||||
<h3 className="text-3xl font-semibold leading-8 text-neutral-900 dark:text-neutral-100">
|
||||
{user?.display_name || user?.name}
|
||||
</h3>
|
||||
<p className="text-lg text-neutral-700 dark:text-neutral-300">
|
||||
{user?.nip05 || displayNpub(ark.account.pubkey, 16)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { useArk } from "@lume/ark";
|
||||
import { EditIcon, LoaderIcon } from "@lume/icons";
|
||||
import { compactNumber } from "@lume/utils";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export function RelayCard() {
|
||||
const ark = useArk();
|
||||
|
||||
const { status, data } = useQuery({
|
||||
queryKey: ["relays", ark.account.pubkey],
|
||||
queryFn: async () => {
|
||||
const relays = await ark.getUserRelays({});
|
||||
return relays;
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="col-span-1 h-44 rounded-2xl bg-neutral-100 transition-all duration-150 ease-smooth hover:scale-105 dark:bg-neutral-900">
|
||||
{status === "pending" ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-col justify-between p-4">
|
||||
<h3 className="pt-1 text-5xl font-semibold tabular-nums text-neutral-900 dark:text-neutral-100">
|
||||
{compactNumber.format(data?.relays?.length || 0)}
|
||||
</h3>
|
||||
<div className="mt-auto flex h-6 w-full items-center justify-between">
|
||||
<p className="text-xl font-medium leading-none text-neutral-600 dark:text-neutral-400">
|
||||
Relays
|
||||
</p>
|
||||
<Link
|
||||
to="/relays"
|
||||
className="inline-flex h-6 w-max items-center gap-1 rounded-full bg-neutral-200 px-2.5 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<EditIcon className="h-3 w-3" />
|
||||
Edit
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { useArk } from "@lume/ark";
|
||||
import { LoaderIcon } from "@lume/icons";
|
||||
import { useStorage } from "@lume/storage";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function NWCForm({ setWalletConnectURL }) {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
|
||||
const [uri, setUri] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
if (!uri.startsWith("nostr+walletconnect:")) {
|
||||
toast.error(
|
||||
"Connect URI is required and must start with format nostr+walletconnect:, please check again",
|
||||
);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const uriObj = new URL(uri);
|
||||
const params = new URLSearchParams(uriObj.search);
|
||||
|
||||
if (params.has("relay") && params.has("secret")) {
|
||||
await storage.createPrivkey(`${ark.account.pubkey}-nwc`, uri);
|
||||
setWalletConnectURL(uri);
|
||||
setLoading(false);
|
||||
} else {
|
||||
setLoading(false);
|
||||
toast.error("Connect URI is not valid, please check again");
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
toast.error(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-xl bg-neutral-100 p-3 dark:bg-neutral-900">
|
||||
<textarea
|
||||
name="walletConnectURL"
|
||||
value={uri}
|
||||
onChange={(e) => setUri(e.target.value)}
|
||||
placeholder="nostr+walletconnect://"
|
||||
className="h-40 w-full resize-none rounded-lg border-transparent bg-neutral-200 px-3 py-3 text-neutral-900 !outline-none placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:focus:ring-blue-800 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600"
|
||||
>
|
||||
{loading ? <LoaderIcon className="h-4 w-4 animate-spin" /> : "Connect"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { useArk } from "@lume/ark";
|
||||
import { LoaderIcon } from "@lume/icons";
|
||||
import { compactNumber } from "@lume/utils";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetch } from "@tauri-apps/plugin-http";
|
||||
|
||||
export function ZapCard() {
|
||||
const ark = useArk();
|
||||
const { status, data } = useQuery({
|
||||
queryKey: ["user-stats", ark.account.pubkey],
|
||||
queryFn: async ({ signal }: { signal: AbortSignal }) => {
|
||||
const res = await fetch(
|
||||
`https://api.nostr.band/v0/stats/profile/${ark.account.pubkey}`,
|
||||
{
|
||||
signal,
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("Error");
|
||||
}
|
||||
|
||||
return await res.json();
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="col-span-1 h-44 rounded-2xl bg-neutral-100 transition-all duration-150 ease-smooth hover:scale-105 dark:bg-neutral-900">
|
||||
{status === "pending" ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-col justify-between p-4">
|
||||
<h3 className="pt-1 text-5xl font-semibold tabular-nums text-neutral-900 dark:text-neutral-100">
|
||||
{compactNumber.format(
|
||||
data?.stats[ark.account.pubkey]?.zaps_received?.msats / 1000 || 0,
|
||||
)}
|
||||
</h3>
|
||||
<div className="mt-auto flex h-6 items-center text-xl font-medium leading-none text-neutral-600 dark:text-neutral-400">
|
||||
Sats received
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { useArk } from "@lume/ark";
|
||||
import { LoaderIcon } from "@lume/icons";
|
||||
import { User } from "@lume/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export function EditContactScreen() {
|
||||
const ark = useArk();
|
||||
const { status, data } = useQuery({
|
||||
queryKey: ["contacts"],
|
||||
queryFn: async () => {
|
||||
return await ark.getUserContacts();
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-xl flex-col gap-3">
|
||||
{status === "pending" ? (
|
||||
<div className="flex h-10 w-full items-center justify-center">
|
||||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
data.map((item) => (
|
||||
<div
|
||||
key={item}
|
||||
className="flex h-16 w-full items-center justify-between rounded-xl bg-neutral-100 px-2.5 dark:bg-neutral-900"
|
||||
>
|
||||
<User pubkey={item} variant="simple" />
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,307 +0,0 @@
|
||||
import { useArk } from "@lume/ark";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
LoaderIcon,
|
||||
PlusIcon,
|
||||
UnverifiedIcon,
|
||||
} from "@lume/icons";
|
||||
import { useStorage } from "@lume/storage";
|
||||
import { NDKKind, NDKUserProfile } from "@nostr-dev-kit/ndk";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { message } from "@tauri-apps/plugin-dialog";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export function EditProfileScreen() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [picture, setPicture] = useState("");
|
||||
const [banner, setBanner] = useState("");
|
||||
const [nip05, setNIP05] = useState({ verified: true, text: "" });
|
||||
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
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 queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const uploadAvatar = async () => {
|
||||
try {
|
||||
if (!ark.ndk.signer) return navigate("/new/privkey");
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const image = await ark.upload({});
|
||||
if (image) {
|
||||
setPicture(image);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
await message(`Upload failed, error: ${e}`, {
|
||||
title: "Lume",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const uploadBanner = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const image = await ark.upload({});
|
||||
|
||||
if (image) {
|
||||
setBanner(image);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
await message(`Upload failed, error: ${e}`, {
|
||||
title: "Lume",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (data: NDKUserProfile) => {
|
||||
// start loading
|
||||
setLoading(true);
|
||||
|
||||
let content = {
|
||||
...data,
|
||||
username: data.name,
|
||||
display_name: data.name,
|
||||
bio: data.about,
|
||||
image: data.picture,
|
||||
};
|
||||
|
||||
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 Lume ID / NIP-05, please check again",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const publish = await ark.createEvent({
|
||||
kind: NDKKind.Metadata,
|
||||
tags: [],
|
||||
content: JSON.stringify(content),
|
||||
});
|
||||
|
||||
if (publish) {
|
||||
// invalid cache
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["user", ark.account.pubkey],
|
||||
});
|
||||
// reset form
|
||||
reset();
|
||||
// reset state
|
||||
setLoading(false);
|
||||
setPicture(null);
|
||||
setBanner(null);
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="mb-0">
|
||||
<input type={"hidden"} {...register("picture")} value={picture} />
|
||||
<input type={"hidden"} {...register("banner")} value={banner} />
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<div className="relative h-36 w-full">
|
||||
{banner ? (
|
||||
<img
|
||||
src={banner}
|
||||
alt="user's banner"
|
||||
className="h-full w-full rounded-xl object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full rounded-xl bg-neutral-200 dark:bg-neutral-900" />
|
||||
)}
|
||||
<div className="absolute left-1/2 top-1/2 z-10 h-full w-full -translate-x-1/2 -translate-y-1/2 transform overflow-hidden rounded-xl">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => uploadBanner()}
|
||||
className="inline-flex h-full w-full items-center justify-center bg-black/20 text-white"
|
||||
>
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-5 px-4">
|
||||
<div className="relative z-10 -mt-7 h-14 w-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"
|
||||
/>
|
||||
<div className="absolute left-1/2 top-1/2 z-10 h-full w-full -translate-x-1/2 -translate-y-1/2 transform">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => uploadAvatar()}
|
||||
className="inline-flex h-full w-full items-center justify-center rounded-xl bg-black/50 text-white"
|
||||
>
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="display_name"
|
||||
className="text-sm font-semibold uppercase tracking-wider"
|
||||
>
|
||||
Display Name
|
||||
</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"
|
||||
>
|
||||
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" />
|
||||
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" />
|
||||
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"
|
||||
>
|
||||
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"
|
||||
>
|
||||
Lightning address
|
||||
</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"
|
||||
>
|
||||
Bio
|
||||
</label>
|
||||
<textarea
|
||||
{...register("about")}
|
||||
spellCheck={false}
|
||||
className="relative h-20 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>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isValid}
|
||||
className="mx-auto inline-flex h-11 w-full transform items-center justify-center gap-1 rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600 focus:outline-none active:translate-y-1 disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-white" />
|
||||
) : (
|
||||
"Update"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,352 +0,0 @@
|
||||
import { CheckIcon, DarkIcon, LightIcon, SystemModeIcon } from "@lume/icons";
|
||||
import { useStorage } from "@lume/storage";
|
||||
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 { twMerge } from "tailwind-merge";
|
||||
|
||||
export function GeneralSettingScreen() {
|
||||
const storage = useStorage();
|
||||
|
||||
const [apiKey, setAPIKey] = useState("");
|
||||
const [settings, setSettings] = useState({
|
||||
lowPower: false,
|
||||
autoupdate: false,
|
||||
autolaunch: false,
|
||||
outbox: false,
|
||||
media: true,
|
||||
hashtag: true,
|
||||
notification: true,
|
||||
translation: 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 }));
|
||||
|
||||
const data = await storage.getAllSettings();
|
||||
if (!data) return;
|
||||
|
||||
for (const item of data) {
|
||||
if (item.key === "autoupdate")
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
autoupdate: !!parseInt(item.value),
|
||||
}));
|
||||
|
||||
if (item.key === "lowPower")
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
lowPower: !!parseInt(item.value),
|
||||
}));
|
||||
|
||||
if (item.key === "outbox")
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
outbox: !!parseInt(item.value),
|
||||
}));
|
||||
|
||||
if (item.key === "media")
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
media: !!parseInt(item.value),
|
||||
}));
|
||||
|
||||
if (item.key === "hashtag")
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
hashtag: !!parseInt(item.value),
|
||||
}));
|
||||
|
||||
if (item.key === "translation")
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
translation: !!parseInt(item.value),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
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-24 shrink-0 text-end text-sm font-semibold">
|
||||
Update
|
||||
</div>
|
||||
<div className="text-sm">Automatically download new update</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-24 shrink-0 text-end text-sm font-semibold">
|
||||
Low Power
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
Sustainable for low network environment.
|
||||
</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-24 shrink-0 text-end text-sm font-semibold">
|
||||
Startup
|
||||
</div>
|
||||
<div className="text-sm">Launch Lume at Login</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-24 shrink-0 text-end text-sm font-semibold">
|
||||
Media
|
||||
</div>
|
||||
<div className="text-sm">Automatically load media</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-24 shrink-0 text-end text-sm font-semibold">
|
||||
Hashtag
|
||||
</div>
|
||||
<div className="text-sm">Show all hashtags in content</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-24 shrink-0 text-end text-sm font-semibold">
|
||||
Notification
|
||||
</div>
|
||||
<div className="text-sm">Automatically send notification</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-24 shrink-0 text-end text-sm font-semibold">
|
||||
Translation
|
||||
</div>
|
||||
<div className="text-sm">Translate text to your language</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-24 shrink-0 text-end text-sm font-semibold">
|
||||
API Key
|
||||
</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"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex w-full items-start gap-8">
|
||||
<div className="w-24 shrink-0 text-end text-sm font-semibold">
|
||||
Appearance
|
||||
</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={twMerge(
|
||||
"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">
|
||||
Light
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeTheme("dark")}
|
||||
className="flex flex-col items-center justify-center gap-0.5"
|
||||
>
|
||||
<div
|
||||
className={twMerge(
|
||||
"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">
|
||||
Dark
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => changeTheme("auto")}
|
||||
className="flex flex-col items-center justify-center gap-0.5"
|
||||
>
|
||||
<div
|
||||
className={twMerge(
|
||||
"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">
|
||||
System
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { ContactCard } from "./components/contactCard";
|
||||
import { PostCard } from "./components/postCard";
|
||||
import { ProfileCard } from "./components/profileCard";
|
||||
import { RelayCard } from "./components/relayCard";
|
||||
import { ZapCard } from "./components/zapCard";
|
||||
|
||||
export function UserSettingScreen() {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-xl">
|
||||
<ProfileCard />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<ContactCard />
|
||||
<RelayCard />
|
||||
<PostCard />
|
||||
<ZapCard />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
import { webln } from "@getalby/sdk";
|
||||
import { useArk } from "@lume/ark";
|
||||
import { CheckCircleIcon } from "@lume/icons";
|
||||
import { useStorage } from "@lume/storage";
|
||||
import { useEffect, useState } from "react";
|
||||
import { NWCForm } from "./components/walletConnectForm";
|
||||
|
||||
export function NWCScreen() {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
|
||||
const [walletConnectURL, setWalletConnectURL] = useState<null | string>(null);
|
||||
const [balance, setBalance] = useState(0);
|
||||
|
||||
const remove = async () => {
|
||||
await storage.removePrivkey(`${ark.account.pubkey}-nwc`);
|
||||
setWalletConnectURL(null);
|
||||
};
|
||||
|
||||
const loadBalance = async () => {
|
||||
const nwc = new webln.NostrWebLNProvider({
|
||||
nostrWalletConnectUrl: walletConnectURL,
|
||||
});
|
||||
await nwc.enable();
|
||||
|
||||
const balanceResponse = await nwc.getBalance();
|
||||
setBalance(balanceResponse.balance);
|
||||
|
||||
nwc.close();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (walletConnectURL) loadBalance();
|
||||
}, [walletConnectURL]);
|
||||
|
||||
useEffect(() => {
|
||||
async function getNWC() {
|
||||
const nwc = await storage.loadPrivkey(`${ark.account.pubkey}-nwc`);
|
||||
if (nwc) setWalletConnectURL(nwc);
|
||||
}
|
||||
getNWC();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex w-full flex-col gap-5">
|
||||
<div className="text-center">
|
||||
<h3 className="text-2xl font-semibold leading-tight">
|
||||
Nostr Wallet Connect
|
||||
</h3>
|
||||
<p className="text-lg font-medium leading-snug text-neutral-600 dark:text-neutral-500">
|
||||
Sending zap easily via Bitcoin Lightning.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mx-auto w-full max-w-lg">
|
||||
{!walletConnectURL ? (
|
||||
<NWCForm setWalletConnectURL={setWalletConnectURL} />
|
||||
) : (
|
||||
<div className="flex w-full flex-col gap-3 rounded-xl bg-neutral-100 p-3 dark:bg-neutral-900">
|
||||
<div className="flex items-center justify-center gap-1.5 text-sm text-teal-500">
|
||||
<CheckCircleIcon className="h-4 w-4" />
|
||||
<div>You're using nostr wallet connect</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<textarea
|
||||
readOnly
|
||||
value={`${walletConnectURL.substring(0, 120)}****`}
|
||||
className="h-40 w-full resize-none rounded-lg border-transparent bg-neutral-200 px-3 py-3 text-neutral-900 !outline-none placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:focus:ring-blue-800 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove()}
|
||||
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg bg-neutral-200 px-6 font-medium text-red-500 hover:bg-red-500 hover:text-white focus:outline-none dark:bg-neutral-800 dark:text-neutral-100"
|
||||
>
|
||||
Remove connection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{walletConnectURL ? (
|
||||
<div className="mt-5 flex flex-col">
|
||||
<h3 className="font-medium text-neutral-600 dark:text-neutral-400">
|
||||
Current balance
|
||||
</h3>
|
||||
<p className="text-2xl font-semibold">
|
||||
{new Intl.NumberFormat().format(balance)} sats
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-5 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h5 className="font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
Introduction
|
||||
</h5>
|
||||
<p className="text-neutral-600 dark:text-neutral-400">
|
||||
Nostr Wallet Connect (NWC) is a way for applications like
|
||||
Nostr clients to access a remote Lightning wallet through a
|
||||
standardized protocol.
|
||||
</p>
|
||||
<p className="text-neutral-600 dark:text-neutral-400">
|
||||
To learn more about the details have a look at{" "}
|
||||
<a
|
||||
href="https://github.com/nostr-protocol/nips/blob/master/47.md"
|
||||
target="_blank"
|
||||
className="text-blue-500"
|
||||
rel="noreferrer"
|
||||
>
|
||||
the specs (NIP47)
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h5 className="font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
About zapping
|
||||
</h5>
|
||||
<p className="text-neutral-600 dark:text-neutral-400">
|
||||
Lume doesn't take any commission or platform fees when
|
||||
you zap someone. Lume doesn't hold your Bitcoin
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h5 className="font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
Recommend wallet that support NWC
|
||||
</h5>
|
||||
<p className="text-neutral-600 dark:text-neutral-400">
|
||||
- Mutiny Wallet:{" "}
|
||||
<a
|
||||
href="https://www.mutinywallet.com/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-500"
|
||||
>
|
||||
website
|
||||
</a>
|
||||
</p>
|
||||
<p className="text-neutral-600 dark:text-neutral-400">
|
||||
- Self hosted NWC on Umbrel :{" "}
|
||||
<a
|
||||
href="https://apps.umbrel.com/app/alby-nostr-wallet-connect"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-500"
|
||||
>
|
||||
website
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
import { useArk, useProfile } from "@lume/ark";
|
||||
import { useStorage } from "@lume/storage";
|
||||
import { NIP05 } from "@lume/ui";
|
||||
import { displayNpub } from "@lume/utils";
|
||||
import * as Avatar from "@radix-ui/react-avatar";
|
||||
import { minidenticon } from "minidenticons";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { UserStats } from "./stats";
|
||||
|
||||
export function UserProfile({ pubkey }: { pubkey: string }) {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
|
||||
const { user } = useProfile(pubkey);
|
||||
|
||||
const [followed, setFollowed] = useState(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const svgURI = `data:image/svg+xml;utf8,${encodeURIComponent(
|
||||
minidenticon(pubkey, 90, 50),
|
||||
)}`;
|
||||
|
||||
const follow = async () => {
|
||||
try {
|
||||
if (!ark.ndk.signer) return navigate("/new/privkey");
|
||||
setFollowed(true);
|
||||
|
||||
const add = await ark.createContact({ pubkey });
|
||||
|
||||
if (!add) {
|
||||
toast.success("You already follow this user");
|
||||
setFollowed(false);
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e);
|
||||
setFollowed(false);
|
||||
}
|
||||
};
|
||||
|
||||
const unfollow = async () => {
|
||||
try {
|
||||
if (!ark.ndk.signer) return navigate("/new/privkey");
|
||||
setFollowed(false);
|
||||
|
||||
await ark.deleteContact({ pubkey });
|
||||
} catch (e) {
|
||||
toast.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (ark.account.contacts.includes(pubkey)) {
|
||||
setFollowed(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!user) return <p>Loading...</p>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-56 w-full overflow-hidden rounded-tl-lg">
|
||||
{user?.banner ? (
|
||||
<img
|
||||
src={user?.banner}
|
||||
alt="user banner"
|
||||
className="h-full w-full rounded-tl-lg object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full rounded-tl-lg bg-neutral-100 dark:bg-neutral-900" />
|
||||
)}
|
||||
</div>
|
||||
<div className="-mt-7 flex w-full flex-col items-center px-5">
|
||||
<Avatar.Root className="shrink-0">
|
||||
<Avatar.Image
|
||||
src={user?.picture || user?.image}
|
||||
alt={pubkey}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ contentVisibility: "auto" }}
|
||||
className="h-14 w-14 rounded-lg bg-white object-cover ring-2 ring-neutral-100 dark:ring-neutral-900"
|
||||
/>
|
||||
<Avatar.Fallback delayMs={300}>
|
||||
<img
|
||||
src={svgURI}
|
||||
alt={pubkey}
|
||||
className="h-14 w-14 rounded-lg bg-black dark:bg-white"
|
||||
/>
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div className="mt-2 flex flex-1 flex-col gap-6">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div className="inline-flex flex-col items-center">
|
||||
<h5 className="text-center text-xl font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{user?.name ||
|
||||
user?.display_name ||
|
||||
user?.displayName ||
|
||||
"No name"}
|
||||
</h5>
|
||||
{user?.nip05 ? (
|
||||
<NIP05
|
||||
pubkey={pubkey}
|
||||
nip05={user.nip05}
|
||||
className="text-neutral-600 dark:text-neutral-400"
|
||||
/>
|
||||
) : (
|
||||
<span className="max-w-[15rem] truncate text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{displayNpub(pubkey, 16)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-6">
|
||||
{user?.about || user?.bio ? (
|
||||
<p className="mt-2 max-w-[500px] select-text break-words text-center text-neutral-900 dark:text-neutral-100">
|
||||
{user.about || user.bio}
|
||||
</p>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<UserStats pubkey={pubkey} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="inline-flex items-center justify-center gap-2">
|
||||
{followed ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={unfollow}
|
||||
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-neutral-200 text-sm font-medium text-neutral-900 backdrop-blur-xl hover:bg-blue-500 hover:text-neutral-100 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-blue-600 dark:hover:text-neutral-100"
|
||||
>
|
||||
Unfollow
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={follow}
|
||||
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-neutral-200 text-sm font-medium text-neutral-900 backdrop-blur-xl hover:bg-blue-500 hover:text-neutral-100 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-blue-600 dark:hover:text-neutral-100"
|
||||
>
|
||||
Follow
|
||||
</button>
|
||||
)}
|
||||
<Link
|
||||
to={`/chats/${pubkey}`}
|
||||
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-neutral-200 text-sm font-medium text-neutral-900 backdrop-blur-xl hover:bg-blue-500 hover:text-neutral-100 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-blue-600 dark:hover:text-neutral-100"
|
||||
>
|
||||
Message
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { LoaderIcon } from "@lume/icons";
|
||||
import { compactNumber } from "@lume/utils";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetch } from "@tauri-apps/plugin-http";
|
||||
|
||||
export function UserStats({ pubkey }: { pubkey: string }) {
|
||||
const { status, data } = useQuery({
|
||||
queryKey: ["user-stats", pubkey],
|
||||
queryFn: async ({ signal }: { signal: AbortSignal }) => {
|
||||
const res = await fetch(
|
||||
`https://api.nostr.band/v0/stats/profile/${pubkey}`,
|
||||
{
|
||||
signal,
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("Error");
|
||||
}
|
||||
|
||||
return await res.json();
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
if (status === "pending") {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center">
|
||||
<LoaderIcon className="h-5 w-5 animate-spin text-neutral-900 dark:text-neutral-100" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "error") {
|
||||
return <div className="flex w-full items-center justify-center" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center gap-10">
|
||||
<div className="inline-flex flex-col items-center gap-1">
|
||||
<span className="font-semibold leading-none text-neutral-900 dark:text-neutral-100">
|
||||
{compactNumber.format(data?.stats[pubkey]?.followers_pubkey_count) ??
|
||||
0}
|
||||
</span>
|
||||
<span className="text-sm leading-none text-neutral-500 dark:text-neutral-400">
|
||||
Followers
|
||||
</span>
|
||||
</div>
|
||||
<div className="inline-flex flex-col items-center gap-1">
|
||||
<span className="font-semibold leading-none text-neutral-900 dark:text-neutral-100">
|
||||
{compactNumber.format(
|
||||
data?.stats[pubkey]?.pub_following_pubkey_count,
|
||||
) ?? 0}
|
||||
</span>
|
||||
<span className="text-sm leading-none text-neutral-500 dark:text-neutral-400">
|
||||
Following
|
||||
</span>
|
||||
</div>
|
||||
<div className="inline-flex flex-col items-center gap-1">
|
||||
<span className="font-semibold leading-none text-neutral-900 dark:text-neutral-100">
|
||||
{compactNumber.format(
|
||||
data?.stats[pubkey]?.zaps_received?.msats / 1000 ?? 0,
|
||||
)}
|
||||
</span>
|
||||
<span className="text-sm leading-none text-neutral-500 dark:text-neutral-400">
|
||||
Zaps received
|
||||
</span>
|
||||
</div>
|
||||
<div className="inline-flex flex-col items-center gap-1">
|
||||
<span className="font-semibold leading-none text-neutral-900 dark:text-neutral-100">
|
||||
{compactNumber.format(
|
||||
data?.stats[pubkey]?.zaps_sent?.msats / 1000 ?? 0,
|
||||
)}
|
||||
</span>
|
||||
<span className="text-sm leading-none text-neutral-500 dark:text-neutral-400">
|
||||
Zaps sent
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
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 { useMemo } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { UserProfile } from "./components/profile";
|
||||
|
||||
export function UserScreen() {
|
||||
const { pubkey } = useParams();
|
||||
const ark = useArk();
|
||||
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
|
||||
useInfiniteQuery({
|
||||
queryKey: ["user-posts", pubkey],
|
||||
initialPageParam: 0,
|
||||
queryFn: async ({
|
||||
signal,
|
||||
pageParam,
|
||||
}: {
|
||||
signal: AbortSignal;
|
||||
pageParam: number;
|
||||
}) => {
|
||||
const events = await ark.getInfiniteEvents({
|
||||
filter: {
|
||||
kinds: [NDKKind.Text, NDKKind.Repost],
|
||||
authors: [pubkey],
|
||||
},
|
||||
limit: FETCH_LIMIT,
|
||||
pageParam,
|
||||
signal,
|
||||
});
|
||||
|
||||
return events;
|
||||
},
|
||||
getNextPageParam: (lastPage) => {
|
||||
const lastEvent = lastPage.at(-1);
|
||||
if (!lastEvent) return;
|
||||
return lastEvent.created_at - 1;
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const allEvents = useMemo(
|
||||
() => (data ? data.pages.flatMap((page) => page) : []),
|
||||
[data],
|
||||
);
|
||||
|
||||
// render event match event kind
|
||||
const renderItem = (event: NDKEvent) => {
|
||||
switch (event.kind) {
|
||||
case NDKKind.Text:
|
||||
return <TextNote key={event.id} event={event} />;
|
||||
case NDKKind.Repost:
|
||||
return <RepostNote key={event.id} event={event} />;
|
||||
default:
|
||||
return <TextNote key={event.id} event={event} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full overflow-y-auto">
|
||||
<UserProfile pubkey={pubkey} />
|
||||
<div className="mt-6 h-full w-full border-t border-neutral-100 px-1.5 dark:border-neutral-900">
|
||||
<h3 className="mb-2 pt-4 text-center text-lg font-semibold leading-none text-neutral-900 dark:text-neutral-100">
|
||||
Latest posts
|
||||
</h3>
|
||||
<div className="mx-auto flex h-full max-w-[500px] flex-col justify-between gap-1.5 pb-4 pt-1.5">
|
||||
{status === "pending" ? (
|
||||
<NoteSkeleton />
|
||||
) : (
|
||||
allEvents.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 items-center justify-center w-full h-12 gap-2 font-medium bg-neutral-100 hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800 rounded-xl focus:outline-none"
|
||||
>
|
||||
{isFetchingNextPage ? (
|
||||
<LoaderIcon className="size-5 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<ArrowRightCircleIcon className="size-5" />
|
||||
Load more
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
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,
|
||||
});
|
||||
26
apps/desktop2/.gitignore
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
src/router.gen.ts
|
||||
14
apps/desktop2/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Lume Desktop</title>
|
||||
</head>
|
||||
<body
|
||||
class="relative h-screen w-screen cursor-default select-none overflow-hidden font-sans text-black antialiased dark:text-white"
|
||||
>
|
||||
<div id="root" class="h-full w-full"></div>
|
||||
<script type="module" src="/src/app.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||