Compare commits

..

62 Commits

Author SHA1 Message Date
Ren Amamiya
1bbfebc2b8 fix ci again 2023-09-19 16:31:38 +07:00
Ren Amamiya
d84e97b0d4 fix ci 2023-09-19 15:59:50 +07:00
Ren Amamiya
824aa8fa28 back to pnpm, bun is fun but cannot generate tauri build 2023-09-19 15:56:33 +07:00
Ren Amamiya
2b34ef3b7a fix build error 2023-09-19 15:29:26 +07:00
Ren Amamiya
4fa8f40e6a improve nwc 2023-09-19 15:06:12 +07:00
Ren Amamiya
c1bddeb6ed bump version 2023-09-19 11:20:15 +07:00
Ren Amamiya
5c2bfa0ea3 improve notification 2023-09-19 11:15:35 +07:00
Ren Amamiya
60e93965ea respect user's relay list (kind 10002) 2023-09-19 08:01:57 +07:00
Ren Amamiya
380d1fb930 temporary using default relays 2023-09-18 15:42:17 +07:00
Ren Amamiya
53aa13c8aa clean up messy code 2023-09-18 09:50:15 +07:00
Ren Amamiya
13f5190ba1 update dependencies and better handle repost 2023-09-17 16:14:04 +07:00
Ren Amamiya
c590e290e0 Merge pull request #87 from luminous-devs/feat/improve-onboarding
merge now, improve later
2023-09-17 08:44:16 +07:00
Ren Amamiya
cdf86a2613 polish 2023-09-17 08:43:42 +07:00
Ren Amamiya
8726e22b38 replace nostr.com with njump.me 2023-09-17 08:03:29 +07:00
Ren Amamiya
1206486016 partial support replaceable event 2023-09-16 16:06:01 +07:00
Ren Amamiya
11ad281d72 wip 2023-09-16 08:57:24 +07:00
Ren Amamiya
fe4bfa1699 wip: learn nostr widget 2023-09-16 07:47:44 +07:00
Ren Amamiya
c6a0636e8c add complete screen 2023-09-15 10:29:39 +07:00
Ren Amamiya
d3db6492d9 update onboarding 2023-09-15 08:58:09 +07:00
Ren Amamiya
8f8617d8f9 update import key flow 2023-09-14 16:51:38 +07:00
Ren Amamiya
8e513404c3 update create account flow 2023-09-14 09:20:36 +07:00
Ren Amamiya
5a6dd172b1 small fixes 2023-09-13 11:10:24 +07:00
Ren Amamiya
fa0d7cac31 hide nwc secret in frontend 2023-09-12 16:16:57 +07:00
Ren Amamiya
432b2ae185 polish nwc connection flow 2023-09-12 16:00:41 +07:00
Ren Amamiya
fb8a6581dd replace pnpm with bun 2023-09-12 08:43:12 +07:00
Ren Amamiya
a4f221f868 wip: redesign nwc 2023-09-12 08:27:29 +07:00
Ren Amamiya
602d012efe fix alby connection 2023-09-11 07:52:43 +07:00
Ren Amamiya
5bf816eba2 fully suport alby nostr-wallet-connect 2023-09-10 16:25:35 +07:00
Ren Amamiya
a33c9d3517 wip: integrate alby 2023-09-10 07:19:36 +07:00
Ren Amamiya
1553f5ced2 bump version 2023-09-09 07:21:02 +07:00
Ren Amamiya
41901b2174 fix hashtag step in onboarding 2023-09-08 17:02:01 +07:00
Ren Amamiya
177e4c1ff7 fix some bugs 2023-09-08 15:29:41 +07:00
Ren Amamiya
10036500cb small fixes 2023-09-08 09:22:09 +07:00
Ren Amamiya
a1fa777f8c fix logout function, prepare for multi-account support 2023-09-08 08:36:15 +07:00
Ren Amamiya
472925bb05 small fixes 2023-09-07 12:19:28 +07:00
Ren Amamiya
8eb11efb34 update notification 2023-09-07 11:34:26 +07:00
Ren Amamiya
48066a4018 bump version 2023-09-06 16:53:05 +07:00
Ren Amamiya
5c8850ea8f redesign widget list 2023-09-06 14:30:57 +07:00
Ren Amamiya
09aea3cff5 update widgets 2023-09-06 08:58:02 +07:00
Ren Amamiya
45c5a890b9 update tauri config 2023-09-06 07:46:31 +07:00
Ren Amamiya
69a3e85cb3 small updates and bump version 2023-09-05 17:25:00 +07:00
Ren Amamiya
224439f62b support drag and drop upload in composer 2023-09-05 13:07:21 +07:00
Ren Amamiya
2389ad5fdc replace void.cat with nostr.build 2023-09-05 12:46:00 +07:00
Ren Amamiya
4019623d99 small updates 2023-09-05 08:50:13 +07:00
Ren Amamiya
57c17ffbf9 use nostr.com to display unfound event 2023-09-04 18:09:41 +07:00
Ren Amamiya
98d2ccfc86 small fixes 2023-09-04 17:18:28 +07:00
Ren Amamiya
c74a81cfdb re-add link preview 2023-09-04 14:35:57 +07:00
Ren Amamiya
3ebcf4a981 new parser, faster than before 50% 2023-09-04 14:05:04 +07:00
Ren Amamiya
5d45027776 fix build issue 2023-09-04 07:44:57 +07:00
Ren Amamiya
21ea8309c7 fix ci (final) 2023-09-03 12:02:59 +07:00
Ren Amamiya
431331174a fix ci again 2023-09-03 11:30:45 +07:00
Ren Amamiya
c26cfc038d fix ci 2023-09-03 11:08:13 +07:00
Ren Amamiya
39b7b34bb7 new mention popup in composer 2023-09-03 08:43:08 +07:00
Ren Amamiya
a4cf65e7c2 update ui consistent for cross platform 2023-09-03 07:43:38 +07:00
Ren Amamiya
37668393f1 expt: disable note metadata 2023-09-02 17:28:05 +07:00
Ren Amamiya
4309f734b6 Merge pull request #80 from luminous-devs/revert-to-tauri-v1
Revert to Tauri v1.4.0
2023-09-02 12:49:51 +07:00
Ren Amamiya
b4957bae1f native fetch and shadow 2023-09-02 12:49:04 +07:00
Ren Amamiya
7a3b19bf7b revert to tauri v1 2023-09-02 12:15:48 +07:00
Ren Amamiya
1931373515 update composer 2023-09-02 08:50:54 +07:00
Ren Amamiya
28939d1733 improve hashtag parser 2023-09-01 18:26:22 +07:00
Ren Amamiya
e6d35bc635 fix mention in composer and improve error handling 2023-09-01 15:57:31 +07:00
Ren Amamiya
cc315a190a fully support nip05 2023-09-01 08:58:33 +07:00
153 changed files with 5707 additions and 4942 deletions

View File

@@ -1,4 +1,4 @@
name: 'publish'
name: 'Publish'
on: workflow_dispatch
env:
@@ -24,7 +24,7 @@ jobs:
- name: setup node
uses: actions/setup-node@v3
with:
node-version: 20
node-version: 18
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin
@@ -32,7 +32,7 @@ jobs:
if: matrix.settings.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libglib2.0-dev libsoup-3.0 libgtk-3-dev webkit2gtk-4.1 javascriptcoregtk-4.1 libappindicator3-dev librsvg2-dev patchelf
sudo apt-get install -y build-essential libssl-dev libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
@@ -66,3 +66,4 @@ jobs:
releaseDraft: true
prerelease: false
args: ${{ matrix.settings.args }}
includeDebug: true

2
.gitignore vendored
View File

@@ -14,9 +14,9 @@ out
*.local
.next
.vscode
pnpm-lock.yaml
*.db
*.db-journal
bun.lockb
# Editor directories and files
.vscode/*

View File

@@ -8,6 +8,12 @@ Download Lume for your platform here: [https://github.com/luminous-devs/lume/rel
Supported platform: macOS, Windows and Linux
### Prerequisites
- Bun: https://bun.sh/docs/installation
- Tauri: https://tauri.app/v1/guides/getting-started/prerequisites#setting-up-macos
### Develop
Clone project
@@ -19,23 +25,17 @@ git clone https://github.com/luminous-devs/lume.git && cd lume
Install packages
```
pnpm install
bun install
```
Run dev
Run dev build
```
pnpm tauri dev
bun tauri dev
```
Build
Generate production build
```
pnpm tauri build
```
(Advance) - Generate SQLite migration
```
pnpm add-migrate <migrate_name>
```
bun tauri build
```

View File

@@ -2,7 +2,7 @@
"name": "lume",
"description": "the communication app",
"private": true,
"version": "1.2.1",
"version": "1.2.5",
"scripts": {
"dev": "vite",
"build": "vite build",
@@ -18,8 +18,8 @@
"**/*.{ts, tsx, css, md, html, json}": "prettier --cache --write"
},
"dependencies": {
"@ctrl/magnet-link": "^3.1.2",
"@nostr-dev-kit/ndk": "^0.8.23",
"@getalby/sdk": "^2.4.0",
"@nostr-dev-kit/ndk": "^1.0.0",
"@nostr-fetch/adapter-ndk": "^0.12.2",
"@radix-ui/react-alert-dialog": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3",
@@ -27,33 +27,16 @@
"@radix-ui/react-dropdown-menu": "^2.0.5",
"@radix-ui/react-popover": "^1.0.6",
"@radix-ui/react-tooltip": "^1.0.6",
"@tanstack/react-query": "^4.33.0",
"@tanstack/react-query": "^4.35.0",
"@tanstack/react-virtual": "3.0.0-beta.54",
"@tauri-apps/api": "2.0.0-alpha.6",
"@tauri-apps/cli": "2.0.0-alpha.11",
"@tauri-apps/plugin-app": "github:tauri-apps/tauri-plugin-app#v2",
"@tauri-apps/plugin-autostart": "github:tauri-apps/tauri-plugin-autostart#v2",
"@tauri-apps/plugin-clipboard-manager": "github:tauri-apps/tauri-plugin-clipboard-manager#v2",
"@tauri-apps/plugin-dialog": "github:tauri-apps/tauri-plugin-dialog#v2",
"@tauri-apps/plugin-fs": "github:tauri-apps/tauri-plugin-fs#v2",
"@tauri-apps/plugin-http": "github:tauri-apps/tauri-plugin-http#v2",
"@tauri-apps/plugin-notification": "github:tauri-apps/tauri-plugin-notification#v2",
"@tauri-apps/plugin-os": "github:tauri-apps/tauri-plugin-os#v2",
"@tauri-apps/plugin-process": "github:tauri-apps/tauri-plugin-process#v2",
"@tauri-apps/plugin-shell": "github:tauri-apps/tauri-plugin-shell#v2",
"@tauri-apps/plugin-sql": "github:tauri-apps/tauri-plugin-sql#v2",
"@tauri-apps/plugin-store": "github:tauri-apps/tauri-plugin-store#v2",
"@tauri-apps/plugin-stronghold": "github:tauri-apps/tauri-plugin-stronghold#v2",
"@tauri-apps/plugin-upload": "github:tauri-apps/tauri-plugin-upload#v2",
"@tauri-apps/plugin-window": "github:tauri-apps/tauri-plugin-window#v2",
"@tiptap/extension-image": "^2.1.7",
"@tiptap/extension-mention": "^2.1.7",
"@tiptap/extension-placeholder": "^2.1.7",
"@tiptap/pm": "^2.1.7",
"@tiptap/react": "^2.1.7",
"@tiptap/starter-kit": "^2.1.7",
"@tiptap/suggestion": "^2.1.7",
"@void-cat/api": "^1.0.7",
"@tauri-apps/api": "^1.4.0",
"@tiptap/extension-image": "^2.1.8",
"@tiptap/extension-mention": "^2.1.8",
"@tiptap/extension-placeholder": "^2.1.8",
"@tiptap/pm": "^2.1.8",
"@tiptap/react": "^2.1.8",
"@tiptap/starter-kit": "^2.1.8",
"@tiptap/suggestion": "^2.1.8",
"dayjs": "^1.11.9",
"destr": "^2.0.1",
"get-urls": "^12.1.0",
@@ -61,53 +44,57 @@
"light-bolt11-decoder": "^3.0.0",
"lru-cache": "^10.0.1",
"minidenticons": "^4.2.0",
"nostr-fetch": "^0.12.2",
"nostr-fetch": "^0.13.0",
"nostr-tools": "^1.14.2",
"qrcode.react": "^3.1.0",
"react": "^18.2.0",
"react-currency-input-field": "^3.6.11",
"react-dom": "^18.2.0",
"react-hook-form": "^7.45.4",
"react-hotkeys-hook": "^4.4.1",
"react-hook-form": "^7.46.1",
"react-markdown": "^8.0.7",
"react-player": "^2.12.0",
"react-player": "^2.13.0",
"react-router-dom": "^6.15.0",
"react-textarea-autosize": "^8.5.3",
"react-virtuoso": "^4.5.0",
"react-virtuoso": "^4.5.1",
"remark-gfm": "^3.0.1",
"tippy.js": "^6.3.7",
"tauri-plugin-sql-api": "github:tauri-apps/tauri-plugin-sql#v1",
"tauri-plugin-store-api": "github:tauri-apps/tauri-plugin-store#v1",
"tauri-plugin-stronghold-api": "github:tauri-apps/tauri-plugin-stronghold#v1",
"tauri-plugin-upload-api": "github:tauri-apps/tauri-plugin-upload#v1",
"zustand": "^4.4.1"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.9",
"@tailwindcss/typography": "^0.5.10",
"@tauri-apps/cli": "^1.4.0",
"@trivago/prettier-plugin-sort-imports": "^4.2.0",
"@types/html-to-text": "^9.0.1",
"@types/node": "^20.5.6",
"@types/node": "^20.6.0",
"@types/react": "^18.2.21",
"@types/react-dom": "^18.2.7",
"@types/youtube-player": "^5.5.7",
"@typescript-eslint/eslint-plugin": "^6.4.1",
"@typescript-eslint/parser": "^6.4.1",
"@typescript-eslint/eslint-plugin": "^6.6.0",
"@typescript-eslint/parser": "^6.6.0",
"@vitejs/plugin-react-swc": "^3.3.2",
"autoprefixer": "^10.4.15",
"clsx": "^2.0.0",
"cross-env": "^7.0.3",
"csstype": "^3.1.2",
"encoding": "^0.1.13",
"eslint": "^8.48.0",
"eslint": "^8.49.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-simple-import-sort": "^10.0.0",
"husky": "^8.0.3",
"lint-staged": "^14.0.1",
"postcss": "^8.4.28",
"prettier": "^3.0.2",
"prettier-plugin-tailwindcss": "^0.5.3",
"postcss": "^8.4.29",
"prettier": "^3.0.3",
"prettier-plugin-tailwindcss": "^0.5.4",
"prop-types": "^15.8.1",
"tailwind-merge": "^1.14.0",
"tailwindcss": "^3.3.3",
"typescript": "^5.2.2",
"vite": "^4.4.9",
"vite-tsconfig-paths": "^4.2.0"
"vite-tsconfig-paths": "^4.2.1"
}
}

3447
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

1658
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,51 +1,59 @@
[package]
name = "lume"
version = "1.2.1"
description = "nostr client"
version = "1.2.5"
description = "the communication app"
authors = ["Ren Amamiya"]
license = "GPL-3.0"
repository = "https://github.com/luminous-devs/lume"
edition = "2021"
rust-version = "1.71"
rust-version = "1.66"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies]
tauri-build = { version = "2.0.0-alpha.8", features = [] }
tauri-build = { version = "1.4", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
tauri = { git = "https://git.nextgraph.org/NextGraph/tauri.git", branch = "alpha.11-nextgraph", features = [
"protocol-asset",
tauri = { version = "1.4", features = [
"window-close",
"window-print",
"window-create",
"macos-private-api",
"no-ipc-custom-protocol",
"fs-read-dir",
"fs-read-file",
"window-start-dragging",
"path-all",
"http-all",
"clipboard-write-text",
"os-all",
"notification-all",
"clipboard-read-text",
"window-set-resizable",
"window-set-size",
"shell-open",
"fs-write-file",
"app-all",
"fs-remove-file",
"window-center",
"dialog-all",
"http-multipart",
] }
tauri-plugin-single-instance = { git = "https://github.com/luminous-devs/plugins-workspace", branch = "v2" }
tauri-plugin-autostart = { git = "https://github.com/luminous-devs/plugins-workspace", branch = "v2" }
tauri-plugin-stronghold = { git = "https://github.com/luminous-devs/plugins-workspace", branch = "v2" }
tauri-plugin-upload = { git = "https://github.com/luminous-devs/plugins-workspace", branch = "v2" }
tauri-plugin-updater = { git = "https://github.com/luminous-devs/plugins-workspace", branch = "v2" }
tauri-plugin-dialog = { git = "https://github.com/luminous-devs/plugins-workspace", branch = "v2" }
tauri-plugin-http = { git = "https://github.com/luminous-devs/plugins-workspace", branch = "v2" }
tauri-plugin-fs = { git = "https://github.com/luminous-devs/plugins-workspace", branch = "v2" }
tauri-plugin-clipboard-manager = { git = "https://github.com/luminous-devs/plugins-workspace", branch = "v2" }
tauri-plugin-notification = { git = "https://github.com/luminous-devs/plugins-workspace", branch = "v2" }
tauri-plugin-app = { git = "https://github.com/luminous-devs/plugins-workspace", branch = "v2" }
tauri-plugin-process = { git = "https://github.com/luminous-devs/plugins-workspace", branch = "v2" }
tauri-plugin-os = { git = "https://github.com/luminous-devs/plugins-workspace", branch = "v2" }
tauri-plugin-window = { git = "https://github.com/luminous-devs/plugins-workspace", branch = "v2" }
tauri-plugin-store = { git = "https://github.com/luminous-devs/plugins-workspace", branch = "v2" }
tauri-plugin-shell = { git = "https://github.com/luminous-devs/plugins-workspace", branch = "v2" }
tauri-plugin-sql = { git = "hhttps://github.com/tauri-apps/plugins-workspace", branch = "v1", features = [
"sqlite",
] }
tauri-plugin-single-instance = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
tauri-plugin-autostart = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
tauri-plugin-stronghold = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
tauri-plugin-store = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
tauri-plugin-upload = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
window-vibrancy = { git = "https://github.com/tauri-apps/window-vibrancy", branch = "dev" }
sqlx-cli = { version = "0.7.0", default-features = false, features = [
"sqlite",
] }
rust-argon2 = "1.0"
[dependencies.tauri-plugin-sql]
git = "https://github.com/luminous-devs/plugins-workspace"
branch = "v2"
features = ["sqlite"]
webpage = { version = "1.6.0", features = ["serde"] }
[features]
# by default Tauri runs in production mode
@@ -53,7 +61,7 @@ features = ["sqlite"]
default = ["custom-protocol"]
# this feature is used used for production builds where `devPath` points to the filesystem
# DO NOT remove this
custom-protocol = []
custom-protocol = ["tauri/custom-protocol"]
# Optimized for bundle size. If you want faster builds comment out/delete this section.
[profile.release]

View File

@@ -0,0 +1,2 @@
-- Add migration script here
CREATE UNIQUE INDEX unique_relay ON relays (relay);

View File

@@ -6,6 +6,11 @@
use tauri::Manager;
use tauri_plugin_autostart::MacosLauncher;
use tauri_plugin_sql::{Migration, MigrationKind};
use webpage::{Webpage, WebpageOptions};
use std::time::Duration;
#[cfg(target_os = "macos")]
use window_vibrancy::{apply_vibrancy, NSVisualEffectMaterial};
#[derive(Clone, serde::Serialize)]
struct Payload {
@@ -13,6 +18,71 @@ struct Payload {
cwd: String,
}
#[derive(serde::Serialize)]
struct OpenGraphResponse {
title: String,
description: String,
url: String,
image: String,
}
async fn fetch_opengraph(url: String) -> OpenGraphResponse {
let options = WebpageOptions {
allow_insecure: false,
max_redirections: 3,
timeout: Duration::from_secs(15),
useragent: "lume - desktop app".to_string(),
..Default::default()
};
let result = match Webpage::from_url(&url, options) {
Ok(webpage) => webpage,
Err(_) => {
return OpenGraphResponse {
title: "".to_string(),
description: "".to_string(),
url: "".to_string(),
image: "".to_string(),
}
}
};
let html = result.html;
return OpenGraphResponse {
title: html
.opengraph
.properties
.get("title")
.cloned()
.unwrap_or_default(),
description: html
.opengraph
.properties
.get("description")
.cloned()
.unwrap_or_default(),
url: html
.opengraph
.properties
.get("url")
.cloned()
.unwrap_or_default(),
image: html
.opengraph
.images
.get(0)
.and_then(|i| Some(i.url.clone()))
.unwrap_or_default(),
};
}
#[tauri::command]
async fn opengraph(url: String) -> OpenGraphResponse {
let result = fetch_opengraph(url).await;
return result;
}
#[tauri::command]
async fn close_splashscreen(window: tauri::Window) {
// Close splashscreen
@@ -25,6 +95,15 @@ async fn close_splashscreen(window: tauri::Window) {
fn main() {
tauri::Builder::default()
.setup(|app| {
let window = app.get_window("main").unwrap();
#[cfg(target_os = "macos")]
apply_vibrancy(&window, NSVisualEffectMaterial::HudWindow, None, None)
.expect("Unsupported platform! 'apply_vibrancy' is only supported on macOS");
Ok(())
})
.plugin(
tauri_plugin_sql::Builder::default()
.add_migrations(
@@ -132,6 +211,12 @@ fn main() {
sql: include_str!("../migrations/20230817014932_add_last_login_time_to_account.sql"),
kind: MigrationKind::Up,
},
Migration {
version: 20230918235335,
description: "add unique to relay",
sql: include_str!("../migrations/20230918235335_add_uniq_to_relay.sql"),
kind: MigrationKind::Up,
},
],
)
.build(),
@@ -168,20 +253,9 @@ fn main() {
.emit_all("single-instance", Payload { args: argv, cwd })
.unwrap();
}))
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_upload::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_app::init())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_window::init())
.plugin(tauri_plugin_store::Builder::default().build())
.plugin(tauri_plugin_shell::init())
.invoke_handler(tauri::generate_handler![close_splashscreen])
.invoke_handler(tauri::generate_handler![close_splashscreen, opengraph])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View File

@@ -1,47 +1,86 @@
{
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
"build": {
"beforeBuildCommand": "pnpm build",
"beforeDevCommand": "pnpm dev",
"beforeBuildCommand": "pnpm run build",
"beforeDevCommand": "pnpm run dev",
"devPath": "http://localhost:3000",
"distDir": "../dist",
"withGlobalTauri": true
},
"package": {
"productName": "Lume",
"version": "1.2.1"
},
"plugins": {
"fs": {
"scope": [
"$APPDATA/*",
"$DATA/*",
"$LOCALDATA/*",
"$DESKTOP/*",
"$DOCUMENT/*",
"$DOWNLOAD/*",
"$HOME/*",
"$PICTURE/*",
"$PUBLIC/*",
"$VIDEO/*"
]
},
"http": {
"scope": [
"http://**/",
"https://**/"
]
},
"shell": {
"open": true
},
"updater": {
"endpoints": [
"https://lus.reya3772.workers.dev/v1/{{target}}/{{arch}}/{{current_version}}",
"https://lus.reya3772.workers.dev/{{target}}/{{current_version}}"
]
}
"version": "1.2.5"
},
"tauri": {
"allowlist": {
"app": {
"all": true,
"show": true,
"hide": true
},
"path": {
"all": true
},
"dialog": {
"all": true,
"ask": true,
"confirm": true,
"message": true,
"open": true,
"save": true
},
"fs": {
"all": false,
"removeFile": true,
"writeFile": true,
"readDir": true,
"readFile": true,
"scope": [
"$APPDATA/*",
"$DATA/*",
"$LOCALDATA/*",
"$DESKTOP/*",
"$DOCUMENT/*",
"$DOWNLOAD/*",
"$HOME/*",
"$PICTURE/*",
"$PUBLIC/*",
"$VIDEO/*"
]
},
"http": {
"all": true,
"scope": [
"http://**",
"https://**"
]
},
"shell": {
"all": false,
"open": true
},
"os": {
"all": true
},
"window": {
"all": false,
"center": true,
"setResizable": true,
"setSize": true,
"startDragging": true,
"create": true,
"close": true,
"print": true
},
"clipboard": {
"all": false,
"writeText": true,
"readText": true
},
"notification": {
"all": true
}
},
"bundle": {
"active": true,
"appimage": {
@@ -79,19 +118,24 @@
"timestampUrl": ""
}
},
"updater": {
"endpoints": [
"https://lus.reya3772.workers.dev/v1/{{target}}/{{arch}}/{{current_version}}",
"https://lus.reya3772.workers.dev/{{target}}/{{current_version}}"
]
},
"security": {
"csp": {
"connect-src": "ipc: https://ipc.localhost",
"content-security-policy": "upgrade-insecure-requests"
},
"freezePrototype": false,
"assetProtocol": {
"enable": true,
"scope": {
"allow": ["$APPCONFIG/*.db", "$RESOURCE/**"],
"deny": ["$APPCONFIG/*.stronghold"]
"dangerousRemoteDomainIpcAccess": [
{
"scheme": "https",
"domain": "nwc.getalby.com",
"windows": ["alby"],
"enableTauriAPI": true
}
}
]
},
"macOSPrivateApi": true
}

View File

@@ -1,4 +1,5 @@
{
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
"tauri": {
"windows": [
{

View File

@@ -1,4 +1,5 @@
{
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
"tauri": {
"windows": [
{
@@ -27,11 +28,7 @@
"fullscreen": false,
"hiddenTitle": true,
"visible": false,
"fileDropEnabled": true,
"windowEffects": {
"effects": ["hudWindow"],
"state": "followsWindowActiveState"
}
"fileDropEnabled": true
}
]
}

View File

@@ -1,4 +1,5 @@
{
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
"tauri": {
"windows": [
{
@@ -19,16 +20,12 @@
"resizable": true,
"theme": "Dark",
"title": "Lume",
"transparent": true,
"transparent": false,
"center": true,
"fullscreen": false,
"hiddenTitle": true,
"visible": false,
"fileDropEnabled": true,
"windowEffects": {
"effects": ["micaDark", "micaLight", "acrylic"],
"state": "followsWindowActiveState"
}
"fileDropEnabled": true
}
]
}

View File

@@ -1,3 +1,4 @@
import { message } from '@tauri-apps/api/dialog';
import { RouterProvider, createBrowserRouter, redirect } from 'react-router-dom';
import { AuthCreateScreen } from '@app/auth/create';
@@ -5,6 +6,7 @@ import { AuthImportScreen } from '@app/auth/import';
import { OnboardingScreen } from '@app/auth/onboarding';
import { ErrorScreen } from '@app/error';
import { Frame } from '@shared/frame';
import { LoaderIcon } from '@shared/icons';
import { AppLayout } from '@shared/layouts/app';
import { AuthLayout } from '@shared/layouts/auth';
@@ -18,18 +20,20 @@ import './index.css';
async function Loader() {
try {
const account = await checkActiveAccount();
const stronghold = sessionStorage.getItem('stronghold');
const privkey = JSON.parse(stronghold).state.privkey || null;
const onboarding = localStorage.getItem('onboarding');
const step = JSON.parse(onboarding).state.step || null;
if (step) {
return redirect(step);
}
if (!account) {
return redirect('/auth/welcome');
} else {
if (step) {
return redirect(step);
}
if (!privkey) {
return redirect('/auth/unlock');
}
@@ -37,7 +41,7 @@ async function Loader() {
return null;
} catch (e) {
throw new Error('App failed to load');
await message(e, { title: 'An unexpected error has occurred', type: 'error' });
}
}
@@ -76,6 +80,13 @@ const router = createBrowserRouter([
return { Component: NotificationScreen };
},
},
{
path: 'nwc',
async lazy() {
const { NWCScreen } = await import('@app/nwc');
return { Component: NWCScreen };
},
},
],
},
{
@@ -194,15 +205,15 @@ const router = createBrowserRouter([
return { Component: OnboardStep2Screen };
},
},
{
path: 'step-3',
async lazy() {
const { OnboardStep3Screen } = await import('@app/auth/onboarding/step-3');
return { Component: OnboardStep3Screen };
},
},
],
},
{
path: 'complete',
async lazy() {
const { CompleteScreen } = await import('@app/auth/complete');
return { Component: CompleteScreen };
},
},
{
path: 'unlock',
async lazy() {
@@ -224,13 +235,6 @@ const router = createBrowserRouter([
return { Component: ResetScreen };
},
},
{
path: 'hard-reset',
async lazy() {
const { HardResetScreen } = await import('@app/auth/hardReset');
return { Component: HardResetScreen };
},
},
],
},
{
@@ -261,9 +265,9 @@ export default function App() {
<RouterProvider
router={router}
fallbackElement={
<div className="flex h-full w-full items-center justify-center bg-black/90 backdrop-blur-xl">
<Frame className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-6 w-6 animate-spin text-white" />
</div>
</Frame>
}
/>
);

42
src/app/auth/complete.tsx Normal file
View File

@@ -0,0 +1,42 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
export function CompleteScreen() {
const navigate = useNavigate();
const [count, setCount] = useState(5);
useEffect(() => {
let counter: NodeJS.Timeout;
if (count > 0) {
counter = setTimeout(() => setCount(count - 1), 1000);
}
if (count === 0) {
navigate('/', { replace: true });
}
return () => {
clearTimeout(counter);
};
}, [count]);
return (
<div className="relative flex h-full w-full flex-col items-center justify-center">
<div className="mx-auto flex max-w-xl flex-col gap-1.5 text-center">
<h1 className="text-2xl font-light leading-none text-white">
<span className="font-semibold">You&apos;re ready</span>, redirecting in {count}
</h1>
<p className="text-white/70">
Thank you for using Lume. Lume doesn&apos;t use telemetry. If you encounter any
problems, please submit a report via the &quot;Report Issue&quot; button.
<br />
You can find it while using the application.
</p>
</div>
<div className="absolute bottom-6 left-1/2 flex -translate-x-1/2 transform items-center justify-center">
<img src="/lume.png" alt="lume" className="h-auto w-1/5" />
</div>
</div>
);
}

View File

@@ -1,38 +0,0 @@
import { Image } from '@shared/image';
import { useProfile } from '@utils/hooks/useProfile';
import { displayNpub } from '@utils/shortenKey';
export function User({ pubkey, fallback }: { pubkey: string; fallback?: string }) {
const { status, user } = useProfile(pubkey, fallback);
if (status === 'loading') {
return (
<div className="flex items-center gap-2">
<div className="relative h-10 w-10 shrink-0 animate-pulse rounded-md bg-white/10 backdrop-blur-xl" />
<div className="flex w-full flex-1 flex-col items-start gap-1 text-start">
<span className="h-4 w-1/2 animate-pulse rounded bg-white/10 backdrop-blur-xl" />
<span className="h-3 w-1/3 animate-pulse rounded bg-white/10 backdrop-blur-xl" />
</div>
</div>
);
}
return (
<div className="flex items-center gap-2.5">
<Image
src={user?.picture || user?.image}
alt={pubkey}
className="h-10 w-10 shrink-0 rounded-lg object-cover"
/>
<div className="flex w-full flex-1 flex-col items-start text-start">
<p className="max-w-[15rem] truncate font-medium leading-tight text-white">
{user?.name || user?.display_name || user?.nip05}
</p>
<span className="max-w-[15rem] truncate leading-tight text-white/50">
{displayNpub(pubkey, 16)}
</span>
</div>
</div>
);
}

View File

@@ -1,36 +0,0 @@
import { Image } from '@shared/image';
import { useProfile } from '@utils/hooks/useProfile';
import { displayNpub } from '@utils/shortenKey';
export function UserRelay({ pubkey }: { pubkey: string }) {
const { status, user } = useProfile(pubkey);
if (status === 'loading') {
return (
<div className="flex items-center gap-2">
<div className="relative h-10 w-10 shrink-0 animate-pulse rounded-md bg-white/10 backdrop-blur-xl" />
<div className="flex w-full flex-1 flex-col items-start gap-1 text-start">
<span className="h-4 w-1/2 animate-pulse rounded bg-white/10 backdrop-blur-xl" />
<span className="h-3 w-1/3 animate-pulse rounded bg-white/10 backdrop-blur-xl" />
</div>
</div>
);
}
return (
<div className="inline-flex items-center gap-2 text-white/50">
<span className="text-sm">Use by</span>
<div className="inline-flex items-center gap-1">
<Image
src={user?.picture || user?.image}
alt={pubkey}
className="h-5 w-5 shrink-0 rounded object-cover"
/>
<span className="truncate text-sm font-medium leading-none text-white">
{user?.name || user?.display_name || user?.nip05 || displayNpub(pubkey, 16)}
</span>
</div>
</div>
);
}

View File

@@ -1,13 +1,14 @@
import { BaseDirectory, writeTextFile } from '@tauri-apps/plugin-fs';
import { writeText } from '@tauri-apps/api/clipboard';
import { message, save } from '@tauri-apps/api/dialog';
import { writeTextFile } from '@tauri-apps/api/fs';
import { downloadDir } from '@tauri-apps/api/path';
import { generatePrivateKey, getPublicKey, nip19 } from 'nostr-tools';
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider';
import { Button } from '@shared/button';
import { EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
import { CopyIcon } from '@shared/icons';
import { useOnboarding } from '@stores/onboarding';
import { useStronghold } from '@stores/stronghold';
@@ -21,8 +22,8 @@ export function CreateStep1Screen() {
const setPubkey = useOnboarding((state) => state.setPubkey);
const setStep = useOnboarding((state) => state.setStep);
const [privkeyInput, setPrivkeyInput] = useState('password');
const [loading, setLoading] = useState(false);
const [copied, setCopied] = useState(false);
const [downloaded, setDownloaded] = useState(false);
const privkey = useMemo(() => generatePrivateKey(), []);
@@ -30,27 +31,39 @@ export function CreateStep1Screen() {
const npub = nip19.npubEncode(pubkey);
const nsec = nip19.nsecEncode(privkey);
// toggle private key
const showPrivateKey = () => {
if (privkeyInput === 'password') {
setPrivkeyInput('text');
} else {
setPrivkeyInput('password');
const download = async () => {
try {
const downloadPath = await downloadDir();
const fileName = `nostr_keys_${new Date().toISOString()}.txt`;
const filePath = await save({
defaultPath: downloadPath + '/' + fileName,
});
if (filePath) {
await writeTextFile(
filePath,
`Generated by Lume (lume.nu)\nPublic key: ${npub}\nPrivate key: ${nsec}`
);
setDownloaded(true);
} // else { user cancel action }
} catch (e) {
await message(e, { title: 'Cannot download account keys', type: 'error' });
}
};
const download = async () => {
await writeTextFile(
`nostr_keys_${new Date().toISOString().slice(0, 10)}.txt`,
`Generated by Lume (lume.nu)\nPublic key: ${npub}\nPrivate key: ${nsec}`,
{
dir: BaseDirectory.Download,
}
);
setDownloaded(true);
const copyPrivkey = async () => {
try {
await writeText(nsec);
setCopied(true);
setTimeout(() => setCopied(false), 3000);
} catch (e) {
await message(e, { title: 'Cannot copy private key', type: 'error' });
}
};
const submit = () => {
const submit = async () => {
setLoading(true);
// update state
@@ -59,7 +72,7 @@ export function CreateStep1Screen() {
setPubkey(pubkey);
// save to database
db.createAccount(npub, pubkey);
await db.createAccount(npub, pubkey);
// redirect to next step
navigate('/auth/create/step-2', { replace: true });
@@ -67,81 +80,73 @@ export function CreateStep1Screen() {
useEffect(() => {
// save current step, if user close app and reopen it
setStep('/auth/create/step-1');
setStep('/auth/create');
}, []);
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-white">Save your access key!</h1>
<div className="mb-4 border-b border-white/10 pb-4">
<h1 className="mb-2 text-center text-2xl font-semibold text-white">
This is your new Nostr account
</h1>
<p className="mb-2 text-white/70">
Your private key is your password. If you lose this key, you will lose access to
your account! Copy it and keep it in a safe place. There is no way to reset your
private key.
</p>
<p className="text-white/70">
Public key is used for sharing with other people so that they can find you using
the public key.
</p>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<span className="text-base font-semibold text-white/50">Public Key</span>
<input
readOnly
value={npub}
className="relative h-11 w-full rounded-lg bg-white/10 px-3.5 py-1 text-white !outline-none backdrop-blur-xl placeholder:text-white/50"
/>
</div>
<div className="flex flex-col gap-1">
<span className="text-base font-semibold text-white/50">Private Key</span>
<div className="relative">
<div className="flex flex-col gap-8">
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<span className="font-medium text-white">Private Key</span>
<div className="relative">
<input
readOnly
value={nsec.substring(0, 5) + '**************************************'}
className="relative h-12 w-full rounded-lg border-t border-white/10 bg-white/20 py-1 pl-3.5 pr-11 text-white !outline-none backdrop-blur-xl placeholder:text-white/70"
/>
<button
type="button"
onClick={() => copyPrivkey()}
className="group absolute right-2 top-1/2 inline-flex h-7 -translate-y-1/2 transform items-center gap-1.5 rounded-md bg-white/20 px-2.5 text-sm hover:bg-white/30"
>
<CopyIcon className="h-4 w-4 text-white/70 group-hover:text-white" />
{copied ? 'Copied' : 'Copy'}
</button>
</div>
</div>
<div className="flex flex-col gap-1">
<span className="font-medium text-white">Public Key</span>
<input
readOnly
type={privkeyInput}
value={nsec}
className="relative h-11 w-full rounded-lg bg-white/10 py-1 pl-3.5 pr-11 text-white !outline-none backdrop-blur-xl placeholder:text-white/50"
value={npub}
className="relative h-12 w-full rounded-lg border-t border-white/10 bg-white/20 px-3.5 py-1 text-white !outline-none backdrop-blur-xl placeholder:text-white/70"
/>
<button
type="button"
onClick={() => showPrivateKey()}
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 backdrop-blur-xl hover:bg-white/10"
>
{privkeyInput === 'password' ? (
<EyeOffIcon className="h-4 w-4 text-white/50 group-hover:text-white" />
) : (
<EyeOnIcon className="h-4 w-4 text-white/50 group-hover:text-white" />
)}
</button>
</div>
<div className="mt-2 text-sm text-white/50">
<p>
Your private key is your password. If you lose this key, you will lose
access to your account! Copy it and keep it in a safe place. There is no way
to reset your private key.
</p>
</div>
</div>
<div className="flex flex-col gap-2">
<button
type="button"
onClick={() => submit()}
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
onClick={() => download()}
className="inline-flex h-12 w-full items-center justify-center rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
>
{loading ? (
<>
<span className="w-5" />
<span>Creating...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
</>
) : (
<>
<span className="w-5" />
<span>I have saved my key, continue</span>
<ArrowRightCircleIcon className="h-5 w-5" />
</>
)}
{downloaded ? 'Downloaded' : 'Download account keys'}
</button>
{downloaded ? (
<span className="inline-flex h-11 w-full items-center justify-center text-sm text-white/50">
Saved in Download folder
</span>
) : (
<Button preset="large-alt" onClick={() => download()}>
Download
</Button>
)}
<button
type="button"
onClick={() => submit()}
className="inline-flex h-12 w-full items-center justify-center rounded-lg border-t border-white/10 bg-white/20 px-6 font-medium leading-none text-white hover:bg-white/30 focus:outline-none"
>
{loading ? 'Creating...' : 'Continue'}
</button>
<span className="text-center text-sm text-white/50">
By clicking &apos;Continue&apos;, you are ensuring that your keys are saved in
a safe place. You cannot recover these keys if they are lost.
</span>
</div>
</div>
</div>

View File

@@ -1,8 +1,8 @@
import { appConfigDir } from '@tauri-apps/api/path';
import { Stronghold } from '@tauri-apps/plugin-stronghold';
import { useEffect, useState } from 'react';
import { Resolver, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { Stronghold } from 'tauri-plugin-stronghold-api';
import { useStorage } from '@libs/storage/provider';
@@ -61,9 +61,9 @@ export function CreateStep2Screen() {
setLoading(true);
if (data.password.length > 3) {
const dir = await appConfigDir();
const stronghold = await Stronghold.load(`${dir}/lume.stronghold`, data.password);
const stronghold = await Stronghold.load(`${dir}lume.stronghold`, data.password);
db.secureDB = stronghold;
if (!db.secureDB) db.secureDB = stronghold;
// save privkey to secure storage
await db.secureSave(pubkey, privkey);
@@ -86,10 +86,16 @@ export function CreateStep2Screen() {
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-white">
<div className="mb-4 border-b border-white/10 pb-4">
<h1 className="mb-2 text-center text-2xl font-semibold text-white">
Set password to secure your key
</h1>
<p className="text-white/70">
Password is not related to your Nostr account. It is only used to secure your
keys stored on your local machine and to unlock the app (like unlocking your
phone with a passcode). When you move to other Nostr clients, you just need to
copy your private key.
</p>
</div>
<div className="flex flex-col gap-4">
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-3">
@@ -98,12 +104,13 @@ export function CreateStep2Screen() {
<input
{...register('password', { required: true })}
type={passwordInput}
className="relative h-11 w-full rounded-lg bg-white/10 px-3.5 py-1 text-center text-white !outline-none backdrop-blur-xl placeholder:text-white/50"
placeholder="Enter password"
className="relative h-12 w-full rounded-lg border-t border-white/10 bg-white/20 px-3.5 py-1 text-center tracking-widest text-white !outline-none backdrop-blur-xl placeholder:tracking-normal placeholder:text-white/70"
/>
<button
type="button"
onClick={() => showPassword()}
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 backdrop-blur-xl hover:bg-white/10"
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 backdrop-blur-xl hover:bg-white/20"
>
{passwordInput === 'password' ? (
<EyeOffIcon className="h-4 w-4 text-white/50 group-hover:text-white" />
@@ -112,13 +119,6 @@ export function CreateStep2Screen() {
)}
</button>
</div>
<div className="text-sm text-white/50">
<p>
Password is use to secure your key store in local machine, when you move
to other clients, you just need to copy your private key as nsec or
hexstring
</p>
</div>
<span className="text-sm text-red-400">
{errors.password && <p>{errors.password.message}</p>}
</span>
@@ -127,12 +127,12 @@ export function CreateStep2Screen() {
<button
type="submit"
disabled={!isDirty || !isValid}
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg border-t border-white/10 bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
>
{loading ? (
<>
<span className="w-5" />
<span>Creating...</span>
<span>Securing your account...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
</>
) : (

View File

@@ -1,7 +1,10 @@
import { NDKKind } from '@nostr-dev-kit/ndk';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider';
import { AvatarUploader } from '@shared/avatarUploader';
import { BannerUploader } from '@shared/bannerUploader';
import { LoaderIcon } from '@shared/icons';
@@ -9,6 +12,7 @@ import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
import { Image } from '@shared/image';
import { useOnboarding } from '@stores/onboarding';
import { WidgetKinds } from '@stores/widgets';
import { useNostr } from '@utils/hooks/useNostr';
@@ -20,6 +24,7 @@ export function CreateStep3Screen() {
const [picture, setPicture] = useState('https://void.cat/d/5VKmKyuHyxrNMf9bWSVPih');
const [banner, setBanner] = useState('');
const { db } = useStorage();
const { publish } = useNostr();
const {
register,
@@ -40,10 +45,13 @@ export function CreateStep3Screen() {
const event = await publish({
content: JSON.stringify(profile),
kind: 0,
kind: NDKKind.Metadata,
tags: [],
});
// create default widget
await db.createWidget(WidgetKinds.other.learnNostr, 'Learn Nostr', '');
if (event) {
navigate('/auth/onboarding', { replace: true });
}
@@ -60,30 +68,41 @@ export function CreateStep3Screen() {
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-white">Create your profile</h1>
<div className="mb-4 border-b border-white/10 pb-4">
<h1 className="mb-2 text-center text-2xl font-semibold text-white">
Personalize your Nostr profile
</h1>
<p className="text-white/70">
Nostr profile is synchronous across all Nostr clients. If you create a profile
on Lume, it will also work well with other Nostr clients. If you update your
profile on another Nostr client, it will also sync to Lume.
</p>
</div>
<div className="w-full overflow-hidden rounded-xl bg-white/10 backdrop-blur-xl">
<form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col">
<input type={'hidden'} {...register('picture')} value={picture} />
<input type={'hidden'} {...register('banner')} value={banner} />
<div className="relative">
<div className="relative h-44 w-full bg-white/10 backdrop-blur-xl">
<Image
src={banner}
alt="user's banner"
className="h-full w-full object-cover"
/>
<div className="relative h-36 w-full bg-white/10 backdrop-blur-xl">
{banner ? (
<Image
src={banner}
alt="user's banner"
className="h-full w-full object-cover"
/>
) : (
<div className="h-full w-full bg-white/20" />
)}
<div className="absolute left-1/2 top-1/2 z-10 h-full w-full -translate-x-1/2 -translate-y-1/2 transform">
<BannerUploader setBanner={setBanner} />
</div>
</div>
<div className="mb-5 px-4">
<div className="relative z-10 -mt-7 h-14 w-14">
<div className="relative z-10 -mt-8 h-16 w-16">
<Image
src={picture}
alt="user's avatar"
className="h-14 w-14 rounded-lg object-cover ring-2 ring-white/10"
className="h-16 w-16 rounded-lg object-cover ring-2 ring-white/20"
/>
<div className="absolute left-1/2 top-1/2 z-10 h-full w-full -translate-x-1/2 -translate-y-1/2 transform">
<AvatarUploader setPicture={setPicture} />
@@ -93,55 +112,45 @@ export function CreateStep3Screen() {
</div>
<div className="flex flex-col gap-4 px-4 pb-4">
<div className="flex flex-col gap-1">
<label
htmlFor="name"
className="text-sm font-semibold uppercase tracking-wider text-white/50"
>
<label htmlFor="name" className="font-medium text-white">
Name *
</label>
<input
type={'text'}
{...register('name', {
required: true,
minLength: 4,
minLength: 1,
})}
spellCheck={false}
className="relative h-11 w-full rounded-lg bg-white/10 px-3 py-1 text-white !outline-none backdrop-blur-xl placeholder:text-white/50"
className="relative h-12 w-full rounded-lg bg-white/20 px-3 py-1 text-white !outline-none backdrop-blur-xl placeholder:text-white/70"
/>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="about"
className="text-sm font-semibold uppercase tracking-wider text-white/50"
>
<label htmlFor="about" className="font-medium text-white">
Bio
</label>
<textarea
{...register('about')}
spellCheck={false}
className="relative h-20 w-full resize-none rounded-lg bg-white/10 px-3 py-1 text-white !outline-none backdrop-blur-xl placeholder:text-white/50"
className="relative h-20 w-full resize-none rounded-lg bg-white/20 px-3 py-2 text-white !outline-none backdrop-blur-xl placeholder:text-white/70"
/>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="website"
className="text-sm font-semibold uppercase tracking-wider text-white/50"
>
<label htmlFor="website" className="font-medium text-white">
Website
</label>
<input
type={'text'}
{...register('website', {
required: false,
})}
spellCheck={false}
className="relative h-11 w-full rounded-lg bg-white/10 px-3 py-1 text-white !outline-none backdrop-blur-xl placeholder:text-white/50"
className="relative h-12 w-full rounded-lg bg-white/20 px-3 py-1 text-white !outline-none backdrop-blur-xl placeholder:text-white/70"
/>
</div>
<button
type="submit"
disabled={!isDirty || !isValid}
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg border-t border-white/10 bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
>
{loading ? (
<>

View File

@@ -1,7 +0,0 @@
export function HardResetScreen() {
return (
<div>
<p>hard reset</p>
</div>
);
}

View File

@@ -5,7 +5,7 @@ import { useNavigate } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider';
import { LoaderIcon } from '@shared/icons';
import { EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
import { useOnboarding } from '@stores/onboarding';
@@ -37,6 +37,7 @@ export function ImportStep1Screen() {
const setStep = useOnboarding((state) => state.setStep);
const [loading, setLoading] = useState(false);
const [passwordInput, setPasswordInput] = useState('password');
const { db } = useStorage();
const {
@@ -64,12 +65,13 @@ export function ImportStep1Screen() {
setPubkey(pubkey);
// add account to local database
db.createAccount(npub, pubkey);
await db.createAccount(npub, pubkey);
// redirect to step 2
navigate('/auth/import/step-2', { replace: true });
// redirect to step 2 with delay 1.2s
setTimeout(() => navigate('/auth/import/step-2', { replace: true }), 1200);
}
} catch (error) {
setLoading(false);
setError('privkey', {
type: 'custom',
message: 'Private key is invalid, please check again',
@@ -77,27 +79,53 @@ export function ImportStep1Screen() {
}
};
// toggle private key
const showPassword = () => {
if (passwordInput === 'password') {
setPasswordInput('text');
} else {
setPasswordInput('password');
}
};
useEffect(() => {
// save current step, if user close app and reopen it
setStep('/auth/import/step-1');
setStep('/auth/import');
}, []);
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-white">Import your key</h1>
<div className="mb-4 pb-4">
<h1 className="text-center text-2xl font-semibold text-white">
Import your Nostr key
</h1>
</div>
<div className="flex flex-col gap-4">
<form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col gap-3">
<div className="flex flex-col gap-1">
<span className="text-base font-semibold text-white/50">Private key</span>
<input
{...register('privkey', { required: true, minLength: 32 })}
type={'password'}
placeholder="nsec or hexstring"
className="relative h-11 w-full rounded-lg bg-white/10 px-3 py-1 text-white !outline-none backdrop-blur-xl placeholder:text-white/50"
/>
<span className="text-sm text-red-400">
<label htmlFor="privkey" className="font-medium text-white">
Insert your nostr private key, in nsec or hex format
</label>
<div className="relative">
<input
{...register('privkey', { required: true, minLength: 32 })}
type={passwordInput}
placeholder="nsec1..."
className="relative h-12 w-full rounded-lg border-t border-white/10 bg-white/20 px-3 py-1 text-white backdrop-blur-xl placeholder:text-white/70 focus:outline-none"
/>
<button
type="button"
onClick={() => showPassword()}
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 backdrop-blur-xl hover:bg-white/20"
>
{passwordInput === 'password' ? (
<EyeOffIcon className="h-4 w-4 text-white/50 group-hover:text-white" />
) : (
<EyeOnIcon className="h-4 w-4 text-white/50 group-hover:text-white" />
)}
</button>
</div>
<span className="text-sm text-red-500">
{errors.privkey && <p>{errors.privkey.message}</p>}
</span>
</div>
@@ -105,12 +133,12 @@ export function ImportStep1Screen() {
<button
type="submit"
disabled={!isDirty || !isValid}
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
>
{loading ? (
<>
<span className="w-5" />
<span>Creating...</span>
<span>Importing...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
</>
) : (

View File

@@ -1,8 +1,8 @@
import { appConfigDir } from '@tauri-apps/api/path';
import { Stronghold } from '@tauri-apps/plugin-stronghold';
import { useEffect, useState } from 'react';
import { Resolver, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { Stronghold } from 'tauri-plugin-stronghold-api';
import { useStorage } from '@libs/storage/provider';
@@ -61,9 +61,9 @@ export function ImportStep2Screen() {
setLoading(true);
if (data.password.length > 3) {
const dir = await appConfigDir();
const stronghold = await Stronghold.load(`${dir}/lume.stronghold`, data.password);
const stronghold = await Stronghold.load(`${dir}lume.stronghold`, data.password);
db.secureDB = stronghold;
if (!db.secureDB) db.secureDB = stronghold;
// save privkey to secure storage
await db.secureSave(pubkey, privkey);
@@ -86,10 +86,16 @@ export function ImportStep2Screen() {
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-white">
<div className="mb-4 border-b border-white/10 pb-4">
<h1 className="mb-2 text-center text-2xl font-semibold text-white">
Set password to secure your key
</h1>
<p className="text-white/70">
Password is not related to your Nostr account. It is only used to secure your
keys stored on your local machine and to unlock the app (like unlocking your
phone with a passcode). When you move to other Nostr clients, you only need to
copy your private key.
</p>
</div>
<div className="flex flex-col gap-4">
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-3">
@@ -98,12 +104,13 @@ export function ImportStep2Screen() {
<input
{...register('password', { required: true })}
type={passwordInput}
className="relative h-11 w-full rounded-lg bg-white/10 px-3.5 py-1 text-center text-white !outline-none backdrop-blur-xl placeholder:text-white/50"
placeholder="Enter password"
className="relative h-12 w-full rounded-lg border-t border-white/10 bg-white/20 px-3.5 py-1 text-center tracking-widest text-white !outline-none backdrop-blur-xl placeholder:tracking-normal placeholder:text-white/70"
/>
<button
type="button"
onClick={() => showPassword()}
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 backdrop-blur-xl hover:bg-white/10"
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 backdrop-blur-xl hover:bg-white/20"
>
{passwordInput === 'password' ? (
<EyeOffIcon className="h-4 w-4 text-white/50 group-hover:text-white" />
@@ -112,11 +119,6 @@ export function ImportStep2Screen() {
)}
</button>
</div>
<p className="text-sm text-white/50">
Password is use to unlock app and secure your key store in local machine.
When you move to other clients, you just need to copy your private key as
nsec or hexstring
</p>
<span className="text-sm text-red-400">
{errors.password && <p>{errors.password.message}</p>}
</span>
@@ -125,12 +127,12 @@ export function ImportStep2Screen() {
<button
type="submit"
disabled={!isDirty || !isValid}
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg border-t border-white/10 bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
>
{loading ? (
<>
<span className="w-5" />
<span>Creating...</span>
<span>Securing your account...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
</>
) : (

View File

@@ -1,13 +1,13 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { User } from '@app/auth/components/user';
import { useStorage } from '@libs/storage/provider';
import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
import { User } from '@shared/user';
import { useOnboarding } from '@stores/onboarding';
import { WidgetKinds } from '@stores/widgets';
import { useNostr } from '@utils/hooks/useNostr';
@@ -15,11 +15,11 @@ export function ImportStep3Screen() {
const navigate = useNavigate();
const setStep = useOnboarding((state) => state.setStep);
const [loading, setLoading] = useState(false);
const { db } = useStorage();
const { fetchUserData, prefetchEvents } = useNostr();
const [loading, setLoading] = useState(false);
const submit = async () => {
try {
// show loading indicator
@@ -29,6 +29,9 @@ export function ImportStep3Screen() {
const user = await fetchUserData();
const data = await prefetchEvents();
// create default widget
await db.createWidget(WidgetKinds.other.learnNostr, 'Learn Nostr', '');
// redirect to next step
if (user.status === 'ok' && data.status === 'ok') {
navigate('/auth/onboarding/step-2', { replace: true });
@@ -49,17 +52,19 @@ export function ImportStep3Screen() {
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold">
{loading ? 'Prefetching data...' : 'Continue with'}
<div className="mb-4 pb-4">
<h1 className="text-center text-2xl font-semibold text-white">
{loading ? 'Downloading...' : 'Your Nostr profile'}
</h1>
</div>
<div className="w-full rounded-xl bg-white/10 p-4 backdrop-blur-xl">
<div className="flex flex-col gap-3">
<User pubkey={db.account.pubkey} />
<div className="flex flex-col gap-3">
<div className="rounded-lg border-t border-white/10 bg-white/20 px-3 py-3">
<User pubkey={db.account.pubkey} variant="simple" />
</div>
<div className="flex flex-col gap-2">
<button
type="button"
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
onClick={() => submit()}
>
{loading ? (
@@ -76,6 +81,10 @@ export function ImportStep3Screen() {
</>
)}
</button>
<span className="text-center text-sm text-white/50">
By clicking &apos;Continue&apos;, Lume will download your old relay list and
all events from the last 24 hours. It may take a bit
</span>
</div>
</div>
</div>

View File

@@ -1,9 +1,9 @@
import { useQueryClient } from '@tanstack/react-query';
import { appConfigDir } from '@tauri-apps/api/path';
import { Stronghold } from '@tauri-apps/plugin-stronghold';
import { useState } from 'react';
import { Resolver, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { Stronghold } from 'tauri-plugin-stronghold-api';
import { useStorage } from '@libs/storage/provider';

View File

@@ -2,11 +2,10 @@ import { useQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { User } from '@app/auth/components/user';
import { useStorage } from '@libs/storage/provider';
import { ArrowRightCircleIcon, CheckCircleIcon, LoaderIcon } from '@shared/icons';
import { User } from '@shared/user';
import { useOnboarding } from '@stores/onboarding';
@@ -19,7 +18,7 @@ export function OnboardStep1Screen() {
const { publish, fetchUserData, prefetchEvents } = useNostr();
const { db } = useStorage();
const { status, data } = useQuery(['trending-profiles'], async () => {
const { status, data } = useQuery(['trending-profiles-widget'], async () => {
const res = await fetch('https://api.nostr.band/v0/trending/profiles');
if (!res.ok) {
throw new Error('Error');
@@ -68,45 +67,51 @@ export function OnboardStep1Screen() {
}, []);
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-white">
<div className="flex h-full w-full flex-col justify-center">
<div className="mx-auto mb-4 w-full max-w-md border-b border-white/10 pb-4">
<h1 className="mb-2 text-center text-2xl font-semibold text-white">
{loading ? 'Prefetching data...' : 'Enrich your network'}
</h1>
<p className="text-sm text-white/50">Choose account you want to follow</p>
<p className="text-white/70">
Choose the account you want to follow. These accounts are trending in the last
24 hours. If none of the accounts interest you, you can explore more options and
add them later.
</p>
</div>
<div className="flex flex-col gap-4">
<div className="scrollbar-hide flex h-[500px] w-full flex-col overflow-y-auto rounded-xl bg-white/10 py-2 backdrop-blur-xl">
{status === 'loading' ? (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-4 w-4 animate-spin text-white" />
</div>
) : (
data?.profiles.map(
(item: { pubkey: string; profile: { content: string } }) => (
<button
key={item.pubkey}
type="button"
onClick={() => toggleFollow(item.pubkey)}
className="inline-flex transform items-center justify-between px-4 py-2 hover:bg-white/20"
>
<User pubkey={item.pubkey} fallback={item.profile?.content} />
{follows.includes(item.pubkey) && (
<div>
<CheckCircleIcon className="h-4 w-4 text-green-400" />
</div>
)}
</button>
)
)
)}
</div>
<div className="scrollbar-hide flex w-full flex-nowrap items-center gap-4 overflow-x-auto px-4">
{status === 'loading' ? (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-4 w-4 animate-spin text-white" />
</div>
) : (
data?.profiles.map((item: { pubkey: string; profile: { content: string } }) => (
<button
key={item.pubkey}
type="button"
onClick={() => toggleFollow(item.pubkey)}
className="relative h-[300px] shrink-0 grow-0 basis-[250px] rounded-lg border-t border-white/10 bg-white/20 px-4 py-4 hover:bg-white/30"
>
<User
pubkey={item.pubkey}
variant="large"
embedProfile={item.profile?.content}
/>
{follows.includes(item.pubkey) && (
<div className="absolute right-2 top-2">
<CheckCircleIcon className="h-4 w-4 text-green-400" />
</div>
)}
</button>
))
)}
</div>
<div className="mx-auto mt-4 w-full max-w-md">
<div className="flex flex-col gap-2">
<button
type="button"
onClick={submit}
disabled={loading || follows.length === 0}
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none disabled:opacity-50"
className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg border-t border-white/10 bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none disabled:opacity-50"
>
{loading ? (
<>
@@ -122,12 +127,19 @@ export function OnboardStep1Screen() {
</>
)}
</button>
<Link
to="/auth/onboarding/step-2"
className="inline-flex h-11 w-full items-center justify-center rounded-lg px-6 font-medium leading-none text-white backdrop-blur-xl hover:bg-white/10 focus:outline-none"
>
Skip, you can add later
</Link>
{!loading ? (
<Link
to="/auth/onboarding/step-2"
className="inline-flex h-12 w-full items-center justify-center rounded-lg border-t border-white/10 bg-white/20 px-6 font-medium leading-none text-white backdrop-blur-xl hover:bg-white/30 focus:outline-none"
>
Skip, you can add later
</Link>
) : (
<span className="text-center text-sm text-white/50">
By clicking &apos;Continue&apos;, Lume will download all events related to
your follows from the last 24 hours. It may take a bit
</span>
)}
</div>
</div>
</div>

View File

@@ -1,5 +1,6 @@
import { message } from '@tauri-apps/api/dialog';
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider';
@@ -11,6 +12,7 @@ import { WidgetKinds } from '@stores/widgets';
const data = [
{ hashtag: '#bitcoin' },
{ hashtag: '#nostr' },
{ hashtag: '#nostrdesign' },
{ hashtag: '#zap' },
{ hashtag: '#LFG' },
{ hashtag: '#zapchain' },
@@ -19,6 +21,10 @@ const data = [
{ hashtag: '#hodl' },
{ hashtag: '#stacksats' },
{ hashtag: '#nokyc' },
{ hashtag: '#meme' },
{ hashtag: '#memes' },
{ hashtag: '#memestr' },
{ hashtag: '#penisbutter' },
{ hashtag: '#anime' },
{ hashtag: '#waifu' },
{ hashtag: '#manga' },
@@ -28,8 +34,8 @@ const data = [
export function OnboardStep2Screen() {
const navigate = useNavigate();
const setStep = useOnboarding((state) => state.setStep);
const [setStep, clearStep] = useOnboarding((state) => [state.setStep, state.clearStep]);
const [loading, setLoading] = useState(false);
const [tags, setTags] = useState(new Set<string>());
@@ -47,17 +53,34 @@ export function OnboardStep2Screen() {
}
};
const skip = async () => {
// update last login
await db.updateLastLogin();
// clear local storage
clearStep();
navigate('/auth/complete', { replace: true });
};
const submit = async () => {
try {
setLoading(true);
for (const tag of tags) {
await db.createWidget(WidgetKinds.hashtag, tag, tag.replace('#', ''));
await db.createWidget(WidgetKinds.global.hashtag, tag, tag.replace('#', ''));
}
navigate('/auth/onboarding/step-3', { replace: true });
} catch {
console.log('error');
// update last login
await db.updateLastLogin();
// clear local storage
clearStep();
navigate('/auth/complete', { replace: true });
} catch (e) {
setLoading(false);
await message(e, { title: 'Lume', type: 'error' });
}
};
@@ -68,20 +91,23 @@ export function OnboardStep2Screen() {
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-white">
Choose {tags.size}/3 your favorite tags
<div className="mb-4 border-b border-white/10 pb-4">
<h1 className="mb-2 text-center text-2xl font-semibold text-white">
Choose {tags.size}/3 your favorite hashtags
</h1>
<p className="text-sm text-white/50">Customize your space which hashtag widget</p>
<p className="text-white/70">
Hashtags are an easy way to discover more content. By adding a hashtag, Lume
will show all related posts. You can always add more later.
</p>
</div>
<div className="flex flex-col gap-4">
<div className="scrollbar-hide flex h-[500px] w-full flex-col overflow-y-auto rounded-xl bg-white/10 backdrop-blur-xl">
<div className="scrollbar-hide flex h-[450px] w-full flex-col divide-y divide-white/5 overflow-y-auto rounded-xl bg-white/20 backdrop-blur-xl">
{data.map((item: { hashtag: string }) => (
<button
key={item.hashtag}
type="button"
onClick={() => toggleTag(item.hashtag)}
className="inline-flex transform items-center justify-between bg-white/10 px-4 py-2 backdrop-blur-xl hover:bg-white/20"
className="inline-flex transform items-center justify-between px-4 py-2 hover:bg-white/10"
>
<p className="text-white">{item.hashtag}</p>
{tags.has(item.hashtag) && (
@@ -97,7 +123,7 @@ export function OnboardStep2Screen() {
type="button"
onClick={submit}
disabled={loading || tags.size === 0 || tags.size > 3}
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none disabled:opacity-50"
className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg border-t border-white/10 bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none disabled:opacity-50"
>
{loading ? (
<>
@@ -113,12 +139,15 @@ export function OnboardStep2Screen() {
</>
)}
</button>
<Link
to="/auth/onboarding/step-3"
className="inline-flex h-11 w-full items-center justify-center rounded-lg px-6 font-medium leading-none text-white backdrop-blur-xl hover:bg-white/10 focus:outline-none"
>
Skip, you can add later
</Link>
{!loading ? (
<button
type="button"
onClick={() => skip()}
className="inline-flex h-12 w-full items-center justify-center rounded-lg border-t border-white/10 bg-white/20 font-medium leading-none text-white backdrop-blur-xl hover:bg-white/30 focus:outline-none"
>
Skip, you can add later
</button>
) : null}
</div>
</div>
</div>

View File

@@ -2,12 +2,11 @@ import { useQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { UserRelay } from '@app/auth/components/userRelay';
import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
import { ArrowRightCircleIcon, CheckCircleIcon, LoaderIcon } from '@shared/icons';
import { User } from '@shared/user';
import { FULL_RELAYS } from '@stores/constants';
import { useOnboarding } from '@stores/onboarding';
@@ -49,6 +48,8 @@ export function OnboardStep3Screen() {
}
);
const relaysAsArray = Array.from(data?.keys() || []);
const toggleRelay = (relay: string) => {
if (relays.has(relay)) {
setRelays((prev) => {
@@ -61,8 +62,9 @@ export function OnboardStep3Screen() {
};
const submit = async (skip?: boolean) => {
setLoading(true);
try {
setLoading(true);
if (!skip) {
for (const relay of relays) {
await db.createRelay(relay);
@@ -87,8 +89,6 @@ export function OnboardStep3Screen() {
}
};
const relaysAsArray = Array.from(data?.keys() || []);
useEffect(() => {
// save current step, if user close app and reopen it
setStep('/auth/onboarding/step-3');
@@ -118,10 +118,11 @@ export function OnboardStep3Screen() {
<LoaderIcon className="h-4 w-4 animate-spin text-white" />
</div>
) : relaysAsArray.length === 0 ? (
<div className="flex h-full w-full items-center justify-center">
<div className="flex h-full w-full items-center justify-center px-6">
<p className="text-center text-white/50">
Can&apos;t found any relays, you can skip this step and use default relays
instead
Lume couldn&apos;t find any relays from your follows.
<br />
You can skip this step and use default relays instead.
</p>
</div>
) : (
@@ -134,7 +135,7 @@ export function OnboardStep3Screen() {
>
<div className="flex flex-col items-start gap-1">
<p className="max-w-[15rem] truncate">{item.replace(/\/+$/, '')}</p>
<UserRelay pubkey={data.get(item)} />
<User pubkey={data.get(item)} variant="mention" />
</div>
{relays.has(item) && (
<div className="pt-1.5">
@@ -145,7 +146,7 @@ export function OnboardStep3Screen() {
))
)}
{relays.size > 5 && (
<div className="sticky bottom-0 left-0 inline-flex w-full items-center justify-center bg-white/10 px-4 py-2 backdrop-blur-2xl backdrop-blur-xl">
<div className="sticky bottom-0 left-0 inline-flex w-full items-center justify-center bg-white/10 px-4 py-2 backdrop-blur-2xl">
<p className="text-sm text-orange-400">
Using too much relay can cause high resource usage
</p>
@@ -178,7 +179,7 @@ export function OnboardStep3Screen() {
onClick={() => submit(true)}
className="inline-flex h-11 w-full items-center justify-center rounded-lg px-6 font-medium leading-none text-white backdrop-blur-xl hover:bg-white/10 focus:outline-none"
>
Skip, use default relays
Skip, use Lume default relays
</button>
</div>
</div>

View File

@@ -1,9 +1,9 @@
import { appConfigDir } from '@tauri-apps/api/path';
import { Stronghold } from '@tauri-apps/plugin-stronghold';
import { getPublicKey, nip19 } from 'nostr-tools';
import { useState } from 'react';
import { Resolver, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { Link, useNavigate } from 'react-router-dom';
import { Stronghold } from 'tauri-plugin-stronghold-api';
import { useStorage } from '@libs/storage/provider';
@@ -116,28 +116,28 @@ export function ResetScreen() {
</div>
<form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label htmlFor="privkey" className="font-medium text-white/50">
<label htmlFor="privkey" className="font-medium text-white">
Private key
</label>
<div className="relative">
<input
{...register('privkey', { required: true })}
type="text"
placeholder="nsec..."
className="relative h-12 w-full rounded-lg bg-white/10 px-3.5 py-1 text-white !outline-none backdrop-blur-xl placeholder:text-white/10"
placeholder="nsec1..."
className="relative h-12 w-full rounded-lg border-t border-white/10 bg-white/20 px-3.5 py-1 text-white !outline-none backdrop-blur-xl placeholder:text-white/70"
/>
</div>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="password" className="font-medium text-white/50">
<label htmlFor="password" className="font-medium text-white">
Set a new password to protect your key
</label>
<div className="relative">
<input
{...register('password', { required: true })}
type={passwordInput}
placeholder="min. 4 characters"
className="relative h-12 w-full rounded-lg bg-white/10 px-3.5 py-1 text-white !outline-none backdrop-blur-xl placeholder:text-white/10"
placeholder="Min. 4 characters"
className="relative h-12 w-full rounded-lg border-t border-white/10 bg-white/20 px-3.5 py-1 text-white !outline-none backdrop-blur-xl placeholder:text-white/70"
/>
<button
type="button"
@@ -155,7 +155,7 @@ export function ResetScreen() {
{errors.password && <p>{errors.password.message}</p>}
</span>
</div>
<div className="flex items-center justify-center">
<div className="flex flex-col items-center justify-center">
<button
type="submit"
disabled={!isDirty || !isValid}
@@ -167,6 +167,12 @@ export function ResetScreen() {
'Continue →'
)}
</button>
<Link
to="/auth/unlock"
className="mt-1 inline-flex h-12 w-full items-center justify-center rounded-lg text-center text-white/70 hover:bg-white/20"
>
Back
</Link>
</div>
</form>
</div>

View File

@@ -1,14 +1,13 @@
import { appConfigDir } from '@tauri-apps/api/path';
import { Stronghold } from '@tauri-apps/plugin-stronghold';
import { useState } from 'react';
import { Resolver, useForm } from 'react-hook-form';
import { Link, useNavigate } from 'react-router-dom';
import { User } from '@app/auth/components/user';
import { Stronghold } from 'tauri-plugin-stronghold-api';
import { useStorage } from '@libs/storage/provider';
import { ArrowRightCircleIcon, EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
import { User } from '@shared/user';
import { useStronghold } from '@stores/stronghold';
@@ -33,6 +32,7 @@ const resolver: Resolver<FormValues> = async (values) => {
export function UnlockScreen() {
const navigate = useNavigate();
const setPrivkey = useStronghold((state) => state.setPrivkey);
const setWalletConnectURL = useStronghold((state) => state.setWalletConnectURL);
const [showPassword, setShowPassword] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
@@ -55,8 +55,10 @@ export function UnlockScreen() {
if (!db.secureDB) db.secureDB = stronghold;
const privkey = await db.secureLoad(db.account.pubkey);
const uri = await db.secureLoad('walletConnectURL', 'nwc');
setPrivkey(privkey);
if (privkey) setPrivkey(privkey);
if (uri) setWalletConnectURL(uri);
// redirect to home
navigate('/', { replace: true });
} catch (e) {
@@ -71,20 +73,22 @@ export function UnlockScreen() {
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mx-auto w-full max-w-md">
<div className="mb-6 text-center">
<h1 className="text-2xl font-semibold text-white">Enter password to unlock</h1>
<div className="mb-4 pb-4">
<h1 className="text-center text-2xl font-semibold text-white">
Enter password to unlock
</h1>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col">
<div className="flex flex-col rounded-lg bg-white/5">
<div className="w-full rounded-t-lg border-b border-white/10 bg-white/5 p-4">
<User pubkey={db.account.pubkey} />
<User pubkey={db.account.pubkey} variant="simple" />
</div>
<div className="relative">
<input
{...register('password', { required: true, minLength: 4 })}
type={showPassword ? 'text' : 'password'}
placeholder="Password"
className="relative h-12 w-full rounded-b-lg bg-white/10 py-1 text-center text-white !outline-none backdrop-blur-xl placeholder:text-white/50"
className="relative h-12 w-full rounded-b-lg bg-white/10 py-1 text-center tracking-widest text-white !outline-none backdrop-blur-xl placeholder:tracking-normal placeholder:text-white/50"
/>
<button
type="button"
@@ -106,12 +110,12 @@ export function UnlockScreen() {
<button
type="submit"
disabled={!isDirty || !isValid}
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none disabled:opacity-50"
className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none disabled:opacity-50"
>
{loading ? (
<>
<span className="w-5" />
<span>Decryting...</span>
<span>Unlocking...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
</>
) : (
@@ -124,7 +128,7 @@ export function UnlockScreen() {
</button>
<Link
to="/auth/reset"
className="mt-1 inline-flex h-11 w-full items-center justify-center rounded-lg text-center text-white/50 hover:bg-white/10"
className="mt-1 inline-flex h-12 w-full items-center justify-center rounded-lg text-center text-white/70 hover:bg-white/20"
>
Reset password
</Link>

View File

@@ -1,4 +1,4 @@
import { LogicalSize, getCurrent } from '@tauri-apps/plugin-window';
import { LogicalSize, getCurrent } from '@tauri-apps/api/window';
import { useEffect } from 'react';
import { Link } from 'react-router-dom';
@@ -28,19 +28,19 @@ export function WelcomeScreen() {
}, []);
return (
<div className="flex h-screen w-full flex-col justify-between bg-white/10 backdrop-blur-xl">
<div className="flex h-screen w-full flex-col justify-between">
<div className="flex flex-col gap-10 pt-16">
<div className="sflex flex-col gap-2 text-center">
<h1 className="text-3xl font-medium text-white">Welcome to Lume</h1>
<h3 className="mx-auto w-2/3 text-white/50">
<div className="flex flex-col gap-1.5 text-center">
<h1 className="text-3xl font-semibold text-white">Welcome to Lume</h1>
<p className="mx-auto w-2/3 leading-tight text-white/50">
Let&apos;s get you up and connecting with all peoples around the world on
Nostr
</h3>
</p>
</div>
<div className="inline-flex w-full flex-col items-center gap-3 px-4 pb-10">
<Link
to="/auth/import"
className="inline-flex h-11 w-2/3 items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
className="inline-flex h-12 w-3/4 items-center justify-between gap-2 rounded-lg border-t border-white/10 bg-fuchsia-500 px-4 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
>
<span className="w-5" />
<span>Login with private key</span>
@@ -48,14 +48,14 @@ export function WelcomeScreen() {
</Link>
<Link
to="/auth/create"
className="inline-flex h-11 w-2/3 items-center justify-center gap-2 rounded-lg bg-white/10 px-6 font-medium leading-none text-white/50 backdrop-blur-xl hover:bg-white/20 focus:outline-none"
className="inline-flex h-12 w-3/4 items-center justify-center gap-2 rounded-lg border-t border-white/10 bg-white/20 font-medium leading-none text-white backdrop-blur-xl hover:bg-white/30 focus:outline-none"
>
Create new key
</Link>
</div>
</div>
<div className="flex flex-1 items-end justify-center pb-10">
<img src="/lume.png" alt="lume" className="h-auto w-1/3" />
<div className="flex flex-1 items-end justify-center pb-6">
<img src="/lume.png" alt="lume" className="h-auto w-1/4" />
</div>
</div>
);

View File

@@ -27,7 +27,7 @@ export function ChatsListItem({ pubkey }: { pubkey: string }) {
'flex h-10 items-center gap-2.5 rounded-r-lg border-l-2 pl-4 pr-2',
isActive
? 'border-fuchsia-500 bg-white/5 text-white'
: 'border-transparent text-white/80'
: 'border-transparent text-white/70'
)
}
>
@@ -38,7 +38,7 @@ export function ChatsListItem({ pubkey }: { pubkey: string }) {
/>
<div className="inline-flex w-full flex-1 items-center justify-between">
<h5 className="max-w-[10rem] truncate">
{user?.nip05 || user?.name || user?.display_name || displayNpub(pubkey, 16)}
{user?.name || user?.display_name || displayNpub(pubkey, 16)}
</h5>
</div>
</NavLink>

View File

@@ -36,10 +36,6 @@ export function ChatsList() {
<div className="relative h-7 w-7 shrink-0 animate-pulse rounded bg-white/10 backdrop-blur-xl" />
<div className="h-4 w-full animate-pulse rounded bg-white/10 backdrop-blur-xl" />
</div>
<div className="inline-flex h-10 items-center gap-2.5 border-l-2 border-transparent pl-4">
<div className="relative h-7 w-7 shrink-0 animate-pulse rounded bg-white/10 backdrop-blur-xl" />
<div className="h-4 w-full animate-pulse rounded bg-white/10 backdrop-blur-xl" />
</div>
</div>
);
}

View File

@@ -22,7 +22,7 @@ export function ChatMessageItem({
return (
<div className="flex h-min min-h-min w-full select-text flex-col px-5 py-3 backdrop-blur-xl hover:bg-white/10">
<div className="flex flex-col">
<User pubkey={message.pubkey} time={message.created_at} isChat={true} />
<User pubkey={message.pubkey} time={message.created_at} variant="chat" />
<div className="-mt-[20px] pl-[49px]">
<p className="select-text whitespace-pre-line break-words text-base text-white">
{message.content}

View File

@@ -3,14 +3,14 @@ import { Dispatch, SetStateAction, useState } from 'react';
import { LoaderIcon, MediaIcon } from '@shared/icons';
import { useImageUploader } from '@utils/hooks/useUploader';
import { useNostr } from '@utils/hooks/useNostr';
export function MediaUploader({
setState,
}: {
setState: Dispatch<SetStateAction<string>>;
}) {
const upload = useImageUploader();
const { upload } = useNostr();
const [loading, setLoading] = useState(false);
const uploadMedia = async () => {

View File

@@ -2,11 +2,10 @@ import * as Dialog from '@radix-ui/react-dialog';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { User } from '@app/auth/components/user';
import { useStorage } from '@libs/storage/provider';
import { CancelIcon, PlusIcon } from '@shared/icons';
import { User } from '@shared/user';
export function NewMessageModal() {
const navigate = useNavigate();
@@ -35,7 +34,7 @@ export function NewMessageModal() {
</button>
</Dialog.Trigger>
<Dialog.Portal className="relative z-10">
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xl" />
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-2xl" />
<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 rounded-xl bg-white/10 backdrop-blur-xl">
<div className="h-min w-full shrink-0 border-b border-white/10 bg-white/5 px-5 py-5">
@@ -44,7 +43,7 @@ export function NewMessageModal() {
<Dialog.Title className="text-lg font-semibold leading-none text-white">
New chat
</Dialog.Title>
<Dialog.Close className="inline-flex h-6 w-6 items-center justify-center rounded-md backdrop-blur-xl hover:bg-white/10">
<Dialog.Close className="inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-white/10">
<CancelIcon className="h-4 w-4 text-white/50" />
</Dialog.Close>
</div>
@@ -54,17 +53,17 @@ export function NewMessageModal() {
</div>
</div>
<div className="flex h-[500px] flex-col overflow-y-auto overflow-x-hidden pb-2 pt-2">
{db.account?.follows?.map((follow) => (
{db.account?.follows?.map((pubkey) => (
<div
key={follow}
className="group flex items-center justify-between px-4 py-2 backdrop-blur-xl hover:bg-white/10"
key={pubkey}
className="group flex items-center justify-between px-4 py-2 hover:bg-white/10"
>
<User pubkey={follow} />
<User pubkey={pubkey} variant="simple" />
<div>
<button
type="button"
onClick={() => openChat(follow)}
className="hidden w-max rounded bg-white/10 px-3 py-1 text-sm font-medium backdrop-blur-xl hover:bg-fuchsia-500 group-hover:inline-flex"
onClick={() => openChat(pubkey)}
className="hidden w-max rounded bg-white/10 px-3 py-1 text-sm font-medium hover:bg-fuchsia-500 group-hover:inline-flex"
>
Chat
</button>

View File

@@ -1,6 +1,7 @@
import { Link } from 'react-router-dom';
import { Image } from '@shared/image';
import { NIP05 } from '@shared/nip05';
import { useProfile } from '@utils/hooks/useProfile';
import { displayNpub } from '@utils/shortenKey';
@@ -23,9 +24,17 @@ export function ChatSidebar({ pubkey }: { pubkey: string }) {
<h3 className="text-lg font-semibold leading-none">
{user?.display_name || user?.name}
</h3>
<h5 className="leading-none text-white/50">
{user?.nip05 || displayNpub(pubkey, 16)}
</h5>
{user?.nip05 ? (
<NIP05
pubkey={pubkey}
nip05={user?.nip05}
className="leading-none text-white/50"
/>
) : (
<span className="leading-none text-white/50">
{displayNpub(pubkey, 16)}
</span>
)}
</div>
<div>
<p className="leading-tight">{user?.bio || user?.about}</p>

View File

@@ -2,9 +2,8 @@ import * as Dialog from '@radix-ui/react-dialog';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { User } from '@app/auth/components/user';
import { CancelIcon, StrangersIcon } from '@shared/icons';
import { User } from '@shared/user';
import { compactNumber } from '@utils/number';
@@ -35,7 +34,7 @@ export function UnknownsModal({ data }: { data: string[] }) {
</button>
</Dialog.Trigger>
<Dialog.Portal className="relative z-10">
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xl" />
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-2xl" />
<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 rounded-xl bg-white/10 backdrop-blur-xl">
<div className="h-min w-full shrink-0 border-b border-white/10 bg-white/5 px-5 py-5">
@@ -44,7 +43,7 @@ export function UnknownsModal({ data }: { data: string[] }) {
<Dialog.Title className="text-lg font-semibold leading-none text-white">
{data.length} unknowns
</Dialog.Title>
<Dialog.Close className="inline-flex h-6 w-6 items-center justify-center rounded-md backdrop-blur-xl hover:bg-white/10">
<Dialog.Close className="inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-white/10">
<CancelIcon className="h-4 w-4 text-white/50" />
</Dialog.Close>
</div>
@@ -57,14 +56,14 @@ export function UnknownsModal({ data }: { data: string[] }) {
{data.map((pubkey) => (
<div
key={pubkey}
className="group flex items-center justify-between px-4 py-2 backdrop-blur-xl hover:bg-white/10"
className="group flex items-center justify-between px-4 py-2 hover:bg-white/10"
>
<User pubkey={pubkey} />
<User pubkey={pubkey} variant="simple" />
<div>
<button
type="button"
onClick={() => openChat(pubkey)}
className="hidden w-max rounded bg-white/10 px-3 py-1 text-sm font-medium backdrop-blur-xl hover:bg-fuchsia-500 group-hover:inline-flex"
className="hidden w-max rounded bg-white/10 px-3 py-1 text-sm font-medium hover:bg-fuchsia-500 group-hover:inline-flex"
>
Chat
</button>

View File

@@ -1,40 +1,53 @@
import { useEffect, useState } from 'react';
import { useLocation, useRouteError } from 'react-router-dom';
interface IRouteError {
import { Frame } from '@shared/frame';
interface RouteError {
statusText: string;
message: string;
}
interface IDebugInfo {
interface DebugInfo {
os: null | string;
version: null | string;
appDir: null | string;
}
export function ErrorScreen() {
const error = useRouteError() as IRouteError;
const error = useRouteError() as RouteError;
const location = useLocation();
const [debugInfo, setDebugInfo] = useState<IDebugInfo>({ os: null, version: null });
const [debugInfo, setDebugInfo] = useState<DebugInfo>({
os: null,
version: null,
appDir: null,
});
useEffect(() => {
async function getInformation() {
const { platform, version } = await import('@tauri-apps/plugin-os');
const { getVersion } = await import('@tauri-apps/plugin-app');
const { platform, version } = await import('@tauri-apps/api/os');
const { getVersion } = await import('@tauri-apps/api/app');
const { appConfigDir } = await import('@tauri-apps/api/path');
const platformName = await platform();
const osVersion = await version();
const appVersion = await getVersion();
const appDir = await appConfigDir();
setDebugInfo({ os: platformName + ' ' + osVersion, version: appVersion });
setDebugInfo({
os: platformName + ' ' + osVersion,
version: appVersion,
appDir: appDir,
});
}
getInformation();
}, []);
return (
<div className="flex h-full items-center justify-center bg-black/90 backdrop-blur-xl">
<div className="flex max-w-lg flex-col gap-4">
<Frame className="flex h-full items-center justify-center">
<div className="flex w-full flex-col gap-4 px-4 md:max-w-lg md:px-0">
<div className="flex flex-col">
<h1 className="mb-1 text-2xl font-semibold text-white">
Sorry, an unexpected error has occurred.
@@ -57,24 +70,24 @@ export function ErrorScreen() {
href="https://github.com/luminous-devs/lume/issues/new"
target="_blank"
rel="noreferrer"
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-white/10 text-sm font-medium text-white backdrop-blur-xl hover:bg-white/20"
className="inline-flex h-11 w-full items-center justify-center rounded-lg text-sm font-medium text-white backdrop-blur-xl hover:bg-white/10"
>
Click here to report the issue on GitHub
</a>
<button
type="button"
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-white/10 text-sm font-medium text-white backdrop-blur-xl hover:bg-white/20"
className="inline-flex h-11 w-full items-center justify-center rounded-lg text-sm font-medium text-white backdrop-blur-xl hover:bg-white/10"
>
Reload app
</button>
<button
type="button"
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-white/10 text-sm font-medium text-white backdrop-blur-xl hover:bg-white/20"
className="inline-flex h-11 w-full items-center justify-center rounded-lg text-sm font-medium text-white backdrop-blur-xl hover:bg-white/10"
>
Reset app
</button>
</div>
</div>
</div>
</Frame>
);
}

View File

@@ -1,7 +1,7 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
import { writeText } from '@tauri-apps/api/clipboard';
import { nip19 } from 'nostr-tools';
import { EventPointer } from 'nostr-tools/lib/nip19';
import { AddressPointer, EventPointer } from 'nostr-tools/lib/nip19';
import { useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
@@ -13,11 +13,11 @@ import {
NoteActions,
NoteReplyForm,
NoteStats,
ThreadUser,
UnknownNote,
} from '@shared/notes';
import { RepliesList } from '@shared/notes/replies/list';
import { NoteSkeleton } from '@shared/notes/skeleton';
import { User } from '@shared/user';
import { useEvent } from '@utils/hooks/useEvent';
@@ -27,13 +27,15 @@ export function ArticleNoteScreen() {
const { id } = useParams();
const { db } = useStorage();
const { status, data } = useEvent(id);
const naddr = id.startsWith('naddr') ? (nip19.decode(id).data as AddressPointer) : null;
const { status, data } = useEvent(id, naddr);
const [isCopy, setIsCopy] = useState(false);
const share = async () => {
await writeText(
'https://nostr.com/' +
'https://njump.me/' +
nip19.neventEncode({ id: data.id, author: data.pubkey } as EventPointer)
);
// update state
@@ -98,21 +100,27 @@ export function ArticleNoteScreen() {
</div>
</div>
) : (
<div className="h-min w-full px-3">
<div className="rounded-xl bg-white/10 px-3 pt-3 backdrop-blur-xl">
<ThreadUser pubkey={data.pubkey} time={data.created_at} />
<div className="mt-2">{renderKind(data)}</div>
<div>
<NoteActions id={id} pubkey={data.pubkey} extraButtons={false} />
<NoteStats id={id} />
<>
<div className="h-min w-full px-3">
<div className="rounded-xl border-t border-white/10 bg-white/20 px-3 pt-3">
<User pubkey={data.pubkey} time={data.created_at} variant="thread" />
<div className="mt-2">{renderKind(data)}</div>
<div>
<NoteActions
id={data.id}
pubkey={data.pubkey}
extraButtons={false}
/>
<NoteStats id={data.id} />
</div>
</div>
</div>
</div>
<div ref={replyRef} className="px-3">
<NoteReplyForm id={data.id} pubkey={db.account.pubkey} />
<RepliesList id={data.id} />
</div>
</>
)}
<div ref={replyRef} className="px-3">
<NoteReplyForm id={id} pubkey={db.account.pubkey} />
<RepliesList id={id} />
</div>
</div>
<div className="col-span-1" />
</div>

View File

@@ -1,5 +1,5 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
import { writeText } from '@tauri-apps/api/clipboard';
import { nip19 } from 'nostr-tools';
import { EventPointer } from 'nostr-tools/lib/nip19';
import { useRef, useState } from 'react';
@@ -15,11 +15,11 @@ import {
NoteReplyForm,
NoteStats,
TextNote,
ThreadUser,
UnknownNote,
} from '@shared/notes';
import { RepliesList } from '@shared/notes/replies/list';
import { NoteSkeleton } from '@shared/notes/skeleton';
import { User } from '@shared/user';
import { useEvent } from '@utils/hooks/useEvent';
@@ -35,7 +35,7 @@ export function TextNoteScreen() {
const share = async () => {
await writeText(
'https://nostr.com/' +
'https://njump.me/' +
nip19.neventEncode({ id: data.id, author: data.pubkey } as EventPointer)
);
// update state
@@ -51,7 +51,7 @@ export function TextNoteScreen() {
const renderKind = (event: NDKEvent) => {
switch (event.kind) {
case NDKKind.Text:
return <TextNote event={event} />;
return <TextNote content={event.content} />;
case NDKKind.Article:
return <ArticleNote event={event} />;
case 1063:
@@ -106,7 +106,7 @@ export function TextNoteScreen() {
) : (
<div className="h-min w-full px-3">
<div className="rounded-xl bg-white/10 px-3 pt-3 backdrop-blur-xl">
<ThreadUser pubkey={data.pubkey} time={data.created_at} />
<User pubkey={data.pubkey} time={data.created_at} variant="thread" />
<div className="mt-2">{renderKind(data)}</div>
<div>
<NoteActions id={id} pubkey={data.pubkey} extraButtons={false} />

View File

@@ -1,32 +1,25 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { NotiContent } from '@app/notifications/components/content';
import { NotiUser } from '@app/notifications/components/user';
import { formatCreatedAt } from '@utils/createdAt';
import { parser } from '@utils/parser';
export function NotiMention({ event }: { event: NDKEvent }) {
const createdAt = formatCreatedAt(event.created_at);
const content = useMemo(() => parser(event), [event]);
const rootId = event.tags.find((el) => el[0])?.[1];
return (
<div className="h-min w-full px-3 py-1.5">
<div className="relative overflow-hidden rounded-xl bg-white/10 px-3 pt-3 backdrop-blur-xl">
<div className="flex items-start justify-between">
<div className="flex items-start gap-1">
<NotiUser pubkey={event.pubkey} />
<p className="leading-none text-white/50">has reply you post · {createdAt}</p>
</div>
</div>
<div className="f- relative z-10 -mt-6 flex gap-3">
<div className="h-11 w-11 shrink-0" />
<div className="mb-2 mt-3 w-full cursor-default rounded-lg bg-white/10 px-3 py-3 backdrop-blur-xl">
<NotiContent content={content} />
</div>
<Link to={`/notes/text/${rootId}`} className="h-min w-full px-3">
<div className="group flex items-center justify-between rounded-xl px-3 py-3 hover:bg-white/10">
<div className="flex items-center gap-2">
<NotiUser pubkey={event.pubkey} />
<p className="leading-none text-white/50">has mention you · {createdAt}</p>
</div>
<span className="hidden text-sm font-semibold text-fuchsia-500 group-hover:block">
View
</span>
</div>
</div>
</Link>
);
}

View File

@@ -1,30 +1,27 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { Link } from 'react-router-dom';
import { SimpleNote } from '@app/notifications/components/simpleNote';
import { NotiUser } from '@app/notifications/components/user';
import { formatCreatedAt } from '@utils/createdAt';
export function NotiReaction({ event }: { event: NDKEvent }) {
const root = event.tags.find((e) => e[0] === 'e')?.[1];
const createdAt = formatCreatedAt(event.created_at);
const rootId = event.tags.find((el) => el[0])?.[1];
return (
<div className="h-min w-full px-3 py-1.5">
<div className="relative overflow-hidden rounded-xl bg-white/10 px-3 pt-3 backdrop-blur-xl">
<div className="flex items-start justify-between">
<div className="flex items-start gap-1">
<NotiUser pubkey={event.pubkey} />
<p className="leading-none text-white/50">
reacted {event.content} · {createdAt}
</p>
</div>
</div>
<div className="relative z-10 -mt-6 flex gap-3">
<div className="h-11 w-11 shrink-0" />
<div className="flex-1">{root && <SimpleNote id={root} />}</div>
<Link to={`/notes/text/${rootId}`} className="h-min w-full px-3">
<div className="group flex items-center justify-between rounded-xl px-3 py-3 hover:bg-white/10">
<div className="flex items-center gap-2">
<NotiUser pubkey={event.pubkey} />
<p className="leading-none text-white/50">
reacted {event.content} · {createdAt}
</p>
</div>
<span className="hidden text-sm font-semibold text-fuchsia-500 group-hover:block">
View
</span>
</div>
</div>
</Link>
);
}

View File

@@ -1,6 +1,6 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { Link } from 'react-router-dom';
import { SimpleNote } from '@app/notifications/components/simpleNote';
import { NotiUser } from '@app/notifications/components/user';
import { useStorage } from '@libs/storage/provider';
@@ -10,29 +10,24 @@ import { formatCreatedAt } from '@utils/createdAt';
export function NotiRepost({ event }: { event: NDKEvent }) {
const { db } = useStorage();
const root = event.tags.find((e) => e[0] === 'e')?.[1];
const createdAt = formatCreatedAt(event.created_at);
const rootId = event.tags.find((el) => el[0])?.[1];
return (
<div className="h-min w-full px-3 py-1.5">
<div className="relative overflow-hidden rounded-xl bg-white/10 px-3 pt-3 backdrop-blur-xl">
<div className="flex items-start justify-between">
<div className="flex items-start gap-1">
<NotiUser pubkey={event.pubkey} />
<p className="leading-none text-white/50">
repost{' '}
{event.pubkey !== db.account.pubkey
? 'a post that mention you'
: 'your post'}{' '}
· {createdAt}
</p>
</div>
</div>
<div className="relative z-10 -mt-6 flex gap-3">
<div className="h-11 w-11 shrink-0" />
<div className="flex-1">{root && <SimpleNote id={root} />}</div>
<Link to={`/notes/text/${rootId}`} className="h-min w-full px-3">
<div className="group flex items-center justify-between rounded-xl px-3 py-3 hover:bg-white/10">
<div className="flex items-center gap-2">
<NotiUser pubkey={event.pubkey} />
<p className="leading-none text-white/50">
repost{' '}
{event.pubkey !== db.account.pubkey ? 'a post that mention you' : 'your post'}{' '}
· {createdAt}
</p>
</div>
<span className="hidden text-sm font-semibold text-fuchsia-500 group-hover:block">
View
</span>
</div>
</div>
</Link>
);
}

View File

@@ -18,7 +18,7 @@ export const SimpleNote = memo(function SimpleNote({ id }: { id: string }) {
const openThread = (event, thread: string) => {
const selection = window.getSelection();
if (selection.toString().length === 0) {
setWidget(db, { kind: WidgetKinds.thread, title: 'Thread', content: thread });
setWidget(db, { kind: WidgetKinds.local.thread, title: 'Thread', content: thread });
} else {
event.stopPropagation();
}
@@ -48,7 +48,7 @@ export const SimpleNote = memo(function SimpleNote({ id }: { id: string }) {
tabIndex={0}
className="mb-2 mt-3 cursor-default rounded-lg bg-white/10 px-3 py-3 backdrop-blur-xl"
>
<User pubkey={data.pubkey} time={data.created_at} size="small" />
<User pubkey={data.pubkey} time={data.created_at} variant="mention" />
<div className="markdown">
<p>
{data.content.length > 200

View File

@@ -18,14 +18,14 @@ export function NotiUser({ pubkey }: { pubkey: string }) {
}
return (
<div className="flex shrink-0 items-start justify-start gap-3">
<div className="flex shrink-0 items-center justify-start gap-2">
<Image
src={user?.picture || user?.image}
alt={pubkey}
className="h-11 w-11 shrink-0 rounded-lg object-cover"
className="h-8 w-8 shrink-0 rounded-md object-cover"
/>
<span className="max-w-[10rem] flex-1 truncate font-medium leading-none text-white">
{user?.nip05 || user?.name || user?.display_name || displayNpub(pubkey, 16)}
<span className="max-w-[10rem] truncate font-medium leading-none text-white">
{user?.name || user?.display_name || displayNpub(pubkey, 16)}
</span>
</div>
);

View File

@@ -1,6 +1,5 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query';
import { useCallback } from 'react';
import { useCallback, useEffect } from 'react';
import { NotiMention } from '@app/notifications/components/mention';
import { NotiReaction } from '@app/notifications/components/reaction';
@@ -11,18 +10,19 @@ import { useStorage } from '@libs/storage/provider';
import { LoaderIcon } from '@shared/icons';
import { TitleBar } from '@shared/titleBar';
import { useActivities } from '@stores/activities';
import { useNostr } from '@utils/hooks/useNostr';
export function NotificationScreen() {
const { db } = useStorage();
const { fetchActivities } = useNostr();
const { status, data } = useQuery(
['notifications', db.account.pubkey],
async () => {
return await fetchActivities();
},
{ refetchOnWindowFocus: false }
);
const [activities, setActivities, clearTotalNewActivities] = useActivities((state) => [
state.activities,
state.setActivities,
state.clearTotalNewActivities,
]);
const renderItem = useCallback(
(event: NDKEvent) => {
@@ -37,34 +37,44 @@ export function NotificationScreen() {
return null;
}
},
[data]
[activities]
);
useEffect(() => {
async function getActivities() {
const events = await fetchActivities();
setActivities(events, db.account.last_login_at);
}
getActivities();
// clear total new activities
clearTotalNewActivities();
}, []);
return (
<div className="scrollbar-hide h-full w-full overflow-y-auto bg-white/10 backdrop-blur-xl">
<div className="grid h-full grid-cols-3">
<div className="col-span-2 flex flex-col border-r border-white/5">
<TitleBar title="Activities in the last 24 hours" />
<div className="flex h-full flex-col gap-1.5">
<div className="flex h-full flex-col">
{status === 'loading' ? (
<div className="flex h-full w-full items-center justify-center">
<div className="flex flex-col items-center gap-1.5">
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
<p className="text-sm font-medium text-white/50">Loading</p>
</div>
<div className="flex h-full flex-col">
{!activities ? (
<div className="flex h-full w-full items-center justify-center">
<div className="flex flex-col items-center gap-1.5">
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
<p className="text-sm font-medium text-white/50">Loading</p>
</div>
) : data?.length < 1 ? (
<div className="flex h-full w-full flex-col items-center justify-center">
<p className="mb-1 text-4xl">🎉</p>
<p className="font-medium text-white/50">
Yo!, no new activities around you in the last 24 hours
</p>
</div>
) : (
data.map((event) => renderItem(event))
)}
</div>
</div>
) : activities.length <= 1 ? (
<div className="flex h-full w-full flex-col items-center justify-center">
<p className="mb-1 text-4xl">🎉</p>
<p className="font-medium text-white/50">
Yo!, no new activities around you in the last 24 hours
</p>
</div>
) : (
activities.map((event) => renderItem(event))
)}
</div>
</div>
</div>

View File

@@ -0,0 +1,152 @@
import { webln } from '@getalby/sdk';
import * as Dialog from '@radix-ui/react-dialog';
import { message } from '@tauri-apps/api/dialog';
import { WebviewWindow } from '@tauri-apps/api/window';
import { useState } from 'react';
import { useStorage } from '@libs/storage/provider';
import {
AlbyIcon,
ArrowRightCircleIcon,
CancelIcon,
CheckCircleIcon,
LoaderIcon,
} from '@shared/icons';
import { useStronghold } from '@stores/stronghold';
export function NWCAlby() {
const { db } = useStorage();
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsloading] = useState(false);
const [isConnected, setIsConnected] = useState(false);
const setWalletConnectURL = useStronghold((state) => state.setWalletConnectURL);
const initAlby = async () => {
try {
setIsloading(true);
const provider = webln.NostrWebLNProvider.withNewSecret();
const walletConnectURL = provider.getNostrWalletConnectUrl(true);
// get auth url
const authURL = provider.getAuthorizationUrl({ name: 'Lume' });
// open auth window
const webview = new WebviewWindow('alby', {
title: 'Connect Alby',
url: authURL.href,
center: true,
width: 400,
height: 650,
});
webview.listen('tauri://close-requested', async () => {
await db.secureSave('walletConnectURL', walletConnectURL, 'nwc');
setWalletConnectURL(walletConnectURL);
setIsConnected(true);
setIsloading(false);
});
} catch (e) {
setIsloading(false);
await message(e.toString(), { title: 'Connect Alby', type: 'error' });
}
};
return (
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
<div className="flex items-center justify-between">
<div className="inline-flex items-center gap-2.5">
<div className="inline-flex h-11 w-11 items-center justify-center rounded-md bg-orange-200">
<AlbyIcon className="h-8 w-8" />
</div>
<div>
<h5 className="font-semibold leading-tight text-white">Alby</h5>
<p className="text-sm leading-tight text-white/50">Require alby account</p>
</div>
</div>
<Dialog.Trigger asChild>
<button
type="button"
className="inline-flex h-9 w-min items-center justify-center rounded-md border-t border-white/10 bg-white/20 px-3 text-sm font-medium text-white hover:bg-green-500"
>
Connect
</button>
</Dialog.Trigger>
</div>
<Dialog.Portal className="relative z-10">
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-2xl" />
<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 rounded-xl bg-white/10 backdrop-blur-xl">
<div className="h-min w-full shrink-0 rounded-t-xl border-b border-white/10 bg-white/5 px-5 py-5">
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<Dialog.Title className="text-lg font-semibold leading-none text-white">
Alby integration (Beta)
</Dialog.Title>
<Dialog.Close className="inline-flex h-6 w-6 items-center justify-center rounded-md backdrop-blur-xl hover:bg-white/10">
<CancelIcon className="h-4 w-4 text-white/50" />
</Dialog.Close>
</div>
</div>
</div>
<div className="flex flex-col gap-3 px-5 py-5">
<div className="relative flex h-40 items-center justify-center gap-4">
<div className="inline-flex h-16 w-16 items-end justify-center rounded-lg bg-black pb-2">
<img src="/lume.png" className="w-1/3" alt="Lume Logo" />
</div>
<div className="w-20 border border-dashed border-white/5" />
<div className="inline-flex h-16 w-16 items-center justify-center rounded-lg bg-white">
<AlbyIcon className="h-8 w-8" />
</div>
{isConnected ? (
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform">
<CheckCircleIcon className="h-5 w-5 text-green-500" />
</div>
) : null}
</div>
<div className="flex flex-col gap-2">
<p className="text-sm text-white/50">
When you click &quot;Connect&quot;, a new window will open and you need
to click the &quot;Connect Wallet&quot; button to grant Lume permission
to integrate with your Alby account.
</p>
<p className="text-sm text-white/50">
All information will be encrypted and stored on the local machine.
</p>
</div>
<button
type="button"
onClick={() => initAlby()}
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none disabled:opacity-50"
>
{isLoading ? (
<>
<span className="w-5" />
<span>Connecting...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
</>
) : isConnected ? (
<>
<span className="w-5" />
<span>Connected</span>
<CheckCircleIcon className="h-5 w-5" />
</>
) : (
<>
<span className="w-5" />
<span>Connect</span>
<ArrowRightCircleIcon className="h-5 w-5" />
</>
)}
</button>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -0,0 +1,168 @@
import * as Dialog from '@radix-ui/react-dialog';
import { useState } from 'react';
import { Resolver, useForm } from 'react-hook-form';
import { useStorage } from '@libs/storage/provider';
import { ArrowRightCircleIcon, CancelIcon, LoaderIcon, WorldIcon } from '@shared/icons';
import { useStronghold } from '@stores/stronghold';
type FormValues = {
uri: string;
};
const resolver: Resolver<FormValues> = async (values) => {
return {
values: values.uri ? values : {},
errors: !values.uri
? {
uri: {
type: 'required',
message: 'This is required.',
},
}
: {},
};
};
export function NWCOther() {
const { db } = useStorage();
const {
register,
setError,
handleSubmit,
formState: { errors, isDirty, isValid },
} = useForm<FormValues>({ resolver });
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsloading] = useState(false);
const setWalletConnectURL = useStronghold((state) => state.setWalletConnectURL);
const onSubmit = async (data: { [x: string]: string }) => {
try {
if (!data.uri.startsWith('nostr+walletconnect:')) {
setError('uri', {
type: 'custom',
message:
'Connect URI is required and must start with format nostr+walletconnect:, please check again',
});
return;
}
setIsloading(true);
const uriObj = new URL(data.uri);
const params = new URLSearchParams(uriObj.search);
if (params.has('relay') && params.has('secret')) {
await db.secureSave('walletConnectURL', data.uri, 'nwc');
setWalletConnectURL(data.uri);
setIsloading(false);
setIsOpen(false);
}
} catch (e) {
setIsloading(false);
setError('uri', {
type: 'custom',
message:
'Connect URI is required and must start with format nostr+walletconnect:, please check again',
});
}
};
return (
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
<div className="flex items-center justify-between pt-4">
<div className="inline-flex items-center gap-2.5">
<div className="inline-flex h-11 w-11 items-center justify-center rounded-md bg-white/10">
<WorldIcon className="h-5 w-5" />
</div>
<div>
<h5 className="font-semibold leading-tight text-white">URI String</h5>
<p className="text-sm leading-tight text-white/50">
Using format nostr+walletconnect:
</p>
</div>
</div>
<Dialog.Trigger asChild>
<button
type="button"
className="inline-flex h-9 w-min items-center justify-center rounded-md border-t border-white/10 bg-white/20 px-3 text-sm font-medium text-white hover:bg-green-500"
>
Connect
</button>
</Dialog.Trigger>
</div>
<Dialog.Portal className="relative z-10">
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-2xl" />
<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 rounded-xl bg-white/10 backdrop-blur-xl">
<div className="h-min w-full shrink-0 rounded-t-xl border-b border-white/10 bg-white/5 px-5 py-5">
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<Dialog.Title className="text-lg font-semibold leading-none text-white">
Nostr Wallet Connect
</Dialog.Title>
<Dialog.Close className="inline-flex h-6 w-6 items-center justify-center rounded-md backdrop-blur-xl hover:bg-white/10">
<CancelIcon className="h-4 w-4 text-white/50" />
</Dialog.Close>
</div>
</div>
</div>
<form
onSubmit={handleSubmit(onSubmit)}
className="mb-0 flex flex-col gap-3 px-5 py-5"
>
<div className="flex flex-col gap-1">
<label
htmlFor="uri"
className="text-sm font-semibold uppercase tracking-wider text-white/50"
>
Connect URI
</label>
<input
{...register('uri', { required: true })}
placeholder="nostr+walletconnect:"
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
className="relative h-11 w-full rounded-lg bg-white/10 px-3 py-1 text-white !outline-none backdrop-blur-xl placeholder:text-white/50"
/>
<span className="text-sm text-red-400">
{errors.uri && <p>{errors.uri.message}</p>}
</span>
</div>
<div className="flex flex-col gap-1 text-center">
<button
type="submit"
disabled={!isDirty || !isValid}
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none disabled:opacity-50"
>
{isLoading ? (
<>
<span className="w-5" />
<span>Connecting...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
</>
) : (
<>
<span className="w-5" />
<span>Connect</span>
<ArrowRightCircleIcon className="h-5 w-5" />
</>
)}
</button>
<span className="text-sm text-white/50">
All information will be encrypted and stored on the local machine.
</span>
</div>
</form>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

124
src/app/nwc/index.tsx Normal file
View File

@@ -0,0 +1,124 @@
import { NWCAlby } from '@app/nwc/components/alby';
import { NWCOther } from '@app/nwc/components/other';
import { useStorage } from '@libs/storage/provider';
import { CheckCircleIcon } from '@shared/icons';
import { useStronghold } from '@stores/stronghold';
export function NWCScreen() {
const { db } = useStorage();
const [walletConnectURL, setWalletConnectURL] = useStronghold((state) => [
state.walletConnectURL,
state.setWalletConnectURL,
]);
const remove = async () => {
setWalletConnectURL('');
await db.secureSave('walletConnectURL', '', 'nwc');
};
return (
<div className="flex h-full w-full items-center justify-center">
<div className="flex w-full flex-col gap-5">
<div className="text-center">
<h3 className="text-2xl font-bold leading-tight">
Nostr Wallet Connect (Beta)
</h3>
<p className="leading-tight text-white/70">
Sending tips easily via Bitcoin Lightning.
</p>
</div>
<div className="mx-auto max-w-lg">
{!walletConnectURL ? (
<div className="flex w-full flex-col gap-4 divide-y divide-white/5 rounded-xl border-t border-white/10 bg-white/20 p-3">
<NWCAlby />
<NWCOther />
</div>
) : (
<div className="flex w-full flex-col rounded-xl border-t border-white/10 bg-white/20 p-3">
<div className="mb-1 inline-flex items-center gap-1.5 text-sm text-green-500">
<CheckCircleIcon className="h-4 w-4" />
<p>You&apos;re using nostr wallet connect</p>
</div>
<div className="flex flex-col gap-2">
<textarea
readOnly
value={walletConnectURL.substring(0, 120) + '****'}
className="relative h-40 w-full resize-none rounded-lg bg-white/20 px-3 py-1 text-white !outline-none backdrop-blur-xl placeholder:text-white/50"
/>
<button
type="button"
onClick={() => remove()}
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg bg-white/10 px-6 font-medium leading-none text-red-500 hover:bg-white/20 focus:outline-none disabled:opacity-50"
>
Remove connection
</button>
</div>
</div>
)}
<div className="mt-5 flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<h5 className="text-sm font-bold text-white">Introduction</h5>
<p className="text-sm text-white/70">
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-sm text-white/70">
To learn more about the details have a look at{' '}
<a
href="https://github.com/getAlby/nips/blob/7-wallet-connect-patch/47.md"
target="_blank"
className="text-fuchsia-300"
rel="noreferrer"
>
the specs (NIP47)
</a>
</p>
</div>
<div className="flex flex-col gap-1.5">
<h5 className="text-sm font-bold text-white">About tipping</h5>
<p className="text-sm text-white/70">
Also known as Zap in other Nostr client.
</p>
<p className="text-sm text-white/70">
Lume doesn&apos;t take any commission or platform fees when you tip
someone.
</p>
<p className="text-sm text-white/70">Lume doesn&apos;t hold your Bitcoin</p>
</div>
<div className="flex flex-col gap-1.5">
<h5 className="text-sm font-bold text-white">
Recommend wallet that support NWC
</h5>
<p className="text-sm text-white/70">
Mutiny Wallet:{' '}
<a
href="https://www.mutinywallet.com/"
target="_blank"
rel="noreferrer"
className="text-fuchsia-300"
>
website
</a>
</p>
<p className="text-sm text-white/70">
Self hosted NWC on Umbrel :{' '}
<a
href="https://apps.umbrel.com/app/alby-nostr-wallet-connect"
target="_blank"
rel="noreferrer"
className="text-fuchsia-300"
>
website
</a>
</p>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { getVersion } from '@tauri-apps/plugin-app';
import { getVersion } from '@tauri-apps/api/app';
import { useEffect, useState } from 'react';
export function VersionSetting() {

View File

@@ -2,9 +2,18 @@ import { useCallback } from 'react';
import { useStorage } from '@libs/storage/provider';
import {
ArticleIcon,
FileIcon,
FollowsIcon,
GroupFeedsIcon,
HashtagIcon,
ThreadsIcon,
TrendingIcon,
} from '@shared/icons';
import { TitleBar } from '@shared/titleBar';
import { DefaultWidgets, useWidgets } from '@stores/widgets';
import { DefaultWidgets, WidgetKinds, useWidgets } from '@stores/widgets';
import { Widget, WidgetGroup, WidgetGroupItem } from '@utils/types';
@@ -20,16 +29,63 @@ export function WidgetList({ params }: { params: Widget }) {
removeWidget(db, params.id);
};
const renderIcon = useCallback(
(kind: number) => {
switch (kind) {
case WidgetKinds.tmp.xfeed:
return <GroupFeedsIcon className="h-5 w-5 text-white" />;
case WidgetKinds.local.follows:
return <FollowsIcon className="h-5 w-5 text-white" />;
case WidgetKinds.local.files:
case WidgetKinds.global.files:
return <FileIcon className="h-5 w-5 text-white" />;
case WidgetKinds.local.articles:
case WidgetKinds.global.articles:
return <ArticleIcon className="h-5 w-5 text-white" />;
case WidgetKinds.tmp.xhashtag:
return <HashtagIcon className="h-5 w-4 text-white" />;
case WidgetKinds.nostrBand.trendingAccounts:
case WidgetKinds.nostrBand.trendingNotes:
return <TrendingIcon className="h-5 w-4 text-white" />;
case WidgetKinds.other.learnNostr:
return <ThreadsIcon className="h-5 w-4 text-white" />;
default:
return null;
}
},
[DefaultWidgets]
);
const renderItem = useCallback(
(row: WidgetGroup) => {
return (
<div className="flex flex-col gap-3">
<h3 className="font-medium text-white/50">{row.title}</h3>
<div className="grid grid-cols-3 gap-6">
<div className="flex flex-col divide-y divide-white/5 overflow-hidden rounded-xl bg-white/10">
{row.data.map((item, index) => (
<button onClick={() => openWidget(item)} key={index}>
<div className="inline-flex aspect-square h-full w-full transform-gpu flex-col items-center justify-center gap-2.5 rounded-2xl bg-white/5 hover:bg-white/10">
<h5 className="text-sm font-medium">{item.title}</h5>
<button
onClick={() => openWidget(item)}
key={index}
className="flex items-center gap-2.5 px-4 hover:bg-white/10"
>
{item.icon ? (
<div className="h-10 w-10 shrink-0 rounded-md">
<img
src={item.icon}
alt={item.title}
className="h-10 w-10 object-cover"
/>
</div>
) : (
<div className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-white/10">
{renderIcon(item.kind)}
</div>
)}
<div className="inline-flex h-16 w-full flex-col items-start justify-center gap-1">
<h5 className="line-clamp-1 font-medium leading-none">{item.title}</h5>
<p className="line-clamp-1 text-xs leading-none text-white/50">
{item.description}
</p>
</div>
</button>
))}
@@ -41,10 +97,26 @@ export function WidgetList({ params }: { params: Widget }) {
);
return (
<div className="relative h-full shrink-0 grow-0 basis-[400px] overflow-hidden">
<div className="relative h-full shrink-0 grow-0 basis-[400px] bg-white/10">
<TitleBar id={params.id} title="Add widget" />
<div className="flex flex-col gap-8 px-3">
{DefaultWidgets.map((row: WidgetGroup) => renderItem(row))}
<div className="scrollbar-hide h-full overflow-y-auto pb-20">
<div className="flex flex-col gap-6 px-3">
{DefaultWidgets.map((row: WidgetGroup) => renderItem(row))}
<div className="border-t border-white/5 pt-6">
<button
type="button"
disabled
className="inline-flex h-14 w-full items-center justify-center gap-2.5 rounded-xl bg-white/5 text-sm font-medium text-white/50"
>
Build your own widget{' '}
<div className="-rotate-3 transform rounded-md border border-white/20 bg-white/10 px-1.5 py-1">
<span className="bg-gradient-to-t from-fuchsia-200 via-red-200 to-orange-300 bg-clip-text text-xs text-transparent">
Coming soon
</span>
</div>
</button>
</div>
</div>
</div>
</div>
);

View File

@@ -10,9 +10,11 @@ import {
GlobalArticlesWidget,
GlobalFilesWidget,
GlobalHashtagWidget,
LearnNostrWidget,
LocalArticlesWidget,
LocalFeedsWidget,
LocalFilesWidget,
LocalFollowsWidget,
LocalNetworkWidget,
LocalThreadWidget,
LocalUserWidget,
@@ -40,6 +42,8 @@ export function SpaceScreen() {
switch (widget.kind) {
case WidgetKinds.local.network:
return <LocalNetworkWidget key={widget.id} />;
case WidgetKinds.local.follows:
return <LocalFollowsWidget key={widget.id} params={widget} />;
case WidgetKinds.local.feeds:
return <LocalFeedsWidget key={widget.id} params={widget} />;
case WidgetKinds.local.files:
@@ -66,8 +70,10 @@ export function SpaceScreen() {
return <XfeedsWidget key={widget.id} params={widget} />;
case WidgetKinds.tmp.list:
return <WidgetList key={widget.id} params={widget} />;
case WidgetKinds.other.learnNostr:
return <LearnNostrWidget key={widget.id} params={widget} />;
default:
break;
return null;
}
},
[widgets]

View File

@@ -1,3 +1,4 @@
import { message } from '@tauri-apps/api/dialog';
import { invoke } from '@tauri-apps/api/tauri';
import { useEffect, useState } from 'react';
@@ -10,48 +11,53 @@ import { useNostr } from '@utils/hooks/useNostr';
export function SplashScreen() {
const { db } = useStorage();
const { ndk, relayUrls } = useNDK();
const { ndk } = useNDK();
const { fetchUserData, prefetchEvents } = useNostr();
const [isLoading, setIsLoading] = useState<boolean>(true);
const [errorMessage, setErrorMessage] = useState<null | string>(null);
const skip = async () => {
await invoke('close_splashscreen');
};
const prefetch = async () => {
const onboarding = localStorage.getItem('onboarding');
const step = JSON.parse(onboarding).state.step || null;
if (step) await invoke('close_splashscreen');
try {
const user = await fetchUserData();
const data = await prefetchEvents();
const [user, events] = await Promise.all([fetchUserData(), prefetchEvents()]);
if (user.status === 'ok' && data.status === 'ok') {
if (user.status === 'ok' && events.status === 'ok') {
// update last login = current time
await db.updateLastLogin();
// close splash screen and open main app screen
await invoke('close_splashscreen');
} else {
setIsLoading(false);
setErrorMessage(user.message);
console.log('fetch failed, error: ', user.message);
}
} catch (e) {
setIsLoading(false);
setErrorMessage(e);
console.log('prefetch failed, error: ', e);
await message(e, {
title: 'An unexpected error has occurred',
type: 'error',
});
}
};
useEffect(() => {
if (!db.account) {
invoke('close_splashscreen');
async function initial() {
if (!db.account) {
await invoke('close_splashscreen');
} else {
const onboarding = localStorage.getItem('onboarding');
const step = JSON.parse(onboarding).state.step || null;
if (step) {
await invoke('close_splashscreen');
} else {
console.log('prefetching...');
prefetch();
}
}
}
if (ndk && db.account) {
console.log('prefetching...');
prefetch();
if (ndk) {
initial();
}
}, [ndk, db.account]);
@@ -64,9 +70,7 @@ export function SplashScreen() {
{isLoading ? (
<div className="flex flex-col gap-1 text-center">
<h3 className="text-lg font-semibold leading-none text-white">
{!ndk
? 'Connecting to relay...'
: `Connected to ${relayUrls.length} relays`}
{!ndk ? 'Connecting to relay...' : 'Fetching events from the last login.'}
</h3>
<p className="text-sm text-white/50">
This may take a few seconds, please don&apos;t close app.
@@ -75,15 +79,14 @@ export function SplashScreen() {
) : (
<div className="mt-2 flex flex-col gap-1 text-center">
<h3 className="text-lg font-semibold leading-none text-white">
Something wrong!
An unexpected error has occurred
</h3>
<p className="text-sm text-white/50">{errorMessage}</p>
<button
type="button"
onClick={skip}
className="mx-auto mt-4 inline-flex h-10 w-max items-center justify-center rounded-md bg-white/10 px-8 text-sm font-medium leading-none text-white backdrop-blur-xl hover:bg-white/20"
>
Skip
Skip this step
</button>
</div>
)}

View File

@@ -1,6 +1,7 @@
import { NDKEvent, NDKUserProfile } from '@nostr-dev-kit/ndk';
import * as Dialog from '@radix-ui/react-dialog';
import { useQueryClient } from '@tanstack/react-query';
import { fetch } from '@tauri-apps/api/http';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
@@ -13,6 +14,12 @@ import { Image } from '@shared/image';
import { useNostr } from '@utils/hooks/useNostr';
interface NIP05 {
names: {
[key: string]: string;
};
}
export function EditProfileModal() {
const queryClient = useQueryClient();
@@ -47,19 +54,26 @@ export function EditProfileModal() {
});
const verifyNIP05 = async (nip05: string) => {
const url = nip05.split('@');
const username = url[0];
const service = url[1];
const verifyURL = `https://${service}/.well-known/nostr.json?name=${username}`;
const localPath = nip05.split('@')[0];
const service = nip05.split('@')[1];
const verifyURL = `https://${service}/.well-known/nostr.json?name=${localPath}`;
const res = await fetch(verifyURL, {
method: 'GET',
timeout: 10,
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
});
console.log(res);
return true;
if (!res.ok) throw new Error(`Failed to fetch NIP-05 service: ${nip05}`);
const data = res.data as NIP05;
if (data.names) {
if (data.names[localPath] !== db.account.pubkey) return false;
return true;
}
return false;
};
const onSubmit = async (data: NDKUserProfile) => {
@@ -131,7 +145,7 @@ export function EditProfileModal() {
</button>
</Dialog.Trigger>
<Dialog.Portal className="relative z-10">
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xl" />
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-2xl" />
<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 rounded-xl bg-white/10 backdrop-blur-xl">
<div className="h-min w-full shrink-0 border-b border-white/10 bg-white/5 px-5 py-5">
@@ -275,7 +289,7 @@ export function EditProfileModal() {
<button
type="submit"
disabled={!isValid}
className="inline-flex h-11 w-full transform items-center justify-center gap-1 rounded-md bg-fuchsia-500 font-medium text-white hover:bg-fuchsia-600 focus:outline-none active:translate-y-1 disabled:pointer-events-none disabled:opacity-50"
className="inline-flex h-11 w-full transform items-center justify-center gap-1 rounded-lg bg-fuchsia-500 font-medium text-white hover:bg-fuchsia-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" />

View File

@@ -7,10 +7,11 @@ import { UserStats } from '@app/users/components/stats';
import { useStorage } from '@libs/storage/provider';
import { Image } from '@shared/image';
import { NIP05 } from '@shared/nip05';
import { useNostr } from '@utils/hooks/useNostr';
import { useProfile } from '@utils/hooks/useProfile';
import { shortenKey } from '@utils/shortenKey';
import { displayNpub } from '@utils/shortenKey';
export function UserProfile({ pubkey }: { pubkey: string }) {
const { db } = useStorage();
@@ -57,7 +58,7 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
className="h-full w-full object-cover"
/>
) : (
<div className="h-full w-full bg-black/50" />
<div className="h-full w-full bg-black" />
)}
</div>
<div className="-mt-7 flex w-full flex-col items-center px-5">
@@ -68,13 +69,21 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
/>
<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 gap-1.5">
<div className="inline-flex flex-col items-center gap-1.5">
<h5 className="text-center text-xl font-semibold leading-none">
{user.display_name || user.displayName || user.name || 'No name'}
</h5>
<span className="max-w-[15rem] truncate text-center leading-none text-white/50">
{user.nip05 || user.username || shortenKey(pubkey)}
</span>
{user.nip05 ? (
<NIP05
pubkey={pubkey}
nip05={user?.nip05}
className="max-w-[15rem] truncate text-sm leading-none text-white/50"
/>
) : (
<span className="max-w-[15rem] truncate text-sm leading-none text-white/50">
{displayNpub(pubkey, 16)}
</span>
)}
</div>
<div className="flex flex-col gap-6">
{user.about || user.bio ? (
@@ -82,7 +91,7 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
{user.about || user.bio}
</p>
) : (
<></>
<div />
)}
<UserStats pubkey={pubkey} />
</div>

View File

@@ -56,7 +56,7 @@ export function UserScreen() {
ref={virtualizer.measureElement}
>
<NoteWrapper event={event}>
<TextNote event={event} />
<TextNote content={event.content} />
</NoteWrapper>
</div>
);

View File

@@ -2,20 +2,21 @@
@tailwind components;
@tailwind utilities;
html {
font-size: 14px;
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
a {
@apply cursor-default no-underline !important;
}
button {
@apply cursor-default focus:outline-none;
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.markdown {
@apply prose prose-white max-w-none select-text hyphens-auto text-white prose-p:mb-2 prose-p:mt-0 prose-p:break-words prose-p:[word-break:break-word] prose-p:last:mb-0 prose-a:break-words prose-a:break-all prose-a:font-normal hover:prose-a:text-fuchsia-500 prose-blockquote:mb-1 prose-blockquote:mt-1 prose-blockquote:border-l-[2px] prose-blockquote:border-fuchsia-500 prose-blockquote:pl-2 prose-pre:whitespace-pre-wrap prose-pre:break-words prose-pre:break-all prose-ol:m-0 prose-ol:mb-1 prose-ul:mb-1 prose-ul:mt-1 prose-img:mb-2 prose-img:mt-3 prose-hr:mx-0 prose-hr:my-2;
@apply prose prose-white max-w-none select-text hyphens-auto text-white prose-headings:mb-1 prose-headings:mt-3 prose-p:mb-0 prose-p:mt-0 prose-p:break-words prose-p:[word-break:break-word] prose-p:last:mb-0 prose-a:break-words prose-a:break-all prose-a:font-normal hover:prose-a:text-fuchsia-500 prose-blockquote:mb-1 prose-blockquote:mt-1 prose-blockquote:border-l-[2px] prose-blockquote:border-fuchsia-500 prose-blockquote:pl-2 prose-pre:whitespace-pre-wrap prose-pre:break-words prose-pre:break-all prose-pre:bg-white/10 prose-ol:m-0 prose-ol:mb-1 prose-ul:mb-1 prose-ul:mt-1 prose-img:mb-2 prose-img:mt-3 prose-hr:mx-0 prose-hr:my-2;
}
.markdown-article {
@apply prose prose-white max-w-none select-text hyphens-auto text-white/80 prose-headings:mb-1 prose-headings:mt-3 prose-headings:text-white prose-p:mb-2 prose-p:mt-0 prose-p:break-words prose-p:[word-break:break-word] prose-a:break-words prose-a:break-all prose-a:font-normal hover:prose-a:text-fuchsia-500 prose-blockquote:mb-1 prose-blockquote:mt-1 prose-blockquote:border-l-[2px] prose-blockquote:border-fuchsia-500 prose-blockquote:pl-2 prose-pre:whitespace-pre-wrap prose-pre:break-words prose-pre:break-all prose-pre:bg-white/10 prose-ol:m-0 prose-ol:mb-1 prose-ul:mb-1 prose-ul:mt-1 prose-img:mb-2 prose-img:mt-3 prose-hr:mx-0 prose-hr:my-2;
}
.ProseMirror p.is-empty::before {
@@ -30,25 +31,31 @@ button {
@apply outline-fuchsia-500;
}
html {
font-size: 14px;
}
a {
@apply cursor-default no-underline !important;
}
button {
@apply cursor-default focus:outline-none;
}
iframe {
height: auto !important;
}
/* For Webkit-based browsers (Chrome, Safari and Opera) */
.scrollbar-hide::-webkit-scrollbar {
span[data-slate-placeholder] {
margin-top: 0;
}
input::-ms-reveal,
input::-ms-clear {
display: none;
}
/* For IE, Edge and Firefox */
.scrollbar-hide {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.border {
background-clip: padding-box;
}
span[data-slate-placeholder] {
@apply top-0;
::-webkit-input-placeholder {
line-height: normal;
}

View File

@@ -1,6 +1,6 @@
import { NDKCacheAdapter } from '@nostr-dev-kit/ndk';
import { NDKEvent, NDKSubscription } from '@nostr-dev-kit/ndk';
import { Store } from '@tauri-apps/plugin-store';
import { Store } from 'tauri-plugin-store-api';
export default class TauriAdapter implements NDKCacheAdapter {
public store: Store;

View File

@@ -1,85 +1,69 @@
// inspire by: https://github.com/nostr-dev-kit/ndk-react/
import NDK from '@nostr-dev-kit/ndk';
import { message } from '@tauri-apps/api/dialog';
import { fetch } from '@tauri-apps/api/http';
import { useEffect, useMemo, useState } from 'react';
import TauriAdapter from '@libs/ndk/cache';
import { useStorage } from '@libs/storage/provider';
import { FULL_RELAYS } from '@stores/constants';
export const NDKInstance = () => {
const { db } = useStorage();
const [ndk, setNDK] = useState<NDK | undefined>(undefined);
const [relayUrls, setRelayUrls] = useState<string[]>([]);
const { db } = useStorage();
const cacheAdapter = useMemo(() => new TauriAdapter(), [ndk]);
// TODO: fully support NIP-11
async function verifyRelays(relays: string[]) {
async function getExplicitRelays() {
try {
const urls: string[] = relays.map((relay) => {
if (relay.startsWith('ws')) {
return relay.replace('ws', 'http');
}
if (relay.startsWith('wss')) {
return relay.replace('wss', 'https');
}
// get relays
const relays = await db.getExplicitRelayUrls();
const requests = relays.map((relay) => {
const url = new URL(relay);
return fetch(`https://${url.hostname + url.pathname}`, {
method: 'GET',
timeout: 10,
headers: {
Accept: 'application/nostr+json',
},
});
});
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort('timeout'), 5000);
const requests = urls.map((url) =>
fetch(url, {
headers: { Accept: 'application/nostr+json' },
signal: controller.signal,
})
);
const responses = await Promise.all(requests);
const errors = responses.filter((response) => !response.ok);
const successes = responses.filter((res) => res.ok);
if (errors.length > 0) {
throw errors.map((response) => Error(response.statusText));
}
const verifiedRelays: string[] = responses.map((res) => {
if (res.url.startsWith('http')) {
return res.url.replace('htto', 'ws');
}
if (res.url.startsWith('https')) {
return res.url.replace('https', 'wss');
const verifiedRelays: string[] = successes.map((res) => {
// TODO: support payment
// @ts-expect-error, not have type yet
if (!res.data.limitation?.payment_required) {
const url = new URL(res.url);
if (url.protocol === 'http:') return `ws://${url.hostname + url.pathname}`;
if (url.protocol === 'https:') return `wss://${url.hostname + url.pathname}`;
}
});
// clear timeout
clearTimeout(timeoutId);
// return all validate relays
// return all validated relays
return verifiedRelays;
} catch (e) {
console.error('ndk instance error: ', e);
console.error(e);
}
}
async function initNDK() {
let explicitRelayUrls: string[];
const explicitRelayUrlsFromDB = await db.getExplicitRelayUrls();
console.log('relays in db: ', explicitRelayUrlsFromDB);
if (explicitRelayUrlsFromDB) {
explicitRelayUrls = await verifyRelays(explicitRelayUrlsFromDB);
} else {
explicitRelayUrls = await verifyRelays(FULL_RELAYS);
}
console.log('ndk cache adapter: ', cacheAdapter);
const instance = new NDK({ explicitRelayUrls, cacheAdapter });
const explicitRelayUrls = await getExplicitRelays();
const instance = new NDK({
explicitRelayUrls,
cacheAdapter,
});
try {
await instance.connect(10000);
} catch (error) {
throw new Error('NDK instance init failed: ', error);
await message(`NDK instance init failed: ${error}`, {
title: 'Lume',
type: 'error',
});
}
setNDK(instance);

View File

@@ -5,12 +5,12 @@ import { PropsWithChildren, createContext, useContext } from 'react';
import { NDKInstance } from '@libs/ndk/instance';
interface NDKContext {
ndk: NDK;
ndk: undefined | NDK;
relayUrls: string[];
}
const NDKContext = createContext<NDKContext>({
ndk: new NDK({}),
ndk: undefined,
relayUrls: [],
});

View File

@@ -1,44 +1,55 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { BaseDirectory, removeFile } from '@tauri-apps/plugin-fs';
import Database from '@tauri-apps/plugin-sql';
import { Stronghold } from '@tauri-apps/plugin-stronghold';
import { BaseDirectory, removeFile } from '@tauri-apps/api/fs';
import { Platform } from '@tauri-apps/api/os';
import Database from 'tauri-plugin-sql-api';
import { Stronghold } from 'tauri-plugin-stronghold-api';
import { FULL_RELAYS } from '@stores/constants';
import { Account, DBEvent, Relays, Widget } from '@utils/types';
export class LumeStorage {
public db: Database;
public platform: Platform;
public secureDB: Stronghold;
public account: Account | null = null;
constructor(sqlite: Database, stronghold?: Stronghold) {
constructor(sqlite: Database, platform: Platform, stronghold?: Stronghold) {
this.db = sqlite;
this.platform = platform ?? undefined;
this.secureDB = stronghold ?? undefined;
this.account = null;
}
private async getSecureClient() {
private async getSecureClient(key?: string) {
try {
return await this.secureDB.loadClient('lume');
return await this.secureDB.loadClient(key ?? 'lume');
} catch {
return await this.secureDB.createClient('lume');
return await this.secureDB.createClient(key ?? 'lume');
}
}
public async secureSave(key: string, value: string) {
public async secureSave(key: string, value: string, clientKey?: string) {
if (!this.secureDB) throw new Error("Stronghold isn't initialize");
const client = await this.getSecureClient();
const client = await this.getSecureClient(clientKey);
const store = client.getStore();
console.log('insert key: ', key);
await store.insert(key, Array.from(new TextEncoder().encode(value)));
return await this.secureDB.save();
await this.secureDB.save();
}
public async secureLoad(key: string) {
public async secureLoad(key: string, clientKey?: string) {
if (!this.secureDB) throw new Error("Stronghold isn't initialize");
const client = await this.getSecureClient();
const client = await this.getSecureClient(clientKey);
const store = client.getStore();
console.log('get key: ', key);
const value = await store.get(key);
if (!value) return null;
const decoded = new TextDecoder().decode(new Uint8Array(value));
return decoded;
}
@@ -134,18 +145,29 @@ export class LumeStorage {
return await this.db.execute('DELETE FROM widgets WHERE id = $1;', [id]);
}
public async createEvent(
id: string,
event: string,
author: string,
kind: number,
root_id: string,
reply_id: string,
created_at: number
) {
public async createEvent(event: NDKEvent) {
let root: string;
let reply: string;
if (event.tags?.[0]?.[0] === 'e' && !event.tags?.[0]?.[3]) {
root = event.tags[0][1];
} else {
root = event.tags.find((el) => el[3] === 'root')?.[1];
reply = event.tags.find((el) => el[3] === 'reply')?.[1];
}
return await this.db.execute(
'INSERT OR IGNORE INTO events (id, account_id, event, author, kind, root_id, reply_id, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8);',
[id, this.account.id, event, author, kind, root_id, reply_id, created_at]
[
event.id,
this.account.id,
JSON.stringify(event),
event.pubkey,
event.kind,
root,
reply,
event.created_at,
]
);
}
@@ -261,13 +283,13 @@ export class LumeStorage {
}
public async getExplicitRelayUrls() {
if (!this.account) return null;
if (!this.account) return FULL_RELAYS;
const result: Relays[] = await this.db.select(
`SELECT * FROM relays WHERE account_id = "${this.account.id}" ORDER BY id DESC LIMIT 50;`
);
if (result.length < 1) return null;
if (!result || result.length < 1) return FULL_RELAYS;
return result.map((el) => el.relay);
}
@@ -288,6 +310,15 @@ export class LumeStorage {
);
}
public async accountLogout() {
await this.db.execute("UPDATE accounts SET is_active = '0' WHERE id = $1;", [
this.account.id,
]);
this.account = null;
return true;
}
public async close() {
return this.db.close();
}

View File

@@ -1,5 +1,8 @@
import Database from '@tauri-apps/plugin-sql';
import { message } from '@tauri-apps/api/dialog';
import { platform } from '@tauri-apps/api/os';
import { appConfigDir } from '@tauri-apps/api/path';
import { PropsWithChildren, createContext, useContext, useEffect, useState } from 'react';
import Database from 'tauri-plugin-sql-api';
import { LumeStorage } from '@libs/storage/instance';
@@ -15,11 +18,22 @@ const StorageProvider = ({ children }: PropsWithChildren<object>) => {
const [db, setDB] = useState<LumeStorage>(undefined);
async function initLumeStorage() {
const sqlite = await Database.load('sqlite:lume.db');
const lumeStorage = new LumeStorage(sqlite);
try {
const dir = await appConfigDir();
const sqlite = await Database.load('sqlite:lume.db');
const platformName = await platform();
const lumeStorage = new LumeStorage(sqlite, platformName);
if (!lumeStorage.account) await lumeStorage.getActiveAccount();
setDB(lumeStorage);
console.log('App config dir: ', dir);
if (!lumeStorage.account) await lumeStorage.getActiveAccount();
setDB(lumeStorage);
} catch (e) {
await message(`Cannot initialize database: ${e}`, {
title: 'Lume',
type: 'error',
});
}
}
useEffect(() => {

View File

@@ -6,6 +6,8 @@ import { useStorage } from '@libs/storage/provider';
import { AccountMoreActions } from '@shared/accounts/more';
import { Image } from '@shared/image';
import { useActivities } from '@stores/activities';
import { useNostr } from '@utils/hooks/useNostr';
import { useProfile } from '@utils/hooks/useProfile';
import { sendNativeNotification } from '@utils/notification';
@@ -16,6 +18,8 @@ export function ActiveAccount() {
const { status, user } = useProfile(db.account.pubkey);
const { sub } = useNostr();
const addActivity = useActivities((state) => state.addActivity);
useEffect(() => {
const filter: NDKFilter = {
'#p': [db.account.pubkey],
@@ -30,11 +34,13 @@ export function ActiveAccount() {
};
sub(filter, async (event) => {
addActivity(event);
switch (event.kind) {
case NDKKind.Text:
return await sendNativeNotification('Mention');
case NDKKind.Contacts:
return await sendNativeNotification("You've new follower");
return await sendNativeNotification("You've a new follower");
case NDKKind.Repost:
return await sendNativeNotification('Repost');
case NDKKind.Reaction:
@@ -42,7 +48,6 @@ export function ActiveAccount() {
case NDKKind.Zap:
return await sendNativeNotification('Zap');
default:
console.log('[notify] new event: ', event);
break;
}
});
@@ -50,8 +55,8 @@ export function ActiveAccount() {
if (status === 'loading') {
return (
<div className="inline-flex h-10 items-center gap-2.5 rounded-md px-2">
<div className="relative h-7 w-7 shrink-0 animate-pulse rounded bg-white/10 backdrop-blur-xl" />
<div className="inline-flex h-16 items-center gap-2.5 border-l-2 border-transparent pb-2 pl-4 pr-2">
<div className="relative h-10 w-10 shrink-0 animate-pulse rounded bg-white/10 backdrop-blur-xl" />
<div className="h-2.5 w-2/3 animate-pulse rounded bg-white/10 backdrop-blur-xl" />
</div>
);
@@ -67,7 +72,7 @@ export function ActiveAccount() {
/>
<div className="flex w-full flex-1 flex-col items-start gap-1.5">
<p className="max-w-[10rem] truncate font-bold leading-none text-white">
{user?.name || user?.display_name || user?.nip05}
{user?.name || user?.display_name}
</p>
<span className="max-w-[8rem] truncate text-sm leading-none text-white/50">
{displayNpub(db.account.pubkey, 16)}

View File

@@ -3,6 +3,7 @@ import { useState } from 'react';
import { Link } from 'react-router-dom';
import { HorizontalDotsIcon } from '@shared/icons';
import { Logout } from '@shared/logout';
export function AccountMoreActions({ pubkey }: { pubkey: string }) {
const [open, setOpen] = useState(false);
@@ -18,7 +19,7 @@ export function AccountMoreActions({ pubkey }: { pubkey: string }) {
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content className="flex w-[200px] flex-col overflow-hidden rounded-xl bg-white/10 p-2 backdrop-blur-3xl focus:outline-none">
<DropdownMenu.Content className="flex w-[200px] flex-col overflow-hidden rounded-xl border border-white/10 bg-white/20 p-2 backdrop-blur-3xl focus:outline-none">
<DropdownMenu.Item asChild>
<Link
to={`/users/${pubkey}`}
@@ -44,9 +45,7 @@ export function AccountMoreActions({ pubkey }: { pubkey: string }) {
</Link>
</DropdownMenu.Item>
<DropdownMenu.Item asChild>
<button className="inline-flex h-10 items-center rounded-lg px-2 text-sm font-medium text-white hover:bg-white/10">
Logout
</button>
<Logout />
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>

View File

@@ -2,14 +2,14 @@ import { Dispatch, SetStateAction, useState } from 'react';
import { LoaderIcon, PlusIcon } from '@shared/icons';
import { useImageUploader } from '@utils/hooks/useUploader';
import { useNostr } from '@utils/hooks/useNostr';
export function AvatarUploader({
setPicture,
}: {
setPicture: Dispatch<SetStateAction<string>>;
}) {
const upload = useImageUploader();
const { upload } = useNostr();
const [loading, setLoading] = useState(false);
const uploadAvatar = async () => {

View File

@@ -2,23 +2,21 @@ import { Dispatch, SetStateAction, useState } from 'react';
import { LoaderIcon, PlusIcon } from '@shared/icons';
import { useImageUploader } from '@utils/hooks/useUploader';
import { useNostr } from '@utils/hooks/useNostr';
export function BannerUploader({
setBanner,
}: {
setBanner: Dispatch<
SetStateAction<{ url: undefined | string; error?: undefined | string }>
>;
setBanner: Dispatch<SetStateAction<string>>;
}) {
const upload = useImageUploader();
const { upload } = useNostr();
const [loading, setLoading] = useState(false);
const uploadBanner = async () => {
setLoading(true);
const image = await upload(null);
if (image.url) {
setBanner(image);
setBanner(image.url);
}
setLoading(false);
};
@@ -27,13 +25,14 @@ export function BannerUploader({
<button
type="button"
onClick={() => uploadBanner()}
className="inline-flex h-full w-full items-center justify-center bg-black/40 hover:bg-black/50"
className="inline-flex h-full w-full flex-col items-center justify-center gap-1 bg-black/40 hover:bg-black/50"
>
{loading ? (
<LoaderIcon className="h-8 w-8 animate-spin text-white" />
<LoaderIcon className="h-6 w-6 animate-spin text-white" />
) : (
<PlusIcon className="h-8 w-8 text-white" />
<PlusIcon className="h-6 w-6 text-white" />
)}
<p className="text-sm font-medium text-white/70">Add a banner image</p>
</button>
);
}

View File

@@ -1,32 +1,28 @@
import { message } from '@tauri-apps/api/dialog';
import Image from '@tiptap/extension-image';
import Mention from '@tiptap/extension-mention';
import Placeholder from '@tiptap/extension-placeholder';
import { EditorContent, useEditor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import { convert } from 'html-to-text';
import { nip19 } from 'nostr-tools';
import { useState } from 'react';
import { twMerge } from 'tailwind-merge';
import { Suggestion } from '@shared/composer';
import { CancelIcon, LoaderIcon, PlusCircleIcon } from '@shared/icons';
import { MediaUploader, MentionPopup } from '@shared/composer';
import { CancelIcon, LoaderIcon } from '@shared/icons';
import { MentionNote } from '@shared/notes';
import { useComposer } from '@stores/composer';
import { useNostr } from '@utils/hooks/useNostr';
import { useImageUploader } from '@utils/hooks/useUploader';
import { sendNativeNotification } from '@utils/notification';
export function Composer() {
const { publish } = useNostr();
const [status, setStatus] = useState<null | 'loading' | 'done'>(null);
const [loading, setLoading] = useState<boolean>(false);
const [reply, clearReply] = useComposer((state) => [state.reply, state.clearReply]);
const expand = useComposer((state) => state.expand)
const upload = useImageUploader();
const { publish } = useNostr();
const expand = useComposer((state) => state.expand);
const editor = useEditor({
extensions: [
StarterKit.configure({
@@ -35,12 +31,6 @@ export function Composer() {
},
}),
Placeholder.configure({ placeholder: 'Type something...' }),
Mention.configure({
suggestion: Suggestion,
renderLabel({ node }) {
return `nostr:${nip19.npubEncode(node.attrs.id.pubkey)} `;
},
}),
Image.configure({
HTMLAttributes: {
class:
@@ -48,26 +38,22 @@ export function Composer() {
},
}),
],
content: '',
content: JSON.parse(localStorage.getItem('editor-content') || '{}'),
editorProps: {
attributes: {
class: 'h-full markdown break-all overflow-y-auto outline-none pr-2',
},
},
onUpdate: ({ editor }) => {
const jsonContent = JSON.stringify(editor.getJSON());
localStorage.setItem('editor-content', jsonContent);
},
});
const uploadImage = async (file?: string) => {
const image = await upload(file, true);
if (image.url) {
editor.commands.setImage({ src: image.url });
editor.commands.createParagraphNear();
}
};
const submit = async () => {
setStatus('loading');
try {
setLoading(true);
// get plaintext content
const html = editor.getHTML();
const serializedContent = convert(html, {
@@ -108,18 +94,19 @@ export function Composer() {
await publish({ content: serializedContent, kind: 1, tags });
// send native notifiation
await sendNativeNotification('Publish post successfully');
await sendNativeNotification('Post has been published successfully.');
// update state
setStatus('done');
setLoading(false);
// reset editor
editor.commands.clearContent();
// reset reply
if (reply.id) {
clearReply();
}
} catch {
setStatus(null);
console.log('failed to publish');
setLoading(false);
await message('Publishing post failed.', { title: 'Lume', type: 'error' });
}
};
@@ -136,7 +123,10 @@ export function Composer() {
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
className={twMerge('scrollbar-hide markdown break-all max-h-[500px] overflow-y-auto outline-none pr-2', expand ? 'min-h-[500px]' : 'min-h-[120px]')}
className={twMerge(
'scrollbar-hide markdown max-h-[500px] overflow-y-auto break-all pr-2 outline-none',
expand ? 'min-h-[500px]' : reply.id ? 'min-h-min' : 'min-h-[120px]'
)}
/>
{reply.id && (
<div className="relative">
@@ -152,17 +142,18 @@ export function Composer() {
)}
</div>
</div>
<div className="flex items-center justify-between bg-white/5 rounded-b-xl p-2 border-t border-white/10">
<div className="flex items-center justify-between rounded-b-xl border-t border-white/10 bg-white/5 p-2">
<div className="inline-flex items-center gap-1">
<MediaUploader editor={editor} />
<MentionPopup editor={editor} />
</div>
<button
type="button"
onClick={() => uploadImage()}
className="ml-2 inline-flex h-10 w-10 items-center justify-center rounded-lg backdrop-blur-xl hover:bg-white/10"
onClick={() => submit()}
disabled={editor && editor.isEmpty}
className="inline-flex h-10 w-20 items-center justify-center rounded-lg bg-fuchsia-500 px-2 font-semibold hover:bg-fuchsia-600 disabled:opacity-50"
>
<PlusCircleIcon className="h-5 w-5 text-white" />
</button>
<button onClick={() => submit()} className="inline-flex items-center justify-center w-max px-8 rounded-lg font-bold h-10 bg-fuchsia-500 hover:bg-fuchsia-600">
{status === 'loading' ? (
<LoaderIcon className="h-4 w-4 animate-spin text-white" />
{loading === true ? (
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
) : (
'Post'
)}

View File

@@ -1,6 +1,6 @@
export * from './user';
export * from './modal';
export * from './composer';
export * from './mention/list';
export * from './mention/item';
export * from './mention/suggestion';
export * from './mention/popup';
export * from './mediaUploader';

View File

@@ -0,0 +1,62 @@
import { message } from '@tauri-apps/api/dialog';
import { UnlistenFn, listen } from '@tauri-apps/api/event';
import { Editor } from '@tiptap/react';
import { useEffect, useState } from 'react';
import { MediaIcon } from '@shared/icons';
import { useNostr } from '@utils/hooks/useNostr';
export function MediaUploader({ editor }: { editor: Editor }) {
const { upload } = useNostr();
const [loading, setLoading] = useState(false);
const uploadToNostrBuild = async (file?: string) => {
try {
// start loading
setLoading(true);
const image = await upload(file, true);
if (image.url) {
editor.commands.setImage({ src: image.url });
editor.commands.createParagraphNear();
// stop loading
setLoading(false);
}
} catch (e) {
// stop loading
setLoading(false);
await message(`Upload failed, error: ${e}`, { title: 'Lume', type: 'error' });
}
};
useEffect(() => {
let unlisten: UnlistenFn;
async function listenDnD() {
unlisten = await listen('tauri://file-drop', (event) => {
uploadToNostrBuild(event.payload[0]);
});
}
// start listen drag and drop event
listenDnD();
// clean up
return () => {
unlisten();
};
}, []);
return (
<button
type="button"
onClick={() => uploadToNostrBuild()}
className="ml-2 inline-flex h-10 w-max items-center justify-center gap-1.5 rounded-lg px-2 text-sm font-medium text-white/80 hover:bg-white/10 hover:backdrop-blur-xl"
>
<MediaIcon className="h-5 w-5 text-white/80" />
{loading ? 'Uploading...' : 'Add media'}
</button>
);
}

View File

@@ -1,22 +1,36 @@
import { Image } from '@shared/image';
import { useProfile } from '@utils/hooks/useProfile';
import { displayNpub } from '@utils/shortenKey';
import { Profile } from '@utils/types';
export function MentionItem({ profile }: { profile: Profile }) {
export function MentionItem({ pubkey }: { pubkey: string }) {
const { status, user } = useProfile(pubkey);
if (status === 'loading') {
return (
<div className="flex items-center gap-2">
<div className="relative h-8 w-8 shrink-0 animate-pulse rounded bg-white/10 backdrop-blur-xl" />
<div className="flex w-full flex-1 flex-col items-start gap-1 text-start">
<span className="h-4 w-1/2 animate-pulse rounded bg-white/10 backdrop-blur-xl" />
<span className="h-3 w-1/3 animate-pulse rounded bg-white/10 backdrop-blur-xl" />
</div>
</div>
);
}
return (
<div className="flex items-center gap-2">
<div className="flex h-11 items-center justify-start gap-2.5 px-2 hover:bg-white/10">
<Image
src={profile.picture || profile.image}
alt={profile.pubkey}
src={user.picture || user.image}
alt={pubkey}
className="shirnk-0 h-8 w-8 rounded-md object-cover"
/>
<div className="flex flex-col gap-px">
<h5 className="max-w-[15rem] text-sm font-medium leading-none text-white">
{profile.ident}
<div className="flex flex-col items-start gap-px">
<h5 className="max-w-[10rem] truncate text-sm font-medium leading-none text-white">
{user.display_name || user.displayName || user.name}
</h5>
<span className="text-sm leading-none text-white/50">
{displayNpub(profile.pubkey, 16)}
{displayNpub(pubkey, 16)}
</span>
</div>
</div>

View File

@@ -1,75 +0,0 @@
import { NDKUserProfile } from '@nostr-dev-kit/ndk';
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react';
import { twMerge } from 'tailwind-merge';
import { MentionItem } from '@shared/composer';
export const MentionList = forwardRef((props: any, ref: any) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const selectItem = (index) => {
const item = props.items[index];
if (item) {
props.command({ id: item });
}
};
const upHandler = () => {
setSelectedIndex((selectedIndex + props.items.length - 1) % props.items.length);
};
const downHandler = () => {
setSelectedIndex((selectedIndex + 1) % props.items.length);
};
const enterHandler = () => {
selectItem(selectedIndex);
};
useEffect(() => setSelectedIndex(0), [props.items]);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }) => {
if (event.key === 'ArrowUp') {
upHandler();
return true;
}
if (event.key === 'ArrowDown') {
downHandler();
return true;
}
if (event.key === 'Enter') {
enterHandler();
return true;
}
return false;
},
}));
return (
<div className="flex w-[250px] flex-col rounded-xl border-t border-zinc-700/50 bg-zinc-800 px-3 py-3">
{props.items.length ? (
props.items.map((item: NDKUserProfile, index: number) => (
<button
className={twMerge(
'h-11 w-full rounded-lg px-2 text-start text-sm font-medium hover:bg-zinc-700',
`${index === selectedIndex ? 'is-selected' : ''}`
)}
key={index}
onClick={() => selectItem(index)}
>
<MentionItem profile={item} />
</button>
))
) : (
<div>No result</div>
)}
</div>
);
});
MentionList.displayName = 'MentionList';

View File

@@ -0,0 +1,38 @@
import * as Popover from '@radix-ui/react-popover';
import { Editor } from '@tiptap/react';
import { nip19 } from 'nostr-tools';
import { useStorage } from '@libs/storage/provider';
import { MentionItem } from '@shared/composer';
import { MentionIcon } from '@shared/icons';
export function MentionPopup({ editor }: { editor: Editor }) {
const { db } = useStorage();
const insertMention = (pubkey: string) => {
editor.commands.insertContent(`nostr:${nip19.npubEncode(pubkey)}`);
};
return (
<Popover.Root>
<Popover.Trigger asChild>
<button
type="button"
className="inline-flex h-10 w-10 items-center justify-center rounded-lg hover:bg-white/10 hover:backdrop-blur-xl"
>
<MentionIcon className="h-5 w-5 text-white/80" />
</button>
</Popover.Trigger>
<Popover.Content className="h-full max-h-[200px] w-[250px] overflow-hidden overflow-y-auto rounded-lg bg-white/10 backdrop-blur-xl focus:outline-none">
<div className="flex flex-col gap-1 py-1">
{db.account.follows.map((item) => (
<button key={item} type="button" onClick={() => insertMention(item)}>
<MentionItem pubkey={item} />
</button>
))}
</div>
</Popover.Content>
</Popover.Root>
);
}

View File

@@ -1,68 +0,0 @@
import { ReactRenderer } from '@tiptap/react';
import tippy from 'tippy.js';
import { MentionList } from '@shared/composer';
export const Suggestion = {
items: async ({ query }) => {
const users = [];
return users
.filter((item) => item.ident.toLowerCase().startsWith(query.toLowerCase()))
.slice(0, 5);
},
render: () => {
let component;
let popup;
return {
onStart: (props) => {
component = new ReactRenderer(MentionList, {
props,
editor: props.editor,
});
if (!props.clientRect) {
return;
}
popup = tippy('body', {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: 'manual',
placement: 'bottom-start',
});
},
onUpdate(props) {
component.updateProps(props);
if (!props.clientRect) {
return;
}
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown(props) {
if (props.event.key === 'Escape') {
popup[0].hide();
return true;
}
return component.ref?.onKeyDown(props);
},
onExit() {
popup[0].destroy();
component.destroy();
},
};
},
};

View File

@@ -37,7 +37,7 @@ export function ComposerModal() {
</button>
</Dialog.Trigger>
<Dialog.Portal className="relative z-10">
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xl" />
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-2xl" />
<Dialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<div
className={twMerge(

View File

@@ -1,6 +1,7 @@
import { Image } from '@shared/image';
import { useProfile } from '@utils/hooks/useProfile';
import { displayNpub } from '@utils/shortenKey';
export function ComposerUser({ pubkey }: { pubkey: string }) {
const { user } = useProfile(pubkey);
@@ -13,9 +14,7 @@ export function ComposerUser({ pubkey }: { pubkey: string }) {
className="h-10 w-10 shrink-0 rounded-lg"
/>
<h5 className="text-base font-semibold leading-none text-white">
{user?.nip05 || user?.name || (
<div className="h-3 w-20 animate-pulse rounded-sm bg-zinc-700" />
)}
{user?.name || user?.display_name || displayNpub(pubkey, 16)}
</h5>
</div>
);

28
src/shared/frame.tsx Normal file
View File

@@ -0,0 +1,28 @@
import { HTMLProps, ReactNode, useCallback } from 'react';
import { twMerge } from 'tailwind-merge';
import { useStorage } from '@libs/storage/provider';
export function Frame({
children,
className,
lighter,
}: {
children: ReactNode;
className: HTMLProps<HTMLElement>['className'];
lighter?: boolean;
}) {
const { db } = useStorage();
const platformStyles = useCallback(() => {
switch (db.platform) {
case 'darwin':
if (lighter) return 'bg-black/80';
return 'bg-black/90';
default:
return 'bg-black';
}
}, []);
return <div className={twMerge(className, platformStyles())}>{children}</div>;
}

74
src/shared/icons/alby.tsx Normal file
View File

@@ -0,0 +1,74 @@
import { SVGProps } from 'react';
export function AlbyIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="400"
height="578"
fill="none"
viewBox="0 0 400 578"
{...props}
>
<path
fill="#000"
d="M201.283 577.511c54.122 0 97.998-8.1 97.998-18.092 0-9.992-43.876-18.092-97.998-18.092-54.123 0-97.998 8.1-97.998 18.092 0 9.992 43.875 18.092 97.998 18.092z"
opacity="0.1"
></path>
<path
fill="#fff"
stroke="#000"
strokeWidth="15.077"
d="M295.75 471.344c50.627 0 73.67-112.102 73.67-154.608 0-33.13-22.86-53.208-52.913-53.208-29.866 0-54.113 12.843-54.414 28.747-.001 41.971-7.388 179.069 33.657 179.069zM110.837 471.344c-50.627 0-73.67-112.102-73.67-154.608 0-33.13 22.86-53.208 52.913-53.208 29.866 0 54.113 12.843 54.414 28.747.001 41.971 7.388 179.069-33.657 179.069z"
></path>
<path
fill="#FFDF6F"
stroke="#000"
strokeWidth="15"
d="M68.83 303.262v-.002c-.054-.519.052-.82.16-1.016.127-.232.368-.508.773-.738.84-.477 2.014-.563 3.108.076 37.603 22.042 80.976 34.678 128.13 34.678 47.163 0 91.339-12.881 129.184-35.307 1.087-.645 2.26-.565 3.102-.091.407.229.65.504.779.737.109.197.216.499.163 1.019-5.854 58.014-37.322 105.977-79.618 128.054-13.969 7.293-23.576 19.962-32.013 31.089l-.452.597-.002.002c-6.857 9.046-13.063 17.147-20.648 23.116-7.584-5.969-13.791-14.07-20.648-23.116l-.001-.002-.452-.597c-8.437-11.127-18.043-23.796-32.013-31.089-42.135-21.992-73.523-69.677-79.551-127.41z"
></path>
<path
fill="#000"
stroke="#000"
strokeWidth="15.077"
d="M201.786 346.338c73.274 0 132.674-19.8 132.674-44.225s-59.4-44.225-132.674-44.225-132.674 19.8-132.674 44.225 59.4 44.225 132.674 44.225z"
></path>
<path
stroke="#000"
strokeLinecap="round"
strokeWidth="15.077"
d="M95.245 376.491s65.44 22.112 107.546 22.112c42.105 0 107.546-22.112 107.546-22.112"
></path>
<path
fill="#000"
d="M77 143c-16.569 0-30-13.431-30-30 0-16.569 13.431-30 30-30 16.569 0 30 13.431 30 30 0 16.569-13.431 30-30 30z"
></path>
<path stroke="#000" strokeWidth="15" d="M72 108.5l56 56"></path>
<path
fill="#000"
d="M322 143c16.569 0 30-13.431 30-30 0-16.569-13.431-30-30-30-16.569 0-30 13.431-30 30 0 16.569 13.431 30 30 30z"
></path>
<path stroke="#000" strokeWidth="15" d="M327.5 108.5l-56 56"></path>
<path
fill="#FFDF6F"
fillRule="evenodd"
d="M85.516 292.019c-16.17-7.698-25.58-24.983-22.427-42.612C76.618 173.747 133 117 200.5 117c67.663 0 124.155 57.023 137.509 132.958 3.106 17.66-6.381 34.937-22.605 42.572C280.687 308.868 241.91 318 201 318c-41.335 0-80.493-9.323-115.484-25.981z"
clipRule="evenodd"
></path>
<path
fill="#000"
d="M70.472 250.728C83.544 177.62 137.582 124.5 200.5 124.5v-15c-72.082 0-130.809 60.375-144.794 138.587l14.766 2.641zM200.5 124.5c63.069 0 117.218 53.379 130.122 126.757l14.774-2.598C331.592 170.166 272.758 109.5 200.5 109.5v15zm111.71 161.244C278.472 301.621 240.783 310.5 201 310.5v15c42.037 0 81.902-9.386 117.597-26.183l-6.387-13.573zM201 310.5c-40.196 0-78.255-9.064-112.26-25.253l-6.448 13.544C118.269 315.918 158.526 325.5 201 325.5v-15zm129.622-59.243c2.49 14.159-5.091 28.219-18.412 34.487l6.387 13.573c19.128-9.002 30.52-29.497 26.799-50.658l-14.774 2.598zm-274.916-3.17c-3.778 21.124 7.524 41.629 26.586 50.704l6.447-13.544c-13.276-6.32-20.795-20.387-18.267-34.519l-14.766-2.641z"
></path>
<path
fill="#000"
fillRule="evenodd"
d="M114.365 273.209c-13.015-5.301-20.736-19.149-16.226-32.459C112.047 199.704 152.618 170 200.5 170c47.882 0 88.453 29.704 102.361 70.75 4.51 13.31-3.211 27.158-16.226 32.459C260.053 284.035 230.973 290 200.5 290c-30.473 0-59.553-5.965-86.135-16.791z"
clipRule="evenodd"
></path>
<path
fill="#fff"
d="M235 254c13.807 0 25-8.954 25-20s-11.193-20-25-20-25 8.954-25 20 11.193 20 25 20zM163.432 254.012c13.807 0 25-8.954 25-20s-11.193-20-25-20-25 8.954-25 20 11.193 20 25 20z"
></path>
</svg>
);
}

View File

@@ -0,0 +1,22 @@
import { SVGProps } from 'react';
export function ArticleIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
{...props}
>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
d="M16.25 12V4.75a1 1 0 00-1-1H3.75a1 1 0 00-1 1v13a2.5 2.5 0 002.5 2.5H18.5M16.25 12v5.75a2.5 2.5 0 005 0V13a1 1 0 00-1-1h-4zm-9.5 3.75h5.5m-5.5-8h5.5v4.5h-5.5v-4.5z"
></path>
</svg>
);
}

View File

@@ -13,10 +13,9 @@ export function FileIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
d="M8.75 6.75h6.5m-6.5 4h6.5m-6.5 4h2.5m-5.5 6.5h12.5a1 1 0 001-1V3.75a1 1 0 00-1-1H5.75a1 1 0 00-1 1v16.5a1 1 0 001 1z"
/>
d="M12.75 3.25v5a1 1 0 001 1h5m-10 4h3.5m-3.5 4h6.5m-9.5-14.5h6.586a1 1 0 01.707.293l5.914 5.914a1 1 0 01.293.707V20.25a1 1 0 01-1 1H5.75a1 1 0 01-1-1V3.75a1 1 0 011-1z"
></path>
</svg>
);
}

View File

@@ -0,0 +1,22 @@
import { SVGProps } from 'react';
export function FollowsIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
{...props}
>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
d="M17.75 19.25h3.673c.581 0 1.045-.496.947-1.07-.531-3.118-2.351-5.43-5.37-5.43-.446 0-.866.05-1.26.147M11.25 7a3.25 3.25 0 11-6.5 0 3.25 3.25 0 016.5 0zm8.5.5a2.75 2.75 0 11-5.5 0 2.75 2.75 0 015.5 0zM1.87 19.18c.568-3.68 2.647-6.43 6.13-6.43 3.482 0 5.561 2.75 6.13 6.43.088.575-.375 1.07-.956 1.07H2.825c-.58 0-1.043-.495-.955-1.07z"
></path>
</svg>
);
}

View File

@@ -0,0 +1,22 @@
import { SVGProps } from 'react';
export function GroupFeedsIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
{...props}
>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
d="M14.425 13.18c3.361-1.396 7.598.605 8.454 6.003.09.573-.372 1.067-.952 1.067H16.75M10.75 7a3.25 3.25 0 11-6.5 0 3.25 3.25 0 016.5 0zm9 0a3.25 3.25 0 11-6.5 0 3.25 3.25 0 016.5 0zm-6.966 13.25H2.072c-.58 0-1.042-.497-.951-1.07 1.362-8.573 11.252-8.573 12.614 0 .091.573-.371 1.07-.951 1.07z"
></path>
</svg>
);
}

View File

@@ -16,7 +16,7 @@ export function HashtagIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElem
strokeLinejoin="round"
strokeWidth="1.5"
d="M8.75 3.75l-2 16.5m10.5-16.5l-2 16.5M3.75 7.75h16.5m0 8.5H3.75"
/>
></path>
</svg>
);
}

View File

@@ -56,3 +56,11 @@ export * from './expand';
export * from './focus';
export * from './chevronUp';
export * from './secure';
export * from './verified';
export * from './mention';
export * from './groupFeeds';
export * from './article';
export * from './follows';
export * from './alby';
export * from './stars';
export * from './nwc';

View File

@@ -0,0 +1,22 @@
import { SVGProps } from 'react';
export function MentionIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
{...props}
>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
d="M11.85 13.251c-3.719.065-6.427 2.567-7.18 5.915-.13.575.338 1.084.927 1.084h6.901m-.647-6.999l.147-.001c.353 0 .696.022 1.03.064m-1.177-.063a7.889 7.889 0 00-1.852.249m3.028-.186c.334.042.658.104.972.186m-.972-.186a7.475 7.475 0 011.972.524m3.25 1.412v3m0 0v3m0-3h-3m3 0h3m-5.5-11.75a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
></path>
</svg>
);
}

22
src/shared/icons/nwc.tsx Normal file
View File

@@ -0,0 +1,22 @@
import { SVGProps } from 'react';
export function NwcIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
{...props}
>
<path
fill="currentColor"
fillRule="evenodd"
d="M3 6.5A3.5 3.5 0 016.5 3h8.969C16.314 3 17 3.686 17 4.531V8h2.25c.966 0 1.75.784 1.75 1.75v9.5A1.75 1.75 0 0119.25 21h-7a.75.75 0 010-1.5h7a.25.25 0 00.25-.25v-9.5a.25.25 0 00-.25-.25H6a2.986 2.986 0 01-1.5-.401v2.151a.75.75 0 01-1.5 0V6.5zm1.5 0A1.5 1.5 0 006 8h9.5V4.531a.031.031 0 00-.031-.031H6.5a2 2 0 00-2 2zm-1 9.285v2.93L6 20.173l2.5-1.458v-2.93L6 14.327l-2.5 1.458zm2.122-2.975a.75.75 0 01.756 0l3.25 1.896a.75.75 0 01.372.648v3.792a.75.75 0 01-.372.648l-3.25 1.895a.75.75 0 01-.756 0l-3.25-1.895A.75.75 0 012 19.146v-3.792a.75.75 0 01.372-.648l3.25-1.896z"
clipRule="evenodd"
></path>
<path fill="currentColor" d="M15.5 15.5a1 1 0 100-2 1 1 0 000 2z"></path>
</svg>
);
}

View File

@@ -0,0 +1,19 @@
import { SVGProps } from 'react';
export function StarsIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="30"
height="30"
fill="none"
viewBox="0 0 30 30"
{...props}
>
<path
fill="currentColor"
d="M13.379 11.638a.999.999 0 00.759-.759l.886-3.986c.232-1.044 1.72-1.044 1.952 0l.886 3.986c.084.38.38.675.759.76l3.986.885c1.044.232 1.044 1.72 0 1.952l-3.986.886a.999.999 0 00-.759.76l-.886 3.985c-.232 1.044-1.72 1.044-1.952 0l-.886-3.986a.999.999 0 00-.759-.759l-3.986-.886c-1.044-.232-1.044-1.72 0-1.952l3.986-.886zM8.06 19.82a.999.999 0 00.76-.759l.27-1.22c.098-.438.722-.438.819 0l.271 1.22c.084.379.38.675.759.759l1.22.271c.438.097.438.721 0 .818l-1.22.271a.999.999 0 00-.759.759l-.271 1.22c-.097.438-.721.438-.818 0l-.271-1.22a.999.999 0 00-.76-.759l-1.22-.271c-.437-.097-.437-.721.001-.818l1.22-.271z"
></path>
</svg>
);
}

View File

@@ -0,0 +1,21 @@
import { SVGProps } from 'react';
export function VerifiedIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
{...props}
>
<path
fill="currentColor"
fillRule="evenodd"
d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2zm3.58 7.975a.75.75 0 00-1.16-.95l-3.976 4.859L9.03 12.47a.75.75 0 00-1.06 1.06l2 2a.75.75 0 001.11-.055l4.5-5.5z"
clipRule="evenodd"
/>
</svg>
);
}

View File

@@ -1,5 +1,6 @@
import { Outlet, ScrollRestoration } from 'react-router-dom';
import { Frame } from '@shared/frame';
import { Navigation } from '@shared/navigation';
export function AppLayout() {
@@ -8,14 +9,14 @@ export function AppLayout() {
<div className="shrink-0">
<Navigation />
</div>
<div className="h-full w-full flex-1 bg-black/90 backdrop-blur-xl">
<Frame className="h-full w-full flex-1">
<Outlet />
<ScrollRestoration
getKey={(location) => {
return location.pathname;
}}
/>
</div>
</Frame>
</div>
);
}

View File

@@ -1,10 +1,12 @@
import { Outlet } from 'react-router-dom';
import { Frame } from '@shared/frame';
export function AuthLayout() {
return (
<div className="relative h-screen w-screen bg-black/90 backdrop-blur-xl">
<Frame className="relative h-screen w-screen">
<div className="absolute left-0 top-0 z-50 h-16 w-full" data-tauri-drag-region />
<Outlet />
</div>
</Frame>
);
}

View File

@@ -1,10 +1,12 @@
import { Outlet } from 'react-router-dom';
import { Frame } from '@shared/frame';
export function NoteLayout() {
return (
<div className="relative h-screen w-screen bg-black/90 backdrop-blur-xl">
<Frame className="relative h-screen w-screen">
<div className="absolute left-0 top-0 z-50 h-16 w-full" data-tauri-drag-region />
<Outlet />
</div>
</Frame>
);
}

View File

@@ -1,12 +1,13 @@
import { Link, NavLink, Outlet, ScrollRestoration } from 'react-router-dom';
import { twMerge } from 'tailwind-merge';
import { Frame } from '@shared/frame';
import { ArrowLeftIcon, SecureIcon, SettingsIcon } from '@shared/icons';
export function SettingsLayout() {
return (
<div className="flex h-screen w-screen">
<div className="relative flex h-full w-[232px] flex-col bg-black/80">
<Frame className="relative flex h-full w-[232px] flex-col" lighter>
<div data-tauri-drag-region className="h-11 w-full shrink-0" />
<div className="scrollbar-hide flex h-full flex-1 flex-col gap-2 overflow-y-auto pb-32">
<div className="inline-flex items-center gap-2 border-l-2 border-transparent pl-4">
@@ -55,7 +56,7 @@ export function SettingsLayout() {
</NavLink>
</div>
</div>
</div>
</Frame>
<div className="h-full w-full flex-1 bg-black/90 backdrop-blur-xl">
<Outlet />
<ScrollRestoration

View File

@@ -1,78 +1,65 @@
import * as Dialog from '@radix-ui/react-dialog';
import { useQueryClient } from '@tanstack/react-query';
import { relaunch } from '@tauri-apps/plugin-process';
import * as AlertDialog from '@radix-ui/react-alert-dialog';
import { useNavigate } from 'react-router-dom';
import { CancelIcon, LogoutIcon } from '@shared/icons';
import { useStorage } from '@libs/storage/provider';
import { useStronghold } from '@stores/stronghold';
export function Logout() {
const queryClient = useQueryClient();
const { db } = useStorage();
const navigate = useNavigate();
const clearPrivkey = useStronghold((state) => state.clearPrivkey);
const logout = async () => {
// reset database
// await removeAll();
// reset react query
queryClient.clear();
// navigate
await relaunch();
// remove account
db.accountLogout();
// clear privkey in session storage
clearPrivkey();
// redirect to welcome screen
navigate('/auth/welcome');
};
return (
<Dialog.Root>
<Dialog.Trigger asChild>
<AlertDialog.Root>
<AlertDialog.Trigger asChild>
<button
type="button"
aria-label="Logout"
className="inline-flex h-9 w-9 transform items-center justify-center rounded-md bg-white/20 active:translate-y-1"
className="inline-flex h-10 items-center rounded-lg px-2 text-sm font-medium text-white hover:bg-white/10"
>
<LogoutIcon className="h-4 w-4 text-white" />
Logout
</button>
</Dialog.Trigger>
<Dialog.Portal className="relative z-10">
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xl" />
<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 rounded-xl bg-white/10 backdrop-blur-xl">
<div className="h-min w-full shrink-0 border-b border-white/10 bg-white/5 px-5 py-6">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Dialog.Title className="text-lg font-semibold leading-none text-white">
Are you sure!
</Dialog.Title>
<Dialog.Close className="inline-flex h-6 w-6 items-center justify-center rounded-md backdrop-blur-xl hover:bg-white/10">
<CancelIcon className="h-4 w-4 text-white/50" />
</Dialog.Close>
</div>
<Dialog.Description className="text-sm leading-tight text-white/50">
<p className="mb-2">
When logout, all local data will be wiped, and restart app then you
need to start onboarding process again when you log in.
</p>
<p>
In the next version, Lume will support multi account, then you can
switch between all account s instead of logout
</p>
</Dialog.Description>
</div>
</AlertDialog.Trigger>
<AlertDialog.Portal className="relative z-10">
<AlertDialog.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-2xl" />
<AlertDialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<div className="relative h-min w-full max-w-md rounded-xl bg-white/10 backdrop-blur-xl">
<div className="flex flex-col gap-2 border-b border-white/5 px-5 py-4">
<AlertDialog.Title className="text-lg font-semibold leading-none text-white">
Are you sure!
</AlertDialog.Title>
<AlertDialog.Description className="text-sm leading-tight text-white/50">
You can always log back in at any time. If you just want to switch
accounts, you can do that by adding an existing account.
</AlertDialog.Description>
</div>
<div className="flex h-full w-full flex-col items-end justify-center overflow-y-auto px-5 py-2.5">
<div className="flex items-center gap-2">
<button
type="button"
className="inline-flex h-9 items-center justify-center rounded-md px-3 text-sm font-medium text-white/50 backdrop-blur-xl hover:bg-white/10"
>
<div className="flex justify-end gap-2 px-5 py-3">
<AlertDialog.Cancel asChild>
<button className="inline-flex h-9 items-center justify-center rounded-md px-4 text-sm font-medium leading-none text-white outline-none hover:bg-white/10 hover:backdrop-blur-xl">
Cancel
</button>
<button
type="button"
onClick={() => logout()}
className="inline-flex h-9 items-center justify-center rounded-md bg-red-500 px-3 text-sm font-medium text-white hover:bg-red-600"
>
Confirm
</button>
</div>
</AlertDialog.Cancel>
<button
type="button"
onClick={() => logout()}
className="inline-flex h-9 items-center justify-center rounded-md bg-white/10 px-4 text-sm font-medium leading-none text-white outline-none hover:bg-fuchsia-500"
>
Logout
</button>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>
);
}

View File

@@ -4,18 +4,33 @@ import { twMerge } from 'tailwind-merge';
import { ChatsList } from '@app/chats/components/list';
import { useStorage } from '@libs/storage/provider';
import { ActiveAccount } from '@shared/accounts/active';
import { ComposerModal } from '@shared/composer';
import { BellIcon, NavArrowDownIcon, SpaceIcon } from '@shared/icons';
import { Frame } from '@shared/frame';
import { BellIcon, NavArrowDownIcon, NwcIcon, SpaceIcon } from '@shared/icons';
import { useActivities } from '@stores/activities';
import { useSidebar } from '@stores/sidebar';
import { compactNumber } from '@utils/number';
export function Navigation() {
const { db } = useStorage();
const [totalNewActivities] = useActivities((state) => [state.totalNewActivities]);
const [chats, toggleChats] = useSidebar((state) => [state.chats, state.toggleChats]);
const [integrations, toggleIntegrations] = useSidebar((state) => [
state.integrations,
state.toggleIntegrations,
]);
return (
<div className="relative flex h-full w-[232px] flex-col bg-black/80">
<div data-tauri-drag-region className="h-11 w-full shrink-0" />
<Frame className="relative flex h-full w-[232px] flex-col" lighter>
{db.platform === 'darwin' ? (
<div data-tauri-drag-region className="h-11 w-full shrink-0" />
) : null}
<div className="scrollbar-hide flex h-full flex-1 flex-col gap-6 overflow-y-auto pb-32">
<div className="flex flex-col pr-2">
<ComposerModal />
@@ -27,7 +42,7 @@ export function Navigation() {
'flex h-10 items-center gap-2.5 rounded-r-lg border-l-2 pl-4 pr-2',
isActive
? 'border-fuchsia-500 bg-white/5 text-white'
: 'border-transparent text-white/80'
: 'border-transparent text-white/70'
)
}
>
@@ -41,19 +56,68 @@ export function Navigation() {
preventScrollReset={true}
className={({ isActive }) =>
twMerge(
'flex h-10 items-center gap-2.5 rounded-r-lg border-l-2 pl-4 pr-2',
'flex h-10 items-center justify-between rounded-r-lg border-l-2 pl-4 pr-2',
isActive
? 'border-fuchsia-500 bg-white/5 text-white'
: 'border-transparent text-white/80'
: 'border-transparent text-white/70'
)
}
>
<span className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded bg-white/10 backdrop-blur-xl">
<BellIcon className="h-4 w-4 text-white" />
</span>
Notifications
<div className="flex items-center gap-2.5">
{totalNewActivities > 0 ? (
<div className="relative inline-flex h-7 w-7 shrink-0 items-center justify-center rounded bg-fuchsia-500/20 backdrop-blur-xl">
<p className="text-sm font-bold text-fuchsia-500">
{compactNumber.format(totalNewActivities)}
</p>
<span className="absolute right-0 top-0 block h-1 w-1 -translate-y-1/2 translate-x-1/2 transform rounded-full bg-fuchsia-500 ring-2 ring-black/80" />
</div>
) : (
<span className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded bg-white/10 backdrop-blur-xl">
<BellIcon className="h-4 w-4 text-white" />
</span>
)}
Notifications
</div>
</NavLink>
</div>
<Collapsible.Root open={integrations} onOpenChange={toggleIntegrations}>
<div className="flex flex-col gap-1 pr-2">
<Collapsible.Trigger asChild>
<button className="flex items-center gap-1 pl-[20px] pr-4">
<div
className={twMerge(
'inline-flex h-5 w-5 transform items-center justify-center transition-transform duration-150 ease-in-out',
integrations ? '' : 'rotate-180'
)}
>
<NavArrowDownIcon className="h-3 w-3 text-white/50" />
</div>
<h3 className="text-[11px] font-bold uppercase tracking-widest text-white/50">
Integrations
</h3>
</button>
</Collapsible.Trigger>
<Collapsible.Content>
<NavLink
to="/nwc"
preventScrollReset={true}
className={({ isActive }) =>
twMerge(
'flex h-10 items-center gap-2.5 rounded-r-lg border-l-2 pl-4 pr-2',
isActive
? 'border-fuchsia-500 bg-white/5 text-white'
: 'border-transparent text-white/70'
)
}
>
<span className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded bg-white/10 backdrop-blur-xl">
<NwcIcon className="h-4 w-4 text-white" />
</span>
Wallet Connect
</NavLink>
</Collapsible.Content>
</div>
</Collapsible.Root>
<Collapsible.Root open={chats} onOpenChange={toggleChats}>
<div className="flex flex-col gap-1 pr-2">
<Collapsible.Trigger asChild>
@@ -77,9 +141,9 @@ export function Navigation() {
</div>
</Collapsible.Root>
</div>
<div className="shrink-0">
<div className="relative shrink-0">
<ActiveAccount />
</div>
</div>
</Frame>
);
}

75
src/shared/nip05.tsx Normal file
View File

@@ -0,0 +1,75 @@
import { useQuery } from '@tanstack/react-query';
import { fetch } from '@tauri-apps/api/http';
import { memo } from 'react';
import { twMerge } from 'tailwind-merge';
import { UnverifiedIcon, VerifiedIcon } from '@shared/icons';
interface NIP05 {
names: {
[key: string]: string;
};
}
export const NIP05 = memo(function NIP05({
pubkey,
nip05,
className,
}: {
pubkey: string;
nip05: string;
className?: string;
}) {
const { status, data } = useQuery(
['nip05', nip05],
async () => {
try {
const localPath = nip05.split('@')[0];
const service = nip05.split('@')[1];
const verifyURL = `https://${service}/.well-known/nostr.json?name=${localPath}`;
const res = await fetch(verifyURL, {
method: 'GET',
timeout: 10,
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
});
if (!res.ok) throw new Error(`Failed to fetch NIP-05 service: ${nip05}`);
const data = res.data as NIP05;
if (data.names) {
if (data.names[localPath] !== pubkey) return false;
return true;
}
return false;
} catch (e) {
throw new Error(`Failed to verify NIP-05, error: ${e}`);
}
},
{
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
staleTime: Infinity,
}
);
if (status === 'loading') {
<div className="h-3 w-20 animate-pulse rounded bg-white/10" />;
}
return (
<div className={twMerge('leadning-none inline-flex items-center gap-1', className)}>
<p>{nip05}</p>
<div className="shrink-0">
{data === true ? (
<VerifiedIcon className="h-3 w-3 text-green-500" />
) : (
<UnverifiedIcon className="h-3 w-3 text-red-500" />
)}
</div>
</div>
);
});

View File

@@ -28,12 +28,12 @@ export function NoteActions({
return (
<Tooltip.Provider>
<div className="-ml-1 mt-4 inline-flex w-full items-center">
<div className="-ml-1 mt-2 inline-flex w-full items-center">
<div className="inline-flex items-center gap-2">
<NoteReply id={id} pubkey={pubkey} root={root} />
<NoteReaction id={id} pubkey={pubkey} />
<NoteRepost id={id} pubkey={pubkey} />
<NoteZap id={id} />
<NoteZap id={id} pubkey={pubkey} />
</div>
{extraButtons && (
<>
@@ -50,7 +50,7 @@ export function NoteActions({
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="-left-10 select-none rounded-md bg-black px-3.5 py-1.5 text-sm leading-none text-white will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade">
Open thread
Focus
<Tooltip.Arrow className="fill-black" />
</Tooltip.Content>
</Tooltip.Portal>
@@ -61,7 +61,7 @@ export function NoteActions({
type="button"
onClick={() =>
setWidget(db, {
kind: WidgetKinds.thread,
kind: WidgetKinds.local.thread,
title: 'Thread',
content: id,
})

View File

@@ -1,6 +1,6 @@
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import * as Tooltip from '@radix-ui/react-tooltip';
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
import { writeText } from '@tauri-apps/api/clipboard';
import { nip19 } from 'nostr-tools';
import { EventPointer } from 'nostr-tools/lib/nip19';
import { useState } from 'react';
@@ -18,8 +18,7 @@ export function MoreActions({ id, pubkey }: { id: string; pubkey: string }) {
const copyLink = async () => {
await writeText(
'https://nostr.com/' +
nip19.neventEncode({ id: id, author: pubkey } as EventPointer)
'https://njump.me/' + nip19.neventEncode({ id: id, author: pubkey } as EventPointer)
);
setOpen(false);
};
@@ -45,7 +44,7 @@ export function MoreActions({ id, pubkey }: { id: string; pubkey: string }) {
</Tooltip.Portal>
</Tooltip.Root>
<DropdownMenu.Portal>
<DropdownMenu.Content className="flex w-[200px] flex-col overflow-hidden rounded-xl bg-white/10 p-2 backdrop-blur-3xl focus:outline-none">
<DropdownMenu.Content className="flex w-[200px] flex-col overflow-hidden rounded-xl border border-white/10 bg-white/20 p-2 backdrop-blur-3xl focus:outline-none">
<DropdownMenu.Item asChild>
<button
type="button"

View File

@@ -1,3 +1,4 @@
import { NDKKind } from '@nostr-dev-kit/ndk';
import * as Popover from '@radix-ui/react-popover';
import { useState } from 'react';
@@ -44,7 +45,7 @@ export function NoteReaction({ id, pubkey }: { id: string; pubkey: string }) {
const event = await publish({
content: content,
kind: 7,
kind: NDKKind.Reaction,
tags: [
['e', id],
['p', pubkey],

View File

@@ -1,25 +1,39 @@
import { NDKKind } from '@nostr-dev-kit/ndk';
import * as AlertDialog from '@radix-ui/react-alert-dialog';
import * as Tooltip from '@radix-ui/react-tooltip';
import { message } from '@tauri-apps/api/dialog';
import { useState } from 'react';
import { twMerge } from 'tailwind-merge';
import { RepostIcon } from '@shared/icons';
import { useNDK } from '@libs/ndk/provider';
import { LoaderIcon, RepostIcon } from '@shared/icons';
import { useNostr } from '@utils/hooks/useNostr';
import { sendNativeNotification } from '@utils/notification';
export function NoteRepost({ id, pubkey }: { id: string; pubkey: string }) {
const { publish } = useNostr();
const { relayUrls } = useNDK();
const [open, setOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isRepost, setIsRepost] = useState(false);
const submit = async () => {
setIsLoading(true);
const tags = [
['e', id, 'wss://relayable.org', 'root'],
['e', id, relayUrls[0], 'root'],
['p', pubkey],
];
const event = await publish({ content: '', kind: 6, tags: tags });
const event = await publish({ content: '', kind: NDKKind.Repost, tags: tags });
if (event) {
setOpen(false);
setIsRepost(true);
await sendNativeNotification('Reposted successfully', 'Lume');
} else {
console.log('failed reposting');
setIsLoading(false);
await message('Repost failed, try again later', { title: 'Lume', type: 'error' });
}
};
@@ -32,7 +46,12 @@ export function NoteRepost({ id, pubkey }: { id: string; pubkey: string }) {
type="button"
className="group inline-flex h-7 w-7 items-center justify-center"
>
<RepostIcon className="h-5 w-5 text-white group-hover:text-blue-400" />
<RepostIcon
className={twMerge(
'h-5 w-5 group-hover:text-blue-400',
isRepost ? 'text-blue-400' : 'text-white'
)}
/>
</button>
</AlertDialog.Trigger>
</Tooltip.Trigger>
@@ -44,30 +63,34 @@ export function NoteRepost({ id, pubkey }: { id: string; pubkey: string }) {
</Tooltip.Portal>
</Tooltip.Root>
<AlertDialog.Portal className="relative z-10">
<AlertDialog.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xl" />
<AlertDialog.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-2xl" />
<AlertDialog.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 rounded-xl bg-white/10 backdrop-blur-xl">
<div className="relative h-min w-full max-w-md rounded-xl bg-white/10 backdrop-blur-xl">
<div className="flex flex-col gap-2 border-b border-white/5 px-5 py-4">
<AlertDialog.Title className="text-lg font-semibold leading-none text-white">
Confirm repost this post?
</AlertDialog.Title>
<AlertDialog.Description className="text-sm leading-none text-white/50">
<AlertDialog.Description className="text-sm leading-tight text-white/50">
Reposted post will be visible to your followers, and you cannot undo this
action.
</AlertDialog.Description>
</div>
<div className="flex justify-end gap-6 px-5 py-3">
<div className="flex justify-end gap-2 px-3 py-3">
<AlertDialog.Cancel asChild>
<button className="inline-flex h-11 items-center justify-center rounded-lg bg-white/10 px-4 font-medium leading-none text-white outline-none backdrop-blur-xl">
<button className="inline-flex h-9 w-20 items-center justify-center rounded-md text-sm font-medium leading-none text-white outline-none hover:bg-white/10 hover:backdrop-blur-xl">
Cancel
</button>
</AlertDialog.Cancel>
<button
type="button"
onClick={() => submit()}
className="inline-flex h-11 items-center justify-center rounded-lg bg-fuchsia-500 px-4 font-medium leading-none text-white outline-none"
className="inline-flex h-9 w-28 items-center justify-center rounded-md bg-white/10 text-sm font-medium leading-none text-white outline-none hover:bg-fuchsia-500"
>
Yes, repost
{isLoading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-white" />
) : (
'Yes, repost'
)}
</button>
</div>
</div>

View File

@@ -1,34 +1,96 @@
import { NostrEvent } from '@nostr-dev-kit/ndk';
import { webln } from '@getalby/sdk';
import { SendPaymentResponse } from '@getalby/sdk/dist/types';
import * as Dialog from '@radix-ui/react-dialog';
import { message } from '@tauri-apps/api/dialog';
import { QRCodeSVG } from 'qrcode.react';
import { useState } from 'react';
import { twMerge } from 'tailwind-merge';
import { useEffect, useRef, useState } from 'react';
import CurrencyInput from 'react-currency-input-field';
import TextareaAutosize from 'react-textarea-autosize';
import { CancelIcon, ZapIcon } from '@shared/icons';
import { useStronghold } from '@stores/stronghold';
import { useEvent } from '@utils/hooks/useEvent';
import { useNostr } from '@utils/hooks/useNostr';
import { useProfile } from '@utils/hooks/useProfile';
import { sendNativeNotification } from '@utils/notification';
import { compactNumber } from '@utils/number';
export function NoteZap({ id }: { id: string }) {
export function NoteZap({ id, pubkey }: { id: string; pubkey: string }) {
const { createZap } = useNostr();
const { user } = useProfile(pubkey);
const { data: event } = useEvent(id);
const [amount, setAmount] = useState<null | number>(null);
const [amount, setAmount] = useState<string>('21');
const [zapMessage, setZapMessage] = useState<string>('');
const [invoice, setInvoice] = useState<null | string>(null);
const [isOpen, setIsOpen] = useState(false);
const [isCompleted, setIsCompleted] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const selected = (num: number) => {
if (amount === num) return true;
return false;
};
const walletConnectURL = useStronghold((state) => state.walletConnectURL);
const nwc = useRef(null);
const createZapRequest = async () => {
// @ts-expect-error, todo: fix this
const res = await createZap(event as unknown as NostrEvent, amount);
if (res) setInvoice(res);
try {
const zapAmount = parseInt(amount) * 1000;
const res = await createZap(event, zapAmount, zapMessage);
if (!res)
return await message('Cannot create zap request', {
title: 'Zap',
type: 'error',
});
// user don't connect nwc, create QR Code for invoice
if (!walletConnectURL) return setInvoice(res);
// user connect nwc
nwc.current = new webln.NostrWebLNProvider({
nostrWalletConnectUrl: walletConnectURL,
});
await nwc.current.enable();
// start loading
setIsLoading(true);
// send payment via nwc
const send: SendPaymentResponse = await nwc.current.sendPayment(res);
if (send) {
await sendNativeNotification(
`You've tipped ${compactNumber.format(send.amount)} sats to ${
user?.display_name || user?.name
}`
);
// eose
nwc.current.close();
setIsCompleted(true);
setIsLoading(false);
// reset after 3 secs
const timeout = setTimeout(() => setIsCompleted(false), 3000);
clearTimeout(timeout);
}
} catch (e) {
nwc.current.close();
setIsLoading(false);
await message(JSON.stringify(e), { title: 'Zap', type: 'error' });
}
};
useEffect(() => {
return () => {
setAmount('21');
setZapMessage('');
setIsCompleted(false);
setIsLoading(false);
};
}, []);
return (
<Dialog.Root>
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
<Dialog.Trigger asChild>
<button
type="button"
@@ -38,113 +100,123 @@ export function NoteZap({ id }: { id: string }) {
</button>
</Dialog.Trigger>
<Dialog.Portal className="relative z-10">
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xl" />
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-2xl" />
<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 rounded-xl bg-white/10 backdrop-blur-xl">
<div className="relative h-min w-full shrink-0 border-b border-white/5 bg-white/5 px-5 py-5">
<div className="flex flex-col items-center gap-1.5">
<Dialog.Title className="font-medium leading-none text-white">
Zap (Beta)
</Dialog.Title>
<Dialog.Description className="text-sm leading-none text-white/50">
Send tip with Bitcoin via Lightning
</Dialog.Description>
</div>
<Dialog.Close className="absolute right-3 top-3 inline-flex h-6 w-6 items-center justify-center rounded-md backdrop-blur-xl hover:bg-white/10">
<div className="inline-flex w-full shrink-0 items-center justify-between px-5 py-3">
<div className="w-6" />
<Dialog.Title className="text-center text-sm font-semibold leading-none text-white">
Send tip to {user?.display_name || user?.name}
</Dialog.Title>
<Dialog.Close className="inline-flex h-6 w-6 items-center justify-center rounded-md backdrop-blur-xl hover:bg-white/10">
<CancelIcon className="h-4 w-4 text-white/50" />
</Dialog.Close>
</div>
<div className="overflow-y-auto overflow-x-hidden px-5 py-5">
<div className="overflow-y-auto overflow-x-hidden px-5 pb-5">
{!invoice ? (
<>
<div className="grid grid-cols-3 gap-3">
<button
type="button"
onClick={() => setAmount(21000)}
className={twMerge(
'inline-flex flex-col items-center justify-center gap-2 rounded-lg px-2 py-2 backdrop-blur-xl hover:bg-white/10',
`${selected(21000) && 'bg-white/10 backdrop-blur-xl'}`
)}
>
<img className="h-12 w-12" src="/zap.png" alt="High Voltage" />
<span className="text-sm font-medium leading-none text-white/80">
21 sats
<div className="relative flex h-40 flex-col">
<div className="inline-flex h-full flex-1 items-center justify-center gap-1">
<CurrencyInput
placeholder="0"
defaultValue={'21'}
value={amount}
decimalsLimit={2}
min={0} // 0 sats
max={10000} // 1M sats
maxLength={10000} // 1M sats
onValueChange={(value) => setAmount(value)}
className="w-full flex-1 bg-transparent text-right text-4xl font-semibold text-white placeholder:text-white/50 focus:outline-none"
/>
<span className="w-full flex-1 text-left text-4xl font-semibold text-white/50">
sats
</span>
</button>
<button
type="button"
onClick={() => setAmount(69000)}
className={twMerge(
'inline-flex flex-col items-center justify-center gap-2 rounded-lg px-2 py-2 backdrop-blur-xl hover:bg-white/10',
`${selected(69000) && 'bg-white/10 backdrop-blur-xl'}`
)}
>
<img className="h-12 w-12" src="/zap.png" alt="High Voltage" />
<span className="text-sm font-medium leading-none text-white/80">
</div>
<div className="inline-flex items-center justify-center gap-2">
<button
type="button"
onClick={() => setAmount('69')}
className="w-max rounded-full border border-white/5 bg-white/5 px-2.5 py-1 text-sm font-medium hover:bg-white/10"
>
69 sats
</span>
</button>
<button
type="button"
onClick={() => setAmount(100000)}
className={twMerge(
'inline-flex flex-col items-center justify-center gap-2 rounded-lg px-2 py-2 backdrop-blur-xl hover:bg-white/10',
`${selected(100000) && 'bg-white/10 backdrop-blur-xl'}`
)}
>
<img className="h-12 w-12" src="/zap.png" alt="High Voltage" />
<span className="text-sm font-medium leading-none text-white/80">
</button>
<button
type="button"
onClick={() => setAmount('100')}
className="w-max rounded-full border border-white/5 bg-white/5 px-2.5 py-1 text-sm font-medium hover:bg-white/10"
>
100 sats
</span>
</button>
<button
type="button"
onClick={() => setAmount(200000)}
className={twMerge(
'inline-flex flex-col items-center justify-center gap-2 rounded-lg px-2 py-2 backdrop-blur-xl hover:bg-white/10',
`${selected(200000) && 'bg-white/10 backdrop-blur-xl'}`
)}
>
<img className="h-12 w-12" src="/zap.png" alt="High Voltage" />
<span className="text-sm font-medium leading-none text-white/80">
</button>
<button
type="button"
onClick={() => setAmount('200')}
className="w-max rounded-full border border-white/5 bg-white/5 px-2.5 py-1 text-sm font-medium hover:bg-white/10"
>
200 sats
</span>
</button>
<button
type="button"
onClick={() => setAmount(500000)}
className={twMerge(
'inline-flex flex-col items-center justify-center gap-2 rounded-lg px-2 py-2 backdrop-blur-xl hover:bg-white/10',
`${selected(500000) && 'bg-white/10 backdrop-blur-xl'}`
)}
>
<img className="h-12 w-12" src="/zap.png" alt="High Voltage" />
<span className="text-sm font-medium leading-none text-white/80">
</button>
<button
type="button"
onClick={() => setAmount('500')}
className="w-max rounded-full border border-white/5 bg-white/5 px-2.5 py-1 text-sm font-medium hover:bg-white/10"
>
500 sats
</span>
</button>
<button
type="button"
onClick={() => setAmount(1000000)}
className={twMerge(
'inline-flex flex-col items-center justify-center gap-2 rounded-lg px-2 py-2 backdrop-blur-xl hover:bg-white/10',
`${selected(1000000) && 'bg-white/10 backdrop-blur-xl'}`
)}
>
<img className="h-12 w-12" src="/zap.png" alt="High Voltage" />
<span className="text-sm font-medium leading-none text-white/80">
1000 sats
</span>
</button>
</button>
<button
type="button"
onClick={() => setAmount('1000')}
className="w-max rounded-full border border-white/5 bg-white/5 px-2.5 py-1 text-sm font-medium hover:bg-white/10"
>
1K sats
</button>
</div>
</div>
<div className="mt-4 flex w-full">
<button
type="button"
onClick={() => createZapRequest()}
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-fuchsia-500 px-4 hover:bg-fuchsia-600"
>
Create invoice
</button>
<div className="mt-4 flex w-full flex-col gap-2">
<TextareaAutosize
name="zapMessage"
value={zapMessage}
onChange={(e) => setZapMessage(e.target.value)}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
placeholder="Enter message (optional)"
className="relative min-h-[56px] w-full resize-none rounded-lg bg-white/10 px-3 py-2 !outline-none backdrop-blur-xl placeholder:text-white/50"
/>
<div className="flex flex-col gap-2">
{walletConnectURL ? (
<button
type="button"
onClick={() => createZapRequest()}
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-fuchsia-500 px-4 font-medium text-white hover:bg-fuchsia-600"
>
{isCompleted ? (
<p>Successfully tipped</p>
) : isLoading ? (
<span className="flex flex-col">
<p className="mb-px leading-none">Waiting for approval</p>
<p className="text-xs leading-none text-white/70">
Go to your wallet and approve payment request
</p>
</span>
) : (
<span className="flex flex-col">
<p className="mb-px leading-none">Send tip</p>
<p className="text-xs leading-none text-white/70">
You&apos;re using nostr wallet connect
</p>
</span>
)}
</button>
) : (
<button
type="button"
onClick={() => createZapRequest()}
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-fuchsia-500 px-4 font-medium hover:bg-fuchsia-600"
>
<p>Create Lightning invoice</p>
</button>
)}
</div>
</div>
</>
) : (

Some files were not shown because too many files have changed in this diff Show More