Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6dc4e1cde6 | ||
|
|
efbdf26706 | ||
|
|
b41ec353c6 | ||
|
|
875225591a | ||
|
|
04c1223f2e | ||
|
|
773e49afa2 | ||
|
|
025d7a623b | ||
|
|
b6caab15e1 | ||
|
|
1d3d0a17dc | ||
|
|
1cbe781698 | ||
|
|
dc5b4f8ac1 | ||
|
|
fee4ad7b98 | ||
|
|
d5647d7452 | ||
|
|
0a5076f06c | ||
|
|
a3632571ff | ||
|
|
5c48ebe103 | ||
|
|
1c3119577f | ||
|
|
0710996a0d | ||
|
|
0cdf199cb5 | ||
|
|
cb9006abb2 | ||
|
|
108ecafab7 | ||
|
|
6b030f2902 | ||
|
|
ce864c8990 | ||
|
|
ee3e8eb105 | ||
|
|
701712e7b8 | ||
|
|
dad388c6ab | ||
|
|
912c740c51 | ||
|
|
da0b48c5df | ||
|
|
64b4745993 | ||
|
|
8aa2ef39c5 | ||
|
|
a945f04959 | ||
|
|
c93989237a | ||
|
|
95cf3f60f3 | ||
|
|
dd21633624 | ||
|
|
f01074ea9f | ||
|
|
c8d04f4695 | ||
|
|
2f8aa66ff6 | ||
|
|
3ad6830bfb | ||
|
|
f2dddf97f5 | ||
|
|
e218ebee89 | ||
|
|
fd5ecc18a9 | ||
|
|
e7738fb128 | ||
|
|
0b25a4a04b | ||
|
|
ace58ecdd5 | ||
|
|
6685d9af38 | ||
|
|
60b803f419 | ||
|
|
555c8ec08a | ||
|
|
42eb882f52 |
@@ -51,8 +51,8 @@ jobs:
|
|||||||
- uses: tauri-apps/tauri-action@dev
|
- uses: tauri-apps/tauri-action@dev
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||||
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||||
ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }}
|
ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }}
|
||||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||||
@@ -62,7 +62,7 @@ jobs:
|
|||||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
with:
|
with:
|
||||||
tagName: v__VERSION__
|
tagName: v__VERSION__
|
||||||
releaseName: 'App v__VERSION__'
|
releaseName: 'v__VERSION__'
|
||||||
releaseBody: 'See the assets to download this version and install.'
|
releaseBody: 'See the assets to download this version and install.'
|
||||||
releaseDraft: true
|
releaseDraft: true
|
||||||
prerelease: false
|
prerelease: false
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "lume",
|
"name": "lume",
|
||||||
"description": "the communication app",
|
"description": "the communication app",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.0.0",
|
"version": "2.1.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
@@ -20,9 +20,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@evilmartians/harmony": "^1.1.0",
|
"@evilmartians/harmony": "^1.1.0",
|
||||||
"@getalby/sdk": "^2.5.0",
|
"@getalby/sdk": "^2.5.0",
|
||||||
"@nostr-dev-kit/ndk": "^2.0.3",
|
"@nostr-dev-kit/ndk": "^2.0.5",
|
||||||
"@nostr-dev-kit/ndk-cache-dexie": "^2.0.3",
|
"@nostr-fetch/adapter-ndk": "^0.13.1",
|
||||||
"@nostr-fetch/adapter-ndk": "^0.13.0",
|
|
||||||
"@radix-ui/react-alert-dialog": "^1.0.5",
|
"@radix-ui/react-alert-dialog": "^1.0.5",
|
||||||
"@radix-ui/react-avatar": "^1.0.4",
|
"@radix-ui/react-avatar": "^1.0.4",
|
||||||
"@radix-ui/react-collapsible": "^1.0.3",
|
"@radix-ui/react-collapsible": "^1.0.3",
|
||||||
@@ -32,22 +31,20 @@
|
|||||||
"@radix-ui/react-popover": "^1.0.7",
|
"@radix-ui/react-popover": "^1.0.7",
|
||||||
"@radix-ui/react-toolbar": "^1.0.4",
|
"@radix-ui/react-toolbar": "^1.0.4",
|
||||||
"@radix-ui/react-tooltip": "^1.0.7",
|
"@radix-ui/react-tooltip": "^1.0.7",
|
||||||
"@tanstack/react-query": "4.36.1",
|
"@tanstack/react-query": "^5.8.1",
|
||||||
"@tauri-apps/api": "2.0.0-alpha.8",
|
"@tauri-apps/api": "2.0.0-alpha.11",
|
||||||
"@tauri-apps/cli": "2.0.0-alpha.15",
|
"@tauri-apps/cli": "2.0.0-alpha.17",
|
||||||
"@tauri-apps/plugin-app": "2.0.0-alpha.1",
|
"@tauri-apps/plugin-clipboard-manager": "2.0.0-alpha.3",
|
||||||
"@tauri-apps/plugin-clipboard-manager": "2.0.0-alpha.1",
|
"@tauri-apps/plugin-dialog": "2.0.0-alpha.3",
|
||||||
"@tauri-apps/plugin-dialog": "2.0.0-alpha.1",
|
"@tauri-apps/plugin-fs": "2.0.0-alpha.3",
|
||||||
"@tauri-apps/plugin-fs": "2.0.0-alpha.1",
|
"@tauri-apps/plugin-http": "2.0.0-alpha.3",
|
||||||
"@tauri-apps/plugin-http": "2.0.0-alpha.1",
|
"@tauri-apps/plugin-notification": "2.0.0-alpha.3",
|
||||||
"@tauri-apps/plugin-notification": "2.0.0-alpha.1",
|
"@tauri-apps/plugin-os": "2.0.0-alpha.4",
|
||||||
"@tauri-apps/plugin-os": "2.0.0-alpha.2",
|
"@tauri-apps/plugin-process": "2.0.0-alpha.3",
|
||||||
"@tauri-apps/plugin-process": "2.0.0-alpha.1",
|
"@tauri-apps/plugin-shell": "2.0.0-alpha.3",
|
||||||
"@tauri-apps/plugin-shell": "2.0.0-alpha.1",
|
"@tauri-apps/plugin-sql": "2.0.0-alpha.3",
|
||||||
"@tauri-apps/plugin-sql": "2.0.0-alpha.1",
|
"@tauri-apps/plugin-updater": "2.0.0-alpha.3",
|
||||||
"@tauri-apps/plugin-updater": "2.0.0-alpha.1",
|
"@tauri-apps/plugin-upload": "2.0.0-alpha.3",
|
||||||
"@tauri-apps/plugin-upload": "2.0.0-alpha.1",
|
|
||||||
"@tauri-apps/plugin-window": "2.0.0-alpha.1",
|
|
||||||
"@tiptap/extension-character-count": "^2.1.12",
|
"@tiptap/extension-character-count": "^2.1.12",
|
||||||
"@tiptap/extension-document": "^2.1.12",
|
"@tiptap/extension-document": "^2.1.12",
|
||||||
"@tiptap/extension-image": "^2.1.12",
|
"@tiptap/extension-image": "^2.1.12",
|
||||||
@@ -60,65 +57,64 @@
|
|||||||
"@tiptap/starter-kit": "^2.1.12",
|
"@tiptap/starter-kit": "^2.1.12",
|
||||||
"@tiptap/suggestion": "^2.1.12",
|
"@tiptap/suggestion": "^2.1.12",
|
||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
"destr": "^2.0.1",
|
|
||||||
"framer-motion": "^10.16.4",
|
"framer-motion": "^10.16.4",
|
||||||
"html-to-text": "^9.0.5",
|
"html-to-text": "^9.0.5",
|
||||||
|
"idb-keyval": "^6.2.1",
|
||||||
"light-bolt11-decoder": "^3.0.0",
|
"light-bolt11-decoder": "^3.0.0",
|
||||||
"lru-cache": "^10.0.1",
|
"lru-cache": "^10.0.2",
|
||||||
"media-chrome": "^1.4.4",
|
"markdown-to-jsx": "^7.3.2",
|
||||||
"million": "^2.6.4",
|
"media-chrome": "^1.5.2",
|
||||||
"minidenticons": "^4.2.0",
|
"minidenticons": "^4.2.0",
|
||||||
"nostr-fetch": "^0.13.0",
|
"nanoid": "^5.0.3",
|
||||||
|
"nostr-fetch": "^0.13.1",
|
||||||
"nostr-tools": "^1.17.0",
|
"nostr-tools": "^1.17.0",
|
||||||
"qrcode.react": "^3.1.0",
|
"qrcode.react": "^3.1.0",
|
||||||
"re-resizable": "^6.9.11",
|
"re-resizable": "^6.9.11",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-currency-input-field": "^3.6.11",
|
"react-currency-input-field": "^3.6.12",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-hook-form": "^7.47.0",
|
"react-hook-form": "^7.48.2",
|
||||||
"react-hotkeys-hook": "^4.4.1",
|
"react-hotkeys-hook": "^4.4.1",
|
||||||
"react-markdown": "^8.0.7",
|
"react-router-dom": "^6.18.0",
|
||||||
"react-router-dom": "^6.17.0",
|
|
||||||
"react-string-replace": "^1.1.1",
|
"react-string-replace": "^1.1.1",
|
||||||
"reactflow": "^11.9.4",
|
"reactflow": "^11.10.1",
|
||||||
"remark-gfm": "^3.0.1",
|
"sonner": "^1.2.0",
|
||||||
"sonner": "^1.0.3",
|
|
||||||
"tailwind-scrollbar": "^3.0.5",
|
"tailwind-scrollbar": "^3.0.5",
|
||||||
"tauri-controls": "^0.2.0",
|
"tauri-controls": "github:reyamir/tauri-controls",
|
||||||
"tippy.js": "^6.3.7",
|
"tippy.js": "^6.3.7",
|
||||||
"tiptap-markdown": "^0.8.2",
|
"tiptap-markdown": "^0.8.4",
|
||||||
"virtua": "^0.15.0",
|
"virtua": "^0.16.2",
|
||||||
"zustand": "^4.4.3"
|
"zustand": "^4.4.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/typography": "^0.5.10",
|
"@tailwindcss/typography": "^0.5.10",
|
||||||
"@trivago/prettier-plugin-sort-imports": "^4.2.0",
|
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
|
||||||
"@types/html-to-text": "^9.0.3",
|
"@types/html-to-text": "^9.0.4",
|
||||||
"@types/node": "^20.8.7",
|
"@types/node": "^20.9.0",
|
||||||
"@types/react": "^18.2.29",
|
"@types/react": "^18.2.37",
|
||||||
"@types/react-dom": "^18.2.14",
|
"@types/react-dom": "^18.2.15",
|
||||||
"@types/youtube-player": "^5.5.9",
|
"@types/youtube-player": "^5.5.10",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.8.0",
|
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
||||||
"@typescript-eslint/parser": "^6.8.0",
|
"@typescript-eslint/parser": "^6.10.0",
|
||||||
"@vitejs/plugin-react-swc": "^3.4.0",
|
"@vitejs/plugin-react-swc": "^3.4.1",
|
||||||
"autoprefixer": "^10.4.16",
|
"autoprefixer": "^10.4.16",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"csstype": "^3.1.2",
|
"csstype": "^3.1.2",
|
||||||
"encoding": "^0.1.13",
|
"encoding": "^0.1.13",
|
||||||
"eslint": "^8.51.0",
|
"eslint": "^8.53.0",
|
||||||
"eslint-config-prettier": "^9.0.0",
|
"eslint-config-prettier": "^9.0.0",
|
||||||
"eslint-plugin-jsx-a11y": "^6.7.1",
|
"eslint-plugin-jsx-a11y": "^6.8.0",
|
||||||
"eslint-plugin-react": "^7.33.2",
|
"eslint-plugin-react": "^7.33.2",
|
||||||
"eslint-plugin-simple-import-sort": "^10.0.0",
|
"eslint-plugin-simple-import-sort": "^10.0.0",
|
||||||
"husky": "^8.0.3",
|
"husky": "^8.0.3",
|
||||||
"lint-staged": "^14.0.1",
|
"lint-staged": "^15.0.2",
|
||||||
"postcss": "^8.4.31",
|
"postcss": "^8.4.31",
|
||||||
"prettier": "^3.0.3",
|
"prettier": "^3.0.3",
|
||||||
"prettier-plugin-tailwindcss": "^0.5.6",
|
"prettier-plugin-tailwindcss": "^0.5.7",
|
||||||
"prop-types": "^15.8.1",
|
"prop-types": "^15.8.1",
|
||||||
"tailwind-merge": "^1.14.0",
|
"tailwind-merge": "^1.14.0",
|
||||||
"tailwindcss": "^3.3.3",
|
"tailwindcss": "^3.3.5",
|
||||||
"typescript": "^5.2.2",
|
"typescript": "^5.2.2",
|
||||||
"vite": "^4.5.0",
|
"vite": "^4.5.0",
|
||||||
"vite-tsconfig-paths": "^4.2.1"
|
"vite-tsconfig-paths": "^4.2.1"
|
||||||
|
|||||||
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 26 KiB |
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lume"
|
name = "lume"
|
||||||
version = "2.0.0"
|
version = "2.1.0"
|
||||||
description = "the communication app"
|
description = "the communication app"
|
||||||
authors = ["Ren Amamiya"]
|
authors = ["Ren Amamiya"]
|
||||||
license = "GPL-3.0"
|
license = "GPL-3.0"
|
||||||
@@ -18,7 +18,6 @@ tauri = { version = "2.0.0-alpha", features = [
|
|||||||
"macos-private-api",
|
"macos-private-api",
|
||||||
"native-tls-vendored",
|
"native-tls-vendored",
|
||||||
] }
|
] }
|
||||||
tauri-plugin-app = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
|
||||||
tauri-plugin-cli = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
tauri-plugin-cli = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
||||||
tauri-plugin-clipboard-manager = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
tauri-plugin-clipboard-manager = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
||||||
tauri-plugin-dialog = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
tauri-plugin-dialog = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
||||||
@@ -29,11 +28,11 @@ tauri-plugin-os = { git = "https://github.com/tauri-apps/plugins-workspace", bra
|
|||||||
tauri-plugin-process = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
tauri-plugin-process = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
||||||
tauri-plugin-shell = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
tauri-plugin-shell = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
||||||
tauri-plugin-updater = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
tauri-plugin-updater = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
||||||
tauri-plugin-window = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
|
||||||
tauri-plugin-single-instance = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
tauri-plugin-single-instance = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
||||||
tauri-plugin-autostart = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
tauri-plugin-autostart = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
||||||
tauri-plugin-store = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
tauri-plugin-store = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
||||||
tauri-plugin-upload = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
tauri-plugin-upload = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
||||||
|
tauri-plugin-window-state = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
||||||
tauri-plugin-sql = { git = "hhttps://github.com/tauri-apps/plugins-workspace", branch = "v2", features = [
|
tauri-plugin-sql = { git = "hhttps://github.com/tauri-apps/plugins-workspace", branch = "v2", features = [
|
||||||
"sqlite",
|
"sqlite",
|
||||||
] }
|
] }
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-- Add migration script here
|
||||||
|
CREATE TABLE
|
||||||
|
ndk_users (
|
||||||
|
pubkey TEXT NOT NULL PRIMARY KEY,
|
||||||
|
profile TEXT,
|
||||||
|
createdAt NUMBER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE
|
||||||
|
ndk_events (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
pubkey TEXT,
|
||||||
|
content TEXT,
|
||||||
|
kind NUMBER,
|
||||||
|
createdAt NUMBER,
|
||||||
|
relay TEXT,
|
||||||
|
event TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE
|
||||||
|
ndk_eventtags (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
eventId TEXT,
|
||||||
|
tag TEXT,
|
||||||
|
value TEXT,
|
||||||
|
tagValue TEXT
|
||||||
|
);
|
||||||
@@ -5,7 +5,6 @@
|
|||||||
|
|
||||||
use keyring::Entry;
|
use keyring::Entry;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tauri::Manager;
|
|
||||||
use tauri_plugin_autostart::MacosLauncher;
|
use tauri_plugin_autostart::MacosLauncher;
|
||||||
use tauri_plugin_sql::{Migration, MigrationKind};
|
use tauri_plugin_sql::{Migration, MigrationKind};
|
||||||
use webpage::{Webpage, WebpageOptions};
|
use webpage::{Webpage, WebpageOptions};
|
||||||
@@ -114,7 +113,31 @@ fn main() {
|
|||||||
.plugin(tauri_plugin_updater::Builder::new().build())?;
|
.plugin(tauri_plugin_updater::Builder::new().build())?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.plugin(tauri_plugin_app::init())
|
.plugin(
|
||||||
|
tauri_plugin_sql::Builder::default()
|
||||||
|
.add_migrations(
|
||||||
|
"sqlite:lume_v2.db",
|
||||||
|
vec![
|
||||||
|
Migration {
|
||||||
|
version: 20230418013219,
|
||||||
|
description: "initial data",
|
||||||
|
sql: include_str!("../migrations/20230418013219_initial_data.sql"),
|
||||||
|
kind: MigrationKind::Up,
|
||||||
|
},
|
||||||
|
Migration {
|
||||||
|
version: 20231028083224,
|
||||||
|
description: "add ndk cache table",
|
||||||
|
sql: include_str!("../migrations/20231028083224_add_ndk_cache_table.sql"),
|
||||||
|
kind: MigrationKind::Up,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.plugin(tauri_plugin_autostart::init(
|
||||||
|
MacosLauncher::LaunchAgent,
|
||||||
|
Some(vec!["--flag1", "--flag2"]),
|
||||||
|
))
|
||||||
.plugin(tauri_plugin_clipboard_manager::init())
|
.plugin(tauri_plugin_clipboard_manager::init())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_fs::init())
|
.plugin(tauri_plugin_fs::init())
|
||||||
@@ -124,33 +147,7 @@ fn main() {
|
|||||||
.plugin(tauri_plugin_process::init())
|
.plugin(tauri_plugin_process::init())
|
||||||
.plugin(tauri_plugin_shell::init())
|
.plugin(tauri_plugin_shell::init())
|
||||||
.plugin(tauri_plugin_upload::init())
|
.plugin(tauri_plugin_upload::init())
|
||||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||||
.plugin(tauri_plugin_window::init())
|
|
||||||
.plugin(
|
|
||||||
tauri_plugin_sql::Builder::default()
|
|
||||||
.add_migrations(
|
|
||||||
"sqlite:lume_v2.db",
|
|
||||||
vec![Migration {
|
|
||||||
version: 20230418013219,
|
|
||||||
description: "initial data",
|
|
||||||
sql: include_str!("../migrations/20230418013219_initial_data.sql"),
|
|
||||||
kind: MigrationKind::Up,
|
|
||||||
}],
|
|
||||||
)
|
|
||||||
.build(),
|
|
||||||
)
|
|
||||||
.plugin(tauri_plugin_autostart::init(
|
|
||||||
MacosLauncher::LaunchAgent,
|
|
||||||
Some(vec!["--flag1", "--flag2"]),
|
|
||||||
))
|
|
||||||
.plugin(tauri_plugin_single_instance::init(|app, argv, cwd| {
|
|
||||||
println!("{}, {argv:?}, {cwd}", app.package_info().name);
|
|
||||||
app
|
|
||||||
.emit_all("single-instance", Payload { args: argv, cwd })
|
|
||||||
.unwrap();
|
|
||||||
}))
|
|
||||||
.plugin(tauri_plugin_upload::init())
|
|
||||||
.plugin(tauri_plugin_store::Builder::default().build())
|
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
opengraph,
|
opengraph,
|
||||||
secure_save,
|
secure_save,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
},
|
},
|
||||||
"package": {
|
"package": {
|
||||||
"productName": "Lume",
|
"productName": "Lume",
|
||||||
"version": "2.0.0"
|
"version": "2.1.0"
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"fs": {
|
"fs": {
|
||||||
@@ -45,15 +45,12 @@
|
|||||||
"tauri": {
|
"tauri": {
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"appimage": {
|
|
||||||
"bundleMediaFramework": true
|
|
||||||
},
|
|
||||||
"category": "SocialNetworking",
|
"category": "SocialNetworking",
|
||||||
"copyright": "",
|
|
||||||
"deb": {
|
"deb": {
|
||||||
"depends": []
|
"depends": []
|
||||||
},
|
},
|
||||||
"externalBin": [],
|
"externalBin": [],
|
||||||
|
"resources": [],
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
"icons/128x128.png",
|
"icons/128x128.png",
|
||||||
@@ -61,8 +58,21 @@
|
|||||||
"icons/icon.icns",
|
"icons/icon.icns",
|
||||||
"icons/icon.ico"
|
"icons/icon.ico"
|
||||||
],
|
],
|
||||||
|
"copyright": "",
|
||||||
"identifier": "com.lume.nu",
|
"identifier": "com.lume.nu",
|
||||||
"longDescription": "",
|
"longDescription": "The communication app build on Nostr Protocol",
|
||||||
|
"shortDescription": "",
|
||||||
|
"targets": "all",
|
||||||
|
"updater": {
|
||||||
|
"active": true,
|
||||||
|
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEU3OTdCMkM3RjU5QzE2NzkKUldSNUZwejF4N0tYNTVHYjMrU0JkL090SlEyNUVLYU5TM2hTU3RXSWtEWngrZWJ4a0pydUhXZHEK",
|
||||||
|
"windows": {
|
||||||
|
"installMode": "quiet"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"appimage": {
|
||||||
|
"bundleMediaFramework": true
|
||||||
|
},
|
||||||
"macOS": {
|
"macOS": {
|
||||||
"entitlements": null,
|
"entitlements": null,
|
||||||
"exceptionDomain": "",
|
"exceptionDomain": "",
|
||||||
@@ -72,10 +82,6 @@
|
|||||||
"providerShortName": null,
|
"providerShortName": null,
|
||||||
"signingIdentity": null
|
"signingIdentity": null
|
||||||
},
|
},
|
||||||
"resources": [],
|
|
||||||
"shortDescription": "",
|
|
||||||
"targets": "all",
|
|
||||||
"updater": {},
|
|
||||||
"windows": {
|
"windows": {
|
||||||
"certificateThumbprint": null,
|
"certificateThumbprint": null,
|
||||||
"digestAlgorithm": "sha256",
|
"digestAlgorithm": "sha256",
|
||||||
|
|||||||
@@ -10,6 +10,10 @@
|
|||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prose :where(iframe):not(:where([class~="not-prose"] *)) {
|
||||||
|
@apply aspect-video w-full h-auto mx-auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { message } from '@tauri-apps/plugin-dialog';
|
import { message } from '@tauri-apps/plugin-dialog';
|
||||||
|
import { fetch } from '@tauri-apps/plugin-http';
|
||||||
import { RouterProvider, createBrowserRouter, defer, redirect } from 'react-router-dom';
|
import { RouterProvider, createBrowserRouter, defer, redirect } from 'react-router-dom';
|
||||||
import { ReactFlowProvider } from 'reactflow';
|
import { ReactFlowProvider } from 'reactflow';
|
||||||
|
|
||||||
@@ -54,8 +55,8 @@ export default function App() {
|
|||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
async lazy() {
|
async lazy() {
|
||||||
const { SpaceScreen } = await import('@app/space');
|
const { HomeScreen } = await import('@app/home');
|
||||||
return { Component: SpaceScreen };
|
return { Component: HomeScreen };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -231,15 +232,50 @@ export default function App() {
|
|||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
async lazy() {
|
async lazy() {
|
||||||
const { GeneralSettingsScreen } = await import('@app/settings/general');
|
const { UserSettingScreen } = await import('@app/settings');
|
||||||
return { Component: GeneralSettingsScreen };
|
return { Component: UserSettingScreen };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'edit-profile',
|
||||||
|
async lazy() {
|
||||||
|
const { EditProfileScreen } = await import('@app/settings/editProfile');
|
||||||
|
return { Component: EditProfileScreen };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'edit-contact',
|
||||||
|
async lazy() {
|
||||||
|
const { EditContactScreen } = await import('@app/settings/editContact');
|
||||||
|
return { Component: EditContactScreen };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'general',
|
||||||
|
async lazy() {
|
||||||
|
const { GeneralSettingScreen } = await import('@app/settings/general');
|
||||||
|
return { Component: GeneralSettingScreen };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'backup',
|
path: 'backup',
|
||||||
async lazy() {
|
async lazy() {
|
||||||
const { AccountSettingsScreen } = await import('@app/settings/account');
|
const { BackupSettingScreen } = await import('@app/settings/backup');
|
||||||
return { Component: AccountSettingsScreen };
|
return { Component: BackupSettingScreen };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'advanced',
|
||||||
|
async lazy() {
|
||||||
|
const { AdvancedSettingScreen } = await import('@app/settings/advanced');
|
||||||
|
return { Component: AdvancedSettingScreen };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'about',
|
||||||
|
async lazy() {
|
||||||
|
const { AboutScreen } = await import('@app/settings/about');
|
||||||
|
return { Component: AboutScreen };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export function Circle() {
|
|||||||
const enableLinks = async () => {
|
const enableLinks = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const users = ndk.getUser({ hexpubkey: db.account.pubkey });
|
const users = ndk.getUser({ pubkey: db.account.pubkey });
|
||||||
const follows = await users.follows();
|
const follows = await users.follows();
|
||||||
|
|
||||||
if (follows.size === 0) {
|
if (follows.size === 0) {
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ export function FavoriteHashtag() {
|
|||||||
<div className="rounded-xl bg-neutral-100 p-3 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200">
|
<div className="rounded-xl bg-neutral-100 p-3 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h5 className="font-semibold">Favorite hashtag</h5>
|
<h5 className="font-semibold">Favorite topic</h5>
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
By adding favorite hashtag, Lume will display all contents related to this
|
By adding favorite topic, Lume will display all contents related to this topic
|
||||||
hashtag as a column
|
for you
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{hashtag ? (
|
{hashtag ? (
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ import { User } from '@shared/user';
|
|||||||
export function FollowList() {
|
export function FollowList() {
|
||||||
const { db } = useStorage();
|
const { db } = useStorage();
|
||||||
const { ndk } = useNDK();
|
const { ndk } = useNDK();
|
||||||
const { status, data } = useQuery(
|
const { status, data } = useQuery({
|
||||||
['follows'],
|
queryKey: ['follows'],
|
||||||
async () => {
|
queryFn: async () => {
|
||||||
const user = ndk.getUser({ hexpubkey: db.account.pubkey });
|
const user = ndk.getUser({ pubkey: db.account.pubkey });
|
||||||
const follows = await user.follows();
|
const follows = await user.follows();
|
||||||
const followsAsArr = [];
|
const followsAsArr = [];
|
||||||
|
|
||||||
@@ -29,16 +29,14 @@ export function FollowList() {
|
|||||||
|
|
||||||
return followsAsArr;
|
return followsAsArr;
|
||||||
},
|
},
|
||||||
{
|
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative rounded-xl bg-neutral-100 p-3 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200">
|
<div className="relative rounded-xl bg-neutral-100 p-3 text-neutral-800 dark:bg-neutral-900 dark:text-neutral-200">
|
||||||
<h5 className="font-semibold">Your follows</h5>
|
<h5 className="font-semibold">Your follows</h5>
|
||||||
<div className="mt-2 flex w-full items-center justify-center">
|
<div className="mt-2 flex w-full items-center justify-center">
|
||||||
{status === 'loading' ? (
|
{status === 'pending' ? (
|
||||||
<LoaderIcon className="h-4 w-4 animate-spin text-neutral-900 dark:text-neutral-100" />
|
<LoaderIcon className="h-4 w-4 animate-spin text-neutral-900 dark:text-neutral-100" />
|
||||||
) : (
|
) : (
|
||||||
<div className="isolate flex -space-x-2">
|
<div className="isolate flex -space-x-2">
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ export function ImportAccountScreen() {
|
|||||||
>
|
>
|
||||||
<h5 className="mb-1.5 font-semibold">Account found</h5>
|
<h5 className="mb-1.5 font-semibold">Account found</h5>
|
||||||
<div className="flex w-full flex-col gap-2">
|
<div className="flex w-full flex-col gap-2">
|
||||||
<div className="flex h-full w-full items-center justify-between rounded-lg bg-neutral-200 p-2">
|
<div className="flex h-full w-full items-center justify-between rounded-lg bg-neutral-200 p-2 dark:bg-neutral-800">
|
||||||
<User pubkey={pubkey} variant="simple" />
|
<User pubkey={pubkey} variant="simple" />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -17,12 +17,15 @@ import { arrayToNIP02 } from '@utils/transform';
|
|||||||
export function OnboardEnrichScreen() {
|
export function OnboardEnrichScreen() {
|
||||||
const { ndk } = useNDK();
|
const { ndk } = useNDK();
|
||||||
const { db } = useStorage();
|
const { db } = useStorage();
|
||||||
const { status, data } = useQuery(['trending-profiles-widget'], async () => {
|
const { status, data } = useQuery({
|
||||||
|
queryKey: ['trending-profiles-widget'],
|
||||||
|
queryFn: async () => {
|
||||||
const res = await fetch('https://api.nostr.band/v0/trending/profiles');
|
const res = await fetch('https://api.nostr.band/v0/trending/profiles');
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error('Error');
|
throw new Error('Error');
|
||||||
}
|
}
|
||||||
return res.json();
|
return res.json();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -90,7 +93,7 @@ export function OnboardEnrichScreen() {
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full flex-nowrap items-center gap-4 overflow-x-auto px-4 scrollbar-none">
|
<div className="flex w-full flex-nowrap items-center gap-4 overflow-x-auto px-4 scrollbar-none">
|
||||||
{status === 'loading' ? (
|
{status === 'pending' ? (
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
<LoaderIcon className="h-4 w-4 animate-spin text-neutral-900 dark:text-neutral-100" />
|
<LoaderIcon className="h-4 w-4 animate-spin text-neutral-900 dark:text-neutral-100" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,71 +2,33 @@ import { message } from '@tauri-apps/plugin-dialog';
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import { useStorage } from '@libs/storage/provider';
|
|
||||||
|
|
||||||
import { ArrowLeftIcon, CheckCircleIcon, LoaderIcon } from '@shared/icons';
|
import { ArrowLeftIcon, CheckCircleIcon, LoaderIcon } from '@shared/icons';
|
||||||
|
|
||||||
|
import { TOPICS, WIDGET_KIND } from '@stores/constants';
|
||||||
import { useOnboarding } from '@stores/onboarding';
|
import { useOnboarding } from '@stores/onboarding';
|
||||||
import { WidgetKinds } from '@stores/widgets';
|
|
||||||
|
|
||||||
const data = [
|
import { useWidget } from '@utils/hooks/useWidget';
|
||||||
{ hashtag: '#bitcoin' },
|
|
||||||
{ hashtag: '#nostr' },
|
|
||||||
{ hashtag: '#nostrdesign' },
|
|
||||||
{ hashtag: '#security' },
|
|
||||||
{ hashtag: '#zap' },
|
|
||||||
{ hashtag: '#LFG' },
|
|
||||||
{ hashtag: '#zapchain' },
|
|
||||||
{ hashtag: '#shitcoin' },
|
|
||||||
{ hashtag: '#plebchain' },
|
|
||||||
{ hashtag: '#nodes' },
|
|
||||||
{ hashtag: '#hodl' },
|
|
||||||
{ hashtag: '#stacksats' },
|
|
||||||
{ hashtag: '#nokyc' },
|
|
||||||
{ hashtag: '#meme' },
|
|
||||||
{ hashtag: '#memes' },
|
|
||||||
{ hashtag: '#memestr' },
|
|
||||||
{ hashtag: '#nostriches' },
|
|
||||||
{ hashtag: '#dev' },
|
|
||||||
{ hashtag: '#anime' },
|
|
||||||
{ hashtag: '#waifu' },
|
|
||||||
{ hashtag: '#manga' },
|
|
||||||
{ hashtag: '#lume' },
|
|
||||||
{ hashtag: '#snort' },
|
|
||||||
{ hashtag: '#damus' },
|
|
||||||
{ hashtag: '#primal' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function OnboardHashtagScreen() {
|
export function OnboardHashtagScreen() {
|
||||||
const { db } = useStorage();
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [tags, setTags] = useState(new Set<string>());
|
const [topic, setTopic] = useState(null);
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const setHashtag = useOnboarding((state) => state.toggleHashtag);
|
const setHashtag = useOnboarding((state) => state.toggleHashtag);
|
||||||
|
|
||||||
const toggleTag = (tag: string) => {
|
const { addWidget } = useWidget();
|
||||||
if (tags.has(tag)) {
|
|
||||||
setTags((prev) => {
|
|
||||||
prev.delete(tag);
|
|
||||||
return new Set(prev);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
if (tags.size >= 3) return;
|
|
||||||
setTags((prev) => new Set(prev.add(tag)));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
for (const tag of tags) {
|
|
||||||
await db.createWidget(WidgetKinds.global.hashtag, tag, tag.replace('#', ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
setHashtag();
|
setHashtag();
|
||||||
|
|
||||||
|
addWidget.mutate({
|
||||||
|
kind: WIDGET_KIND.topic,
|
||||||
|
title: topic.title,
|
||||||
|
content: JSON.stringify(topic.content),
|
||||||
|
});
|
||||||
|
|
||||||
navigate(-1);
|
navigate(-1);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -89,19 +51,19 @@ export function OnboardHashtagScreen() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mx-auto flex w-full max-w-md flex-col gap-10 px-3">
|
<div className="mx-auto flex w-full max-w-md flex-col gap-10 px-3">
|
||||||
<h1 className="text-center text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
|
<h1 className="text-center text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
|
||||||
Choose {tags.size}/3 your favorite hashtag
|
Choose your favorite topic
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex h-[420px] w-full flex-col overflow-y-auto rounded-xl bg-neutral-100 dark:bg-neutral-900">
|
<div className="flex w-full flex-col gap-3">
|
||||||
{data.map((item: { hashtag: string }) => (
|
{TOPICS.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.hashtag}
|
key={item.title}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleTag(item.hashtag)}
|
onClick={() => setTopic(item)}
|
||||||
className="inline-flex items-center justify-between px-4 py-2 hover:bg-neutral-300 dark:hover:bg-neutral-700"
|
className="inline-flex h-14 items-center justify-between rounded-xl bg-neutral-100 px-4 hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||||
>
|
>
|
||||||
<p className="text-neutral-900 dark:text-neutral-100">{item.hashtag}</p>
|
<p className="font-medium">{item.title}</p>
|
||||||
{tags.has(item.hashtag) && (
|
{topic && topic.title === item.title && (
|
||||||
<div>
|
<div>
|
||||||
<CheckCircleIcon className="h-5 w-5 text-teal-500" />
|
<CheckCircleIcon className="h-5 w-5 text-teal-500" />
|
||||||
</div>
|
</div>
|
||||||
@@ -112,7 +74,7 @@ export function OnboardHashtagScreen() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
disabled={loading || tags.size === 0}
|
disabled={loading || !topic}
|
||||||
className="inline-flex h-9 w-full items-center justify-center gap-2 rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600 focus:outline-none disabled:opacity-50"
|
className="inline-flex h-9 w-full items-center justify-center gap-2 rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600 focus:outline-none disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -121,7 +83,7 @@ export function OnboardHashtagScreen() {
|
|||||||
<span>Adding...</span>
|
<span>Adding...</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span>Add {tags.size} tags & Continue</span>
|
<span>Add & Continue</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function OnboardingListScreen() {
|
|||||||
<div className="relative flex h-full w-full items-center justify-center">
|
<div className="relative flex h-full w-full items-center justify-center">
|
||||||
<div className="mx-auto flex w-full max-w-md flex-col gap-10">
|
<div className="mx-auto flex w-full max-w-md flex-col gap-10">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h1 className="text-2xl text-neutral-900 dark:text-neutral-100">
|
<h1 className="text-2xl font-light text-neutral-900 dark:text-neutral-100">
|
||||||
You're almost ready to use Lume.
|
You're almost ready to use Lume.
|
||||||
</h1>
|
</h1>
|
||||||
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
|
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
|
||||||
|
|||||||
@@ -24,15 +24,13 @@ export function OnboardRelaysScreen() {
|
|||||||
const { db } = useStorage();
|
const { db } = useStorage();
|
||||||
const { ndk } = useNDK();
|
const { ndk } = useNDK();
|
||||||
const { getAllRelaysByUsers } = useNostr();
|
const { getAllRelaysByUsers } = useNostr();
|
||||||
const { status, data } = useQuery(
|
const { status, data } = useQuery({
|
||||||
['relays'],
|
queryKey: ['relays'],
|
||||||
async () => {
|
queryFn: async () => {
|
||||||
return await getAllRelaysByUsers();
|
return await getAllRelaysByUsers();
|
||||||
},
|
},
|
||||||
{
|
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const toggleRelay = (relay: string) => {
|
const toggleRelay = (relay: string) => {
|
||||||
if (relays.has(relay)) {
|
if (relays.has(relay)) {
|
||||||
@@ -89,7 +87,7 @@ export function OnboardRelaysScreen() {
|
|||||||
</h1>
|
</h1>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex h-[420px] w-full flex-col overflow-y-auto rounded-xl bg-neutral-100 dark:bg-neutral-900">
|
<div className="flex h-[420px] w-full flex-col overflow-y-auto rounded-xl bg-neutral-100 dark:bg-neutral-900">
|
||||||
{status === 'loading' ? (
|
{status === 'pending' ? (
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
<LoaderIcon className="h-4 w-4 animate-spin text-neutral-900 dark:text-neutral-100" />
|
<LoaderIcon className="h-4 w-4 animate-spin text-neutral-900 dark:text-neutral-100" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,8 +22,12 @@ export function ChatScreen() {
|
|||||||
const { ndk } = useNDK();
|
const { ndk } = useNDK();
|
||||||
const { pubkey } = useParams();
|
const { pubkey } = useParams();
|
||||||
const { fetchNIP04Messages } = useNostr();
|
const { fetchNIP04Messages } = useNostr();
|
||||||
const { status, data } = useQuery(['nip04-dm', pubkey], async () => {
|
const { status, data } = useQuery({
|
||||||
|
queryKey: ['nip04-dm', pubkey],
|
||||||
|
queryFn: async () => {
|
||||||
return await fetchNIP04Messages(pubkey);
|
return await fetchNIP04Messages(pubkey);
|
||||||
|
},
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
@@ -69,7 +73,7 @@ export function ChatScreen() {
|
|||||||
<User pubkey={pubkey} variant="simple" />
|
<User pubkey={pubkey} variant="simple" />
|
||||||
</div>
|
</div>
|
||||||
<div className="h-full w-full flex-1 px-3 py-3">
|
<div className="h-full w-full flex-1 px-3 py-3">
|
||||||
{status === 'loading' ? (
|
{status === 'pending' ? (
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
<div className="flex flex-col items-center gap-1.5">
|
<div className="flex flex-col items-center gap-1.5">
|
||||||
<LoaderIcon className="h-5 w-5 animate-spin text-neutral-900 dark:text-neutral-100" />
|
<LoaderIcon className="h-5 w-5 animate-spin text-neutral-900 dark:text-neutral-100" />
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const ChatListItem = memo(function ChatListItem({ event }: { event: NDKEv
|
|||||||
const svgURI =
|
const svgURI =
|
||||||
'data:image/svg+xml;utf8,' + encodeURIComponent(minidenticon(event.pubkey, 90, 50));
|
'data:image/svg+xml;utf8,' + encodeURIComponent(minidenticon(event.pubkey, 90, 50));
|
||||||
|
|
||||||
if (status === 'loading') {
|
if (status === 'pending') {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2.5 rounded-md px-3">
|
<div className="flex items-center gap-2.5 rounded-md px-3">
|
||||||
<div className="h-10 w-10 shrink-0 animate-pulse rounded-lg bg-neutral-400 dark:bg-neutral-600" />
|
<div className="h-10 w-10 shrink-0 animate-pulse rounded-lg bg-neutral-400 dark:bg-neutral-600" />
|
||||||
|
|||||||
@@ -15,11 +15,13 @@ export function MediaUploader({
|
|||||||
|
|
||||||
const uploadMedia = async () => {
|
const uploadMedia = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const image = await upload(null);
|
|
||||||
if (image.url) {
|
const image = await upload(['mp4', 'mp3', 'webm', 'mkv', 'avi', 'mov']);
|
||||||
setState((prev: string) => `${prev}\n${image.url}`);
|
|
||||||
}
|
if (image) {
|
||||||
|
setState((prev: string) => `${prev}\n${image}`);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,13 +3,8 @@ import { twMerge } from 'tailwind-merge';
|
|||||||
|
|
||||||
import { useDecryptMessage } from '@app/chats/hooks/useDecryptMessage';
|
import { useDecryptMessage } from '@app/chats/hooks/useDecryptMessage';
|
||||||
|
|
||||||
import { ImagePreview, LinkPreview, MentionNote, VideoPreview } from '@shared/notes';
|
|
||||||
|
|
||||||
import { parser } from '@utils/parser';
|
|
||||||
|
|
||||||
export function ChatMessage({ message, self }: { message: NDKEvent; self: boolean }) {
|
export function ChatMessage({ message, self }: { message: NDKEvent; self: boolean }) {
|
||||||
const decryptedContent = useDecryptMessage(message);
|
const decryptedContent = useDecryptMessage(message);
|
||||||
const richContent = parser(decryptedContent) ?? null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -20,20 +15,11 @@ export function ChatMessage({ message, self }: { message: NDKEvent; self: boolea
|
|||||||
: 'rounded-r-xl bg-neutral-200 text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100'
|
: 'rounded-r-xl bg-neutral-200 text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!richContent ? (
|
{!decryptedContent ? (
|
||||||
<p>Decrypting...</p>
|
<p>Decrypting...</p>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<p className="select-text whitespace-pre-line">{richContent.parsed}</p>
|
<p className="select-text whitespace-pre-line">{decryptedContent}</p>
|
||||||
<div>
|
|
||||||
{richContent.images.length > 0 && <ImagePreview urls={richContent.images} />}
|
|
||||||
{richContent.videos.length > 0 && <VideoPreview urls={richContent.videos} />}
|
|
||||||
{richContent.links.length > 0 && <LinkPreview urls={richContent.links} />}
|
|
||||||
{richContent.notes.length > 0 &&
|
|
||||||
richContent.notes.map((note: string) => (
|
|
||||||
<MentionNote key={note} id={note} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export function useDecryptMessage(message: NDKEvent) {
|
|||||||
async function decryptContent() {
|
async function decryptContent() {
|
||||||
try {
|
try {
|
||||||
const sender = new NDKUser({
|
const sender = new NDKUser({
|
||||||
hexpubkey:
|
pubkey:
|
||||||
db.account.pubkey === message.pubkey
|
db.account.pubkey === message.pubkey
|
||||||
? message.tags.find((el) => el[0] === 'p')[1]
|
? message.tags.find((el) => el[0] === 'p')[1]
|
||||||
: message.pubkey,
|
: message.pubkey,
|
||||||
|
|||||||
@@ -11,18 +11,16 @@ import { useNostr } from '@utils/hooks/useNostr';
|
|||||||
|
|
||||||
export function ChatsScreen() {
|
export function ChatsScreen() {
|
||||||
const { getAllNIP04Chats } = useNostr();
|
const { getAllNIP04Chats } = useNostr();
|
||||||
const { status, data } = useQuery(
|
const { status, data } = useQuery({
|
||||||
['nip04-chats'],
|
queryKey: ['nip04-chats'],
|
||||||
async () => {
|
queryFn: async () => {
|
||||||
return await getAllNIP04Chats();
|
return await getAllNIP04Chats();
|
||||||
},
|
},
|
||||||
{
|
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnMount: false,
|
refetchOnMount: false,
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
(event: NDKEvent) => {
|
(event: NDKEvent) => {
|
||||||
@@ -43,7 +41,7 @@ export function ChatsScreen() {
|
|||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex h-full flex-col gap-1">
|
<div className="flex h-full flex-col gap-1">
|
||||||
{status === 'loading' ? (
|
{status === 'pending' ? (
|
||||||
<div className="flex h-full w-full items-center justify-center pb-16">
|
<div className="flex h-full w-full items-center justify-center pb-16">
|
||||||
<div className="inline-flex flex-col items-center justify-center gap-2">
|
<div className="inline-flex flex-col items-center justify-center gap-2">
|
||||||
<LoaderIcon className="h-5 w-5 animate-spin text-neutral-900 dark:text-neutral-100" />
|
<LoaderIcon className="h-5 w-5 animate-spin text-neutral-900 dark:text-neutral-100" />
|
||||||
|
|||||||
@@ -1,93 +1,138 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { downloadDir } from '@tauri-apps/api/path';
|
||||||
import { useLocation, useRouteError } from 'react-router-dom';
|
import { message, save } from '@tauri-apps/plugin-dialog';
|
||||||
|
import { writeTextFile } from '@tauri-apps/plugin-fs';
|
||||||
|
import { relaunch } from '@tauri-apps/plugin-process';
|
||||||
|
import { useRouteError } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { useStorage } from '@libs/storage/provider';
|
||||||
|
|
||||||
interface RouteError {
|
interface RouteError {
|
||||||
statusText: string;
|
statusText: string;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DebugInfo {
|
|
||||||
os: null | string;
|
|
||||||
version: null | string;
|
|
||||||
appDir: null | string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ErrorScreen() {
|
export function ErrorScreen() {
|
||||||
|
const { db } = useStorage();
|
||||||
const error = useRouteError() as RouteError;
|
const error = useRouteError() as RouteError;
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
const [debugInfo, setDebugInfo] = useState<DebugInfo>({
|
const restart = async () => {
|
||||||
os: null,
|
await relaunch();
|
||||||
version: null,
|
};
|
||||||
appDir: null,
|
|
||||||
|
const download = async () => {
|
||||||
|
try {
|
||||||
|
const downloadPath = await downloadDir();
|
||||||
|
const fileName = `nostr_keys_${new Date().toISOString()}.txt`;
|
||||||
|
const filePath = await save({
|
||||||
|
defaultPath: downloadPath + '/' + fileName,
|
||||||
});
|
});
|
||||||
|
const nsec = await db.secureLoad(db.account.pubkey);
|
||||||
|
|
||||||
useEffect(() => {
|
if (filePath) {
|
||||||
async function getInformation() {
|
if (nsec) {
|
||||||
const { platform, version } = await import('@tauri-apps/plugin-os');
|
await writeTextFile(
|
||||||
const { getVersion } = await import('@tauri-apps/plugin-app');
|
filePath,
|
||||||
const { appConfigDir } = await import('@tauri-apps/api/path');
|
`Nostr account, generated by Lume (lume.nu)\nPublic key: ${db.account.id}\nPrivate key: ${nsec}`
|
||||||
|
);
|
||||||
const platformName = await platform();
|
} else {
|
||||||
const osVersion = await version();
|
await writeTextFile(
|
||||||
const appVersion = await getVersion();
|
filePath,
|
||||||
const appDir = await appConfigDir();
|
`Nostr account, generated by Lume (lume.nu)\nPublic key: ${db.account.id}`
|
||||||
|
);
|
||||||
setDebugInfo({
|
|
||||||
os: platformName + ' ' + osVersion,
|
|
||||||
version: appVersion,
|
|
||||||
appDir: appDir,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
} // else { user cancel action }
|
||||||
getInformation();
|
} catch (e) {
|
||||||
}, []);
|
await message(e, { title: 'Cannot download account keys', type: 'error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div
|
||||||
<div className="flex w-full flex-col gap-4 px-4 md:max-w-lg md:px-0">
|
data-tauri-drag-region
|
||||||
|
className="relative flex h-screen w-screen items-center justify-center bg-blue-600"
|
||||||
|
>
|
||||||
|
<div className="flex w-full max-w-2xl flex-col items-start gap-8">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<h1 className="mb-1 text-2xl font-semibold text-white">
|
<h1 className="mb-3 text-4xl font-semibold text-blue-400">
|
||||||
Sorry, an unexpected error has occurred.
|
Sorry, an unexpected error has occurred.
|
||||||
</h1>
|
</h1>
|
||||||
<div className="mt-4 inline-flex h-16 items-center justify-center rounded-xl border border-dashed border-red-400 bg-red-200/10 px-5">
|
<h3 className="text-3xl font-semibold leading-snug text-white">
|
||||||
<p className="select-text text-sm font-medium text-red-400">
|
Don't be panic, your account is safe.
|
||||||
{error.statusText || error.message}
|
<br />
|
||||||
</p>
|
Here are what things you can do:
|
||||||
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4">
|
<div className="flex w-full flex-col gap-3">
|
||||||
<p className="font-medium text-neutral-600 dark:text-neutral-400">
|
<div className="flex items-center justify-between rounded-xl bg-blue-700 px-3 py-4">
|
||||||
Current location: {location.pathname}
|
<div className="text-xl font-semibold text-white">
|
||||||
</p>
|
1. Try close and re-open app
|
||||||
<p className="font-medium text-neutral-600 dark:text-neutral-400">
|
|
||||||
App version: {debugInfo.version}
|
|
||||||
</p>
|
|
||||||
<p className="font-medium text-neutral-600 dark:text-neutral-400">
|
|
||||||
Platform: {debugInfo.os}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => restart()}
|
||||||
|
className="h-9 w-28 rounded-lg bg-blue-800 px-3 font-medium text-white hover:bg-blue-900"
|
||||||
|
>
|
||||||
|
Restart
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between rounded-xl bg-blue-700 px-3 py-4">
|
||||||
|
<div className="text-xl font-semibold text-white">
|
||||||
|
2. Backup Nostr account
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => download()}
|
||||||
|
className="h-9 w-28 rounded-lg bg-blue-800 px-3 font-medium text-white hover:bg-blue-900"
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl bg-blue-700 px-3 py-4">
|
||||||
|
<div className="flex w-full flex-col gap-2">
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
<div className="text-xl font-semibold text-white">
|
||||||
|
3. Report this issue to Lume's Devs
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<a
|
<a
|
||||||
href="https://github.com/luminous-devs/lume/issues/new"
|
href="https://github.com/luminous-devs/lume/issues/new"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
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"
|
className="inline-flex h-9 w-28 items-center justify-center rounded-lg bg-blue-800 px-3 font-medium text-white hover:bg-blue-900"
|
||||||
>
|
>
|
||||||
Click here to report the issue on GitHub
|
Report
|
||||||
</a>
|
</a>
|
||||||
<button
|
</div>
|
||||||
type="button"
|
<div className="inline-flex h-16 items-center justify-center overflow-y-auto rounded-lg border border-dashed border-red-300 bg-blue-800 px-5">
|
||||||
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"
|
<p className="select-text break-all text-red-400">
|
||||||
>
|
{error.statusText || error.message}
|
||||||
Reload app
|
</p>
|
||||||
</button>
|
</div>
|
||||||
<button
|
</div>
|
||||||
type="button"
|
</div>
|
||||||
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"
|
<div className="rounded-xl bg-blue-700 px-3 py-4">
|
||||||
>
|
<div className="flex w-full flex-col gap-1.5">
|
||||||
Reset app
|
<div className="text-xl font-semibold text-white">
|
||||||
</button>
|
4. Use other Nostr client
|
||||||
|
</div>
|
||||||
|
<div className="select-text text-lg font-medium text-blue-300">
|
||||||
|
<p>
|
||||||
|
While waiting Lume's Devs release the bug fixes, you always can use
|
||||||
|
other Nostr client with your account:
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex flex-col gap-1 text-white">
|
||||||
|
<a href="https://snort.social" className="hover:!underline">
|
||||||
|
snort.social
|
||||||
|
</a>
|
||||||
|
<a href="https://primal.net" className="hover:!underline">
|
||||||
|
primal.net
|
||||||
|
</a>
|
||||||
|
<a href="https://nostrudel.ninja" className="hover:!underline">
|
||||||
|
nostrudel.ninja
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useProfile } from '@utils/hooks/useProfile';
|
|||||||
export const GroupTitle = memo(function GroupTitle({ pubkey }: { pubkey: string }) {
|
export const GroupTitle = memo(function GroupTitle({ pubkey }: { pubkey: string }) {
|
||||||
const { status, user } = useProfile(pubkey);
|
const { status, user } = useProfile(pubkey);
|
||||||
|
|
||||||
if (status === 'loading') {
|
if (status === 'pending') {
|
||||||
return <div className="h-3 w-24 animate-pulse rounded bg-white/10" />;
|
return <div className="h-3 w-24 animate-pulse rounded bg-white/10" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,52 +3,29 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
import { LoaderIcon } from '@shared/icons';
|
import { LoaderIcon } from '@shared/icons';
|
||||||
import {
|
import { MemoizedRepost, MemoizedTextNote, UnknownNote } from '@shared/notes';
|
||||||
ArticleNote,
|
|
||||||
FileNote,
|
|
||||||
NoteWrapper,
|
|
||||||
Repost,
|
|
||||||
TextNote,
|
|
||||||
UnknownNote,
|
|
||||||
} from '@shared/notes';
|
|
||||||
|
|
||||||
import { useNostr } from '@utils/hooks/useNostr';
|
import { useNostr } from '@utils/hooks/useNostr';
|
||||||
|
|
||||||
export function UserLatestPosts({ pubkey }: { pubkey: string }) {
|
export function UserLatestPosts({ pubkey }: { pubkey: string }) {
|
||||||
const { getEventsByPubkey } = useNostr();
|
const { getEventsByPubkey } = useNostr();
|
||||||
const { status, data } = useQuery(['user-posts', pubkey], async () => {
|
const { status, data } = useQuery({
|
||||||
|
queryKey: ['user-posts', pubkey],
|
||||||
|
queryFn: async () => {
|
||||||
return await getEventsByPubkey(pubkey);
|
return await getEventsByPubkey(pubkey);
|
||||||
|
},
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
(event: NDKEvent) => {
|
(event: NDKEvent) => {
|
||||||
switch (event.kind) {
|
switch (event.kind) {
|
||||||
case NDKKind.Text:
|
case NDKKind.Text:
|
||||||
return (
|
return <MemoizedTextNote key={event.id} event={event} />;
|
||||||
<NoteWrapper key={event.id} event={event}>
|
|
||||||
<TextNote />
|
|
||||||
</NoteWrapper>
|
|
||||||
);
|
|
||||||
case NDKKind.Repost:
|
case NDKKind.Repost:
|
||||||
return <Repost key={event.id} event={event} />;
|
return <MemoizedRepost key={event.id} event={event} />;
|
||||||
case 1063:
|
|
||||||
return (
|
|
||||||
<NoteWrapper key={event.id} event={event}>
|
|
||||||
<FileNote />
|
|
||||||
</NoteWrapper>
|
|
||||||
);
|
|
||||||
case NDKKind.Article:
|
|
||||||
return (
|
|
||||||
<NoteWrapper key={event.id} event={event}>
|
|
||||||
<ArticleNote />
|
|
||||||
</NoteWrapper>
|
|
||||||
);
|
|
||||||
default:
|
default:
|
||||||
return (
|
return <UnknownNote key={event.id} event={event} />;
|
||||||
<NoteWrapper key={event.id} event={event}>
|
|
||||||
<UnknownNote />
|
|
||||||
</NoteWrapper>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[data]
|
[data]
|
||||||
@@ -60,7 +37,7 @@ export function UserLatestPosts({ pubkey }: { pubkey: string }) {
|
|||||||
Latest post
|
Latest post
|
||||||
</h3>
|
</h3>
|
||||||
<div>
|
<div>
|
||||||
{status === 'loading' ? (
|
{status === 'pending' ? (
|
||||||
<div className="px-3">
|
<div className="px-3">
|
||||||
<div className="inline-flex h-16 w-full items-center justify-center gap-1.5 rounded-lg bg-neutral-300 text-sm font-medium dark:bg-neutral-700">
|
<div className="inline-flex h-16 w-full items-center justify-center gap-1.5 rounded-lg bg-neutral-300 text-sm font-medium dark:bg-neutral-700">
|
||||||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||||
|
|||||||
@@ -29,9 +29,9 @@ export const UserWithDrawer = memo(function UserWithDrawer({
|
|||||||
|
|
||||||
const follow = async (pubkey: string) => {
|
const follow = async (pubkey: string) => {
|
||||||
try {
|
try {
|
||||||
const user = ndk.getUser({ hexpubkey: db.account.pubkey });
|
const user = ndk.getUser({ pubkey: db.account.pubkey });
|
||||||
const contacts = await user.follows();
|
const contacts = await user.follows();
|
||||||
const add = await user.follow(new NDKUser({ hexpubkey: pubkey }), contacts);
|
const add = await user.follow(new NDKUser({ pubkey: pubkey }), contacts);
|
||||||
|
|
||||||
if (add) {
|
if (add) {
|
||||||
setFollowed(true);
|
setFollowed(true);
|
||||||
@@ -45,14 +45,12 @@ export const UserWithDrawer = memo(function UserWithDrawer({
|
|||||||
|
|
||||||
const unfollow = async (pubkey: string) => {
|
const unfollow = async (pubkey: string) => {
|
||||||
try {
|
try {
|
||||||
const user = ndk.getUser({ hexpubkey: db.account.pubkey });
|
const user = ndk.getUser({ pubkey: db.account.pubkey });
|
||||||
const contacts = await user.follows();
|
const contacts = await user.follows();
|
||||||
contacts.delete(new NDKUser({ hexpubkey: pubkey }));
|
contacts.delete(new NDKUser({ pubkey: pubkey }));
|
||||||
|
|
||||||
let list: string[][];
|
let list: string[][];
|
||||||
contacts.forEach((el) =>
|
contacts.forEach((el) => list.push(['p', el.pubkey, el.relayUrls?.[0] || '', '']));
|
||||||
list.push(['p', el.pubkey, el.relayUrls?.[0] || '', el.profile?.name || ''])
|
|
||||||
);
|
|
||||||
|
|
||||||
const event = new NDKEvent(ndk);
|
const event = new NDKEvent(ndk);
|
||||||
event.content = '';
|
event.content = '';
|
||||||
@@ -84,7 +82,7 @@ export const UserWithDrawer = memo(function UserWithDrawer({
|
|||||||
<Dialog.Portal>
|
<Dialog.Portal>
|
||||||
<Dialog.Content className="fixed right-0 top-0 z-50 flex h-full w-[400px] animate-slideRightAndFade items-center justify-center px-4 pb-4 pt-16 transition-all">
|
<Dialog.Content className="fixed right-0 top-0 z-50 flex h-full w-[400px] animate-slideRightAndFade items-center justify-center px-4 pb-4 pt-16 transition-all">
|
||||||
<div className="h-full w-full overflow-y-auto rounded-lg border border-neutral-300 bg-neutral-200 py-3 dark:border-neutral-700 dark:bg-neutral-800">
|
<div className="h-full w-full overflow-y-auto rounded-lg border border-neutral-300 bg-neutral-200 py-3 dark:border-neutral-700 dark:bg-neutral-800">
|
||||||
{status === 'loading' ? (
|
{status === 'pending' ? (
|
||||||
<div>
|
<div>
|
||||||
<p>Loading...</p>
|
<p>Loading...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
import { VList, VListHandle } from 'virtua';
|
||||||
|
|
||||||
|
import { useStorage } from '@libs/storage/provider';
|
||||||
|
|
||||||
|
import { LoaderIcon } from '@shared/icons';
|
||||||
|
import {
|
||||||
|
ArticleWidget,
|
||||||
|
FileWidget,
|
||||||
|
GroupWidget,
|
||||||
|
HashtagWidget,
|
||||||
|
NewsfeedWidget,
|
||||||
|
NotificationWidget,
|
||||||
|
ThreadWidget,
|
||||||
|
ToggleWidgetList,
|
||||||
|
TopicWidget,
|
||||||
|
TrendingAccountsWidget,
|
||||||
|
TrendingNotesWidget,
|
||||||
|
UserWidget,
|
||||||
|
WidgetList,
|
||||||
|
} from '@shared/widgets';
|
||||||
|
|
||||||
|
import { WIDGET_KIND } from '@stores/constants';
|
||||||
|
|
||||||
|
import { Widget } from '@utils/types';
|
||||||
|
|
||||||
|
export function HomeScreen() {
|
||||||
|
const ref = useRef<VListHandle>(null);
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||||
|
|
||||||
|
const { db } = useStorage();
|
||||||
|
const { status, data } = useQuery({
|
||||||
|
queryKey: ['widgets'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const dbWidgets = await db.getWidgets();
|
||||||
|
const defaultWidgets = [
|
||||||
|
{
|
||||||
|
id: '9998',
|
||||||
|
title: 'Notification',
|
||||||
|
content: '',
|
||||||
|
kind: WIDGET_KIND.notification,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '9999',
|
||||||
|
title: 'Newsfeed',
|
||||||
|
content: '',
|
||||||
|
kind: WIDGET_KIND.newsfeed,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return [...defaultWidgets, ...dbWidgets];
|
||||||
|
},
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderItem = useCallback((widget: Widget) => {
|
||||||
|
switch (widget.kind) {
|
||||||
|
case WIDGET_KIND.notification:
|
||||||
|
return <NotificationWidget key={widget.id} />;
|
||||||
|
case WIDGET_KIND.newsfeed:
|
||||||
|
return <NewsfeedWidget key={widget.id} />;
|
||||||
|
case WIDGET_KIND.topic:
|
||||||
|
return <TopicWidget key={widget.id} widget={widget} />;
|
||||||
|
case WIDGET_KIND.user:
|
||||||
|
return <UserWidget key={widget.id} widget={widget} />;
|
||||||
|
case WIDGET_KIND.thread:
|
||||||
|
return <ThreadWidget key={widget.id} widget={widget} />;
|
||||||
|
case WIDGET_KIND.article:
|
||||||
|
return <ArticleWidget key={widget.id} widget={widget} />;
|
||||||
|
case WIDGET_KIND.file:
|
||||||
|
return <FileWidget key={widget.id} widget={widget} />;
|
||||||
|
case WIDGET_KIND.hashtag:
|
||||||
|
return <HashtagWidget key={widget.id} widget={widget} />;
|
||||||
|
case WIDGET_KIND.group:
|
||||||
|
return <GroupWidget key={widget.id} widget={widget} />;
|
||||||
|
case WIDGET_KIND.trendingNotes:
|
||||||
|
return <TrendingNotesWidget key={widget.id} widget={widget} />;
|
||||||
|
case WIDGET_KIND.trendingAccounts:
|
||||||
|
return <TrendingAccountsWidget key={widget.id} widget={widget} />;
|
||||||
|
case WIDGET_KIND.list:
|
||||||
|
return <WidgetList key={widget.id} widget={widget} />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (status === 'pending') {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center justify-center bg-white dark:bg-black">
|
||||||
|
<LoaderIcon className="h-5 w-5 animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VList
|
||||||
|
ref={ref}
|
||||||
|
className="h-full w-full flex-nowrap overflow-x-auto !overflow-y-hidden scrollbar-none focus:outline-none"
|
||||||
|
initialItemSize={420}
|
||||||
|
tabIndex={0}
|
||||||
|
horizontal
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (!ref.current) return;
|
||||||
|
switch (e.code) {
|
||||||
|
case 'ArrowUp':
|
||||||
|
case 'ArrowLeft': {
|
||||||
|
e.preventDefault();
|
||||||
|
const prevIndex = Math.max(selectedIndex - 1, 0);
|
||||||
|
setSelectedIndex(prevIndex);
|
||||||
|
ref.current.scrollToIndex(prevIndex, {
|
||||||
|
align: 'center',
|
||||||
|
smooth: true,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'ArrowDown':
|
||||||
|
case 'ArrowRight': {
|
||||||
|
e.preventDefault();
|
||||||
|
const nextIndex = Math.min(selectedIndex + 1, data.length - 1);
|
||||||
|
setSelectedIndex(nextIndex);
|
||||||
|
ref.current.scrollToIndex(nextIndex, {
|
||||||
|
align: 'center',
|
||||||
|
smooth: true,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{data.map((widget) => renderItem(widget))}
|
||||||
|
<ToggleWidgetList />
|
||||||
|
</VList>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
|
import { NDKEvent, NDKKind, NDKTag } from '@nostr-dev-kit/ndk';
|
||||||
import CharacterCount from '@tiptap/extension-character-count';
|
import CharacterCount from '@tiptap/extension-character-count';
|
||||||
import Image from '@tiptap/extension-image';
|
import Image from '@tiptap/extension-image';
|
||||||
import Placeholder from '@tiptap/extension-placeholder';
|
import Placeholder from '@tiptap/extension-placeholder';
|
||||||
@@ -71,7 +71,7 @@ export function NewArticleScreen() {
|
|||||||
const content = editor.storage.markdown.getMarkdown();
|
const content = editor.storage.markdown.getMarkdown();
|
||||||
|
|
||||||
// define tags
|
// define tags
|
||||||
const tags: string[][] = [
|
const tags: NDKTag[] = [
|
||||||
['d', ident],
|
['d', ident],
|
||||||
['title', title],
|
['title', title],
|
||||||
['image', cover],
|
['image', cover],
|
||||||
@@ -85,17 +85,20 @@ export function NewArticleScreen() {
|
|||||||
tags.push(['t', tag.replace('#', '')]);
|
tags.push(['t', tag.replace('#', '')]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// publish message
|
|
||||||
const event = new NDKEvent(ndk);
|
const event = new NDKEvent(ndk);
|
||||||
event.content = content;
|
event.content = content;
|
||||||
event.kind = NDKKind.Article;
|
event.kind = NDKKind.Article;
|
||||||
event.tags = tags;
|
event.tags = tags;
|
||||||
|
|
||||||
|
// publish
|
||||||
const publishedRelays = await event.publish();
|
const publishedRelays = await event.publish();
|
||||||
|
|
||||||
if (publishedRelays) {
|
if (publishedRelays) {
|
||||||
toast.success(`Broadcasted to ${publishedRelays.size} relays successfully.`);
|
toast.success(`Broadcasted to ${publishedRelays.size} relays successfully.`);
|
||||||
|
|
||||||
// update state
|
// update state
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
||||||
// reset editor
|
// reset editor
|
||||||
editor.commands.clearContent();
|
editor.commands.clearContent();
|
||||||
localStorage.setItem('editor-article', '{}');
|
localStorage.setItem('editor-article', '{}');
|
||||||
@@ -235,7 +238,7 @@ export function NewArticleScreen() {
|
|||||||
<div className="flex h-16 w-full items-center justify-between border-t border-neutral-100 dark:border-neutral-900">
|
<div className="flex h-16 w-full items-center justify-between border-t border-neutral-100 dark:border-neutral-900">
|
||||||
<div className="inline-flex items-center gap-3">
|
<div className="inline-flex items-center gap-3">
|
||||||
<span className="text-sm font-medium tabular-nums text-neutral-600 dark:text-neutral-400">
|
<span className="text-sm font-medium tabular-nums text-neutral-600 dark:text-neutral-400">
|
||||||
{editor?.storage?.characterCount.characters()}
|
{editor?.storage?.characterCount.characters()} characters
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-medium tabular-nums text-neutral-600 dark:text-neutral-400">
|
<span className="text-sm font-medium tabular-nums text-neutral-600 dark:text-neutral-400">
|
||||||
-
|
-
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { message, open } from '@tauri-apps/plugin-dialog';
|
import { message } from '@tauri-apps/plugin-dialog';
|
||||||
import { readBinaryFile } from '@tauri-apps/plugin-fs';
|
|
||||||
import { Editor } from '@tiptap/react';
|
import { Editor } from '@tiptap/react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { MediaIcon } from '@shared/icons';
|
import { MediaIcon } from '@shared/icons';
|
||||||
|
|
||||||
|
import { useNostr } from '@utils/hooks/useNostr';
|
||||||
|
|
||||||
export function MediaUploader({ editor }: { editor: Editor }) {
|
export function MediaUploader({ editor }: { editor: Editor }) {
|
||||||
|
const { upload } = useNostr();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const uploadToNostrBuild = async () => {
|
const uploadToNostrBuild = async () => {
|
||||||
@@ -13,52 +15,12 @@ export function MediaUploader({ editor }: { editor: Editor }) {
|
|||||||
// start loading
|
// start loading
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const selected = await open({
|
const image = await upload(['mp4', 'mp3', 'webm', 'mkv', 'avi', 'mov']);
|
||||||
multiple: false,
|
|
||||||
filters: [
|
|
||||||
{
|
|
||||||
name: 'Media',
|
|
||||||
extensions: [
|
|
||||||
'png',
|
|
||||||
'jpeg',
|
|
||||||
'jpg',
|
|
||||||
'gif',
|
|
||||||
'mp4',
|
|
||||||
'mp3',
|
|
||||||
'webm',
|
|
||||||
'mkv',
|
|
||||||
'avi',
|
|
||||||
'mov',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!selected) {
|
if (image) {
|
||||||
setLoading(false);
|
editor.commands.setImage({ src: image });
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const file = await readBinaryFile(selected.path);
|
|
||||||
const blob = new Blob([file]);
|
|
||||||
|
|
||||||
const data = new FormData();
|
|
||||||
data.append('fileToUpload', blob);
|
|
||||||
data.append('submit', 'Upload Image');
|
|
||||||
|
|
||||||
const res = await fetch('https://nostr.build/api/v2/upload/files', {
|
|
||||||
method: 'POST',
|
|
||||||
body: data,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
const json = await res.json();
|
|
||||||
const content = json.data[0];
|
|
||||||
|
|
||||||
editor.commands.setImage({ src: content.url });
|
|
||||||
editor.commands.createParagraphNear();
|
editor.commands.createParagraphNear();
|
||||||
|
|
||||||
// stop loading
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { Image } from '@shared/image';
|
|
||||||
|
|
||||||
import { useProfile } from '@utils/hooks/useProfile';
|
import { useProfile } from '@utils/hooks/useProfile';
|
||||||
import { displayNpub } from '@utils/shortenKey';
|
import { displayNpub } from '@utils/shortenKey';
|
||||||
|
|
||||||
export function MentionPopupItem({ pubkey, embed }: { pubkey: string; embed?: string }) {
|
export function MentionPopupItem({ pubkey, embed }: { pubkey: string; embed?: string }) {
|
||||||
const { status, user } = useProfile(pubkey, embed);
|
const { status, user } = useProfile(pubkey, embed);
|
||||||
|
|
||||||
if (status === 'loading') {
|
if (status === 'pending') {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2.5 px-2">
|
<div className="flex items-center gap-2.5 px-2">
|
||||||
<div className="relative h-8 w-8 shrink-0 animate-pulse rounded bg-neutral-400 dark:bg-neutral-600" />
|
<div className="relative h-8 w-8 shrink-0 animate-pulse rounded bg-neutral-400 dark:bg-neutral-600" />
|
||||||
@@ -20,7 +18,7 @@ export function MentionPopupItem({ pubkey, embed }: { pubkey: string; embed?: st
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-11 items-center justify-start gap-2.5 px-2 hover:bg-neutral-200 dark:bg-neutral-800">
|
<div className="flex h-11 items-center justify-start gap-2.5 px-2 hover:bg-neutral-200 dark:bg-neutral-800">
|
||||||
<Image
|
<img
|
||||||
src={user.picture || user.image}
|
src={user.picture || user.image}
|
||||||
alt={pubkey}
|
alt={pubkey}
|
||||||
className="shirnk-0 h-8 w-8 rounded-md object-cover"
|
className="shirnk-0 h-8 w-8 rounded-md object-cover"
|
||||||
|
|||||||
@@ -16,8 +16,13 @@ import { useNDK } from '@libs/ndk/provider';
|
|||||||
import { CancelIcon, LoaderIcon } from '@shared/icons';
|
import { CancelIcon, LoaderIcon } from '@shared/icons';
|
||||||
import { MentionNote } from '@shared/notes';
|
import { MentionNote } from '@shared/notes';
|
||||||
|
|
||||||
|
import { WIDGET_KIND } from '@stores/constants';
|
||||||
|
|
||||||
|
import { useWidget } from '@utils/hooks/useWidget';
|
||||||
|
|
||||||
export function NewPostScreen() {
|
export function NewPostScreen() {
|
||||||
const { ndk, relayUrls } = useNDK();
|
const { ndk } = useNDK();
|
||||||
|
const { addWidget } = useWidget();
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
@@ -51,12 +56,6 @@ export function NewPostScreen() {
|
|||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const reply = {
|
|
||||||
id: searchParams.get('id'),
|
|
||||||
root: searchParams.get('root'),
|
|
||||||
pubkey: searchParams.get('pubkey'),
|
|
||||||
};
|
|
||||||
|
|
||||||
// get plaintext content
|
// get plaintext content
|
||||||
const html = editor.getHTML();
|
const html = editor.getHTML();
|
||||||
const serializedContent = convert(html, {
|
const serializedContent = convert(html, {
|
||||||
@@ -66,47 +65,44 @@ export function NewPostScreen() {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
// define tags
|
|
||||||
let tags: string[][] = [];
|
|
||||||
|
|
||||||
// add reply to tags if present
|
|
||||||
if (reply.id && reply.pubkey) {
|
|
||||||
if (reply.root && reply.root.length > 1) {
|
|
||||||
tags = [
|
|
||||||
['e', reply.root, relayUrls[0], 'root'],
|
|
||||||
['e', reply.id, relayUrls[0], 'reply'],
|
|
||||||
['p', reply.pubkey],
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
tags = [
|
|
||||||
['e', reply.id, relayUrls[0], 'reply'],
|
|
||||||
['p', reply.pubkey],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// add hashtag to tags if present
|
|
||||||
const hashtags = serializedContent
|
|
||||||
.split(/\s/gm)
|
|
||||||
.filter((s: string) => s.startsWith('#'));
|
|
||||||
|
|
||||||
hashtags?.forEach((tag: string) => {
|
|
||||||
tags.push(['t', tag.replace('#', '')]);
|
|
||||||
});
|
|
||||||
|
|
||||||
// publish message
|
|
||||||
const event = new NDKEvent(ndk);
|
const event = new NDKEvent(ndk);
|
||||||
event.content = serializedContent;
|
event.content = serializedContent;
|
||||||
event.kind = NDKKind.Text;
|
event.kind = NDKKind.Text;
|
||||||
event.tags = tags;
|
|
||||||
|
|
||||||
|
// add reply to tags if present
|
||||||
|
const replyTo = searchParams.get('replyTo');
|
||||||
|
const rootReplyTo = searchParams.get('rootReplyTo');
|
||||||
|
|
||||||
|
if (rootReplyTo) {
|
||||||
|
const rootEvent = await ndk.fetchEvent(rootReplyTo);
|
||||||
|
event.tag(rootEvent, 'root');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replyTo) {
|
||||||
|
const replyEvent = await ndk.fetchEvent(replyTo);
|
||||||
|
event.tag(replyEvent, 'reply');
|
||||||
|
}
|
||||||
|
|
||||||
|
// publish event
|
||||||
const publishedRelays = await event.publish();
|
const publishedRelays = await event.publish();
|
||||||
|
|
||||||
if (publishedRelays) {
|
if (publishedRelays) {
|
||||||
toast.success(`Broadcasted to ${publishedRelays.size} relays successfully.`);
|
toast.success(`Broadcasted to ${publishedRelays.size} relays successfully.`);
|
||||||
|
|
||||||
// update state
|
// update state
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
// reset editor
|
|
||||||
setSearchParams({});
|
setSearchParams({});
|
||||||
|
|
||||||
|
// open new widget with this event id
|
||||||
|
if (!replyTo) {
|
||||||
|
addWidget.mutate({
|
||||||
|
title: 'Thread',
|
||||||
|
content: event.id,
|
||||||
|
kind: WIDGET_KIND.thread,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// reset editor
|
||||||
editor.commands.clearContent();
|
editor.commands.clearContent();
|
||||||
localStorage.setItem('editor-post', '{}');
|
localStorage.setItem('editor-post', '{}');
|
||||||
}
|
}
|
||||||
@@ -130,22 +126,22 @@ export function NewPostScreen() {
|
|||||||
autoCorrect="off"
|
autoCorrect="off"
|
||||||
autoCapitalize="off"
|
autoCapitalize="off"
|
||||||
/>
|
/>
|
||||||
{searchParams.get('id') && (
|
{searchParams.get('replyTo') && (
|
||||||
<div className="relative max-w-lg">
|
<div className="relative max-w-lg">
|
||||||
<MentionNote id={searchParams.get('id')} />
|
<MentionNote id={searchParams.get('replyTo')} editing />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSearchParams({})}
|
onClick={() => setSearchParams({})}
|
||||||
className="absolute right-3 top-3 inline-flex h-6 w-6 items-center justify-center rounded bg-neutral-300 px-2 dark:bg-neutral-700"
|
className="absolute right-3 top-3 inline-flex h-6 w-6 items-center justify-center rounded bg-neutral-200 px-2 dark:bg-neutral-800"
|
||||||
>
|
>
|
||||||
<CancelIcon className="h-4 w-4" />
|
<CancelIcon className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex h-16 w-full items-center justify-between border-t border-neutral-100 dark:border-neutral-900">
|
<div className="flex h-16 w-full items-center justify-between border-t border-neutral-100 dark:border-neutral-900">
|
||||||
<span className="text-sm font-medium tabular-nums text-neutral-600 dark:text-neutral-400">
|
<span className="text-sm font-medium tabular-nums text-neutral-600 dark:text-neutral-400">
|
||||||
{editor?.storage?.characterCount.characters()}
|
{editor?.storage?.characterCount.characters()} characters
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="inline-flex items-center gap-2">
|
<div className="inline-flex items-center gap-2">
|
||||||
|
|||||||
@@ -1,27 +1,44 @@
|
|||||||
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
|
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
|
||||||
|
import Markdown from 'markdown-to-jsx';
|
||||||
import { nip19 } from 'nostr-tools';
|
import { nip19 } from 'nostr-tools';
|
||||||
import { AddressPointer, EventPointer } from 'nostr-tools/lib/types/nip19';
|
import { EventPointer } from 'nostr-tools/lib/types/nip19';
|
||||||
import { useRef, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
|
||||||
import { ArrowLeftIcon, CheckCircleIcon, ReplyIcon, ShareIcon } from '@shared/icons';
|
import { ArrowLeftIcon, CheckCircleIcon, ShareIcon } from '@shared/icons';
|
||||||
import { ArticleDetailNote, NoteActions, NoteReplyForm } from '@shared/notes';
|
import { NoteReplyForm } from '@shared/notes';
|
||||||
import { ReplyList } from '@shared/notes/replies/list';
|
import { ReplyList } from '@shared/notes/replies/list';
|
||||||
import { User } from '@shared/user';
|
|
||||||
|
|
||||||
import { useEvent } from '@utils/hooks/useEvent';
|
import { useEvent } from '@utils/hooks/useEvent';
|
||||||
|
|
||||||
export function ArticleNoteScreen() {
|
export function ArticleNoteScreen() {
|
||||||
const navigate = useNavigate();
|
|
||||||
const replyRef = useRef(null);
|
|
||||||
|
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
|
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 [isCopy, setIsCopy] = useState(false);
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const metadata = useMemo(() => {
|
||||||
|
if (status === 'pending') return;
|
||||||
|
|
||||||
|
const title = data.tags.find((tag) => tag[0] === 'title')?.[1];
|
||||||
|
const image = data.tags.find((tag) => tag[0] === 'image')?.[1];
|
||||||
|
const summary = data.tags.find((tag) => tag[0] === 'summary')?.[1];
|
||||||
|
|
||||||
|
let publishedAt: Date | string | number = data.tags.find(
|
||||||
|
(tag) => tag[0] === 'published_at'
|
||||||
|
)?.[1];
|
||||||
|
|
||||||
|
publishedAt = new Date(parseInt(publishedAt) * 1000).toLocaleDateString('en-US');
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
image,
|
||||||
|
publishedAt,
|
||||||
|
summary,
|
||||||
|
};
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
const share = async () => {
|
const share = async () => {
|
||||||
await writeText(
|
await writeText(
|
||||||
'https://njump.me/' +
|
'https://njump.me/' +
|
||||||
@@ -33,14 +50,9 @@ export function ArticleNoteScreen() {
|
|||||||
setTimeout(() => setIsCopy(false), 2000);
|
setTimeout(() => setIsCopy(false), 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const scrollToReply = () => {
|
|
||||||
replyRef.current.scrollIntoView();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto grid grid-cols-8 scroll-smooth px-4">
|
<div className="grid grid-cols-12 scroll-smooth px-4">
|
||||||
<div className="col-span-1">
|
<div className="col-span-1 flex flex-col items-start">
|
||||||
<div className="flex flex-col items-end gap-4">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(-1)}
|
||||||
@@ -48,7 +60,6 @@ export function ArticleNoteScreen() {
|
|||||||
>
|
>
|
||||||
<ArrowLeftIcon className="h-5 w-5" />
|
<ArrowLeftIcon className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
<div className="flex flex-col divide-y divide-neutral-200 rounded-xl bg-neutral-100 dark:divide-neutral-800 dark:bg-neutral-900">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={share}
|
onClick={share}
|
||||||
@@ -60,42 +71,51 @@ export function ArticleNoteScreen() {
|
|||||||
<ShareIcon className="h-5 w-5" />
|
<ShareIcon className="h-5 w-5" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={scrollToReply}
|
|
||||||
className="inline-flex h-12 w-12 items-center justify-center rounded-b-xl"
|
|
||||||
>
|
|
||||||
<ReplyIcon className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="col-span-7 overflow-y-auto px-3 xl:col-span-8">
|
||||||
</div>
|
{status === 'pending' ? (
|
||||||
<div className="relative col-span-6 flex flex-col overflow-y-auto">
|
|
||||||
<div className="mx-auto w-full max-w-2xl">
|
|
||||||
{status === 'loading' ? (
|
|
||||||
<div className="px-3 py-1.5">Loading...</div>
|
<div className="px-3 py-1.5">Loading...</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-min w-full flex-col px-3">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="mb-3 border-b border-neutral-100 pb-3 dark:border-neutral-900">
|
<div className="flex flex-col gap-2 border-b border-neutral-100 pb-4 dark:border-neutral-900">
|
||||||
<User pubkey={data.pubkey} time={data.created_at} variant="thread" />
|
{metadata.image && (
|
||||||
<div className="mt-3">
|
<img
|
||||||
<ArticleDetailNote event={data} />
|
src={metadata.image}
|
||||||
</div>
|
alt={metadata.title}
|
||||||
<div className="mt-3">
|
className="h-auto w-full rounded-lg object-cover"
|
||||||
<NoteActions id={id} pubkey={data.pubkey} extraButtons={false} />
|
/>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<h1 className="mb-2 text-3xl font-semibold">{metadata.title}</h1>
|
||||||
|
<span className="text-sm font-medium text-neutral-600 dark:text-neutral-400">
|
||||||
|
Published: {metadata.publishedAt.toString()}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Markdown
|
||||||
|
options={{
|
||||||
|
overrides: {
|
||||||
|
a: {
|
||||||
|
props: {
|
||||||
|
className: 'text-blue-500 hover:text-blue-600',
|
||||||
|
target: '_blank',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
className="break-p prose-lg prose-neutral dark:prose-invert prose-ul:list-disc"
|
||||||
|
>
|
||||||
|
{data.content}
|
||||||
|
</Markdown>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div ref={replyRef} className="px-3">
|
</div>
|
||||||
|
<div className="col-span-4 border-l border-neutral-100 px-3 dark:border-neutral-900 xl:col-span-3">
|
||||||
<div className="mb-3 border-b border-neutral-100 pb-3 dark:border-neutral-900">
|
<div className="mb-3 border-b border-neutral-100 pb-3 dark:border-neutral-900">
|
||||||
<NoteReplyForm id={id} />
|
<NoteReplyForm rootEvent={data} />
|
||||||
</div>
|
</div>
|
||||||
<ReplyList id={id} />
|
<ReplyList eventId={id} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="col-span-1" />
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,25 +7,26 @@ import { useNavigate, useParams } from 'react-router-dom';
|
|||||||
|
|
||||||
import { ArrowLeftIcon, CheckCircleIcon, ReplyIcon, ShareIcon } from '@shared/icons';
|
import { ArrowLeftIcon, CheckCircleIcon, ReplyIcon, ShareIcon } from '@shared/icons';
|
||||||
import {
|
import {
|
||||||
ArticleNote,
|
ChildNote,
|
||||||
FileNote,
|
MemoizedTextKind,
|
||||||
NoteActions,
|
NoteActions,
|
||||||
NoteReplyForm,
|
NoteReplyForm,
|
||||||
TextNote,
|
|
||||||
UnknownNote,
|
UnknownNote,
|
||||||
} from '@shared/notes';
|
} from '@shared/notes';
|
||||||
import { ReplyList } from '@shared/notes/replies/list';
|
import { ReplyList } from '@shared/notes/replies/list';
|
||||||
import { User } from '@shared/user';
|
import { User } from '@shared/user';
|
||||||
|
|
||||||
import { useEvent } from '@utils/hooks/useEvent';
|
import { useEvent } from '@utils/hooks/useEvent';
|
||||||
|
import { useNostr } from '@utils/hooks/useNostr';
|
||||||
|
|
||||||
export function TextNoteScreen() {
|
export function TextNoteScreen() {
|
||||||
const { id } = useParams();
|
|
||||||
const { status, data } = useEvent(id);
|
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const replyRef = useRef(null);
|
const replyRef = useRef(null);
|
||||||
|
|
||||||
|
const { id } = useParams();
|
||||||
|
const { status, data } = useEvent(id);
|
||||||
|
const { getEventThread } = useNostr();
|
||||||
|
|
||||||
const [isCopy, setIsCopy] = useState(false);
|
const [isCopy, setIsCopy] = useState(false);
|
||||||
|
|
||||||
const share = async () => {
|
const share = async () => {
|
||||||
@@ -44,13 +45,24 @@ export function TextNoteScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderKind = (event: NDKEvent) => {
|
const renderKind = (event: NDKEvent) => {
|
||||||
|
const thread = getEventThread(event.tags);
|
||||||
switch (event.kind) {
|
switch (event.kind) {
|
||||||
case NDKKind.Text:
|
case NDKKind.Text:
|
||||||
return <TextNote content={event.content} />;
|
return (
|
||||||
case NDKKind.Article:
|
<>
|
||||||
return <ArticleNote event={event} />;
|
{thread ? (
|
||||||
case 1063:
|
<div className="mb-2 w-full px-3">
|
||||||
return <FileNote event={event} />;
|
<div className="flex h-min w-full flex-col gap-3 rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
|
||||||
|
{thread.rootEventId ? (
|
||||||
|
<ChildNote id={thread.rootEventId} isRoot />
|
||||||
|
) : null}
|
||||||
|
{thread.replyEventId ? <ChildNote id={thread.replyEventId} /> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<MemoizedTextKind content={event.content} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return <UnknownNote event={event} />;
|
return <UnknownNote event={event} />;
|
||||||
}
|
}
|
||||||
@@ -91,7 +103,7 @@ export function TextNoteScreen() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="relative col-span-6 flex flex-col overflow-y-auto">
|
<div className="relative col-span-6 flex flex-col overflow-y-auto">
|
||||||
<div className="mx-auto w-full max-w-2xl">
|
<div className="mx-auto w-full max-w-2xl">
|
||||||
{status === 'loading' ? (
|
{status === 'pending' ? (
|
||||||
<div className="px-3 py-1.5">Loading...</div>
|
<div className="px-3 py-1.5">Loading...</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-min w-full flex-col px-3">
|
<div className="flex h-min w-full flex-col px-3">
|
||||||
@@ -99,16 +111,16 @@ export function TextNoteScreen() {
|
|||||||
<User pubkey={data.pubkey} time={data.created_at} variant="thread" />
|
<User pubkey={data.pubkey} time={data.created_at} variant="thread" />
|
||||||
<div className="mt-3">{renderKind(data)}</div>
|
<div className="mt-3">{renderKind(data)}</div>
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<NoteActions id={id} pubkey={data.pubkey} extraButtons={false} />
|
<NoteActions event={data} canOpenEvent={false} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div ref={replyRef} className="px-3">
|
<div ref={replyRef} className="px-3">
|
||||||
<div className="mb-3 border-b border-neutral-100 pb-3 dark:border-neutral-900">
|
<div className="mb-3 border-b border-neutral-100 pb-3 dark:border-neutral-900">
|
||||||
<NoteReplyForm id={id} />
|
<NoteReplyForm rootEvent={data} />
|
||||||
</div>
|
</div>
|
||||||
<ReplyList id={id} />
|
<ReplyList eventId={id} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,62 +6,35 @@ import { VList } from 'virtua';
|
|||||||
import { useNDK } from '@libs/ndk/provider';
|
import { useNDK } from '@libs/ndk/provider';
|
||||||
|
|
||||||
import { LoaderIcon } from '@shared/icons';
|
import { LoaderIcon } from '@shared/icons';
|
||||||
import {
|
import { MemoizedRepost, MemoizedTextNote, UnknownNote } from '@shared/notes';
|
||||||
ArticleNote,
|
|
||||||
FileNote,
|
|
||||||
NoteWrapper,
|
|
||||||
Repost,
|
|
||||||
TextNote,
|
|
||||||
UnknownNote,
|
|
||||||
} from '@shared/notes';
|
|
||||||
|
|
||||||
export function RelayEventList({ relayUrl }: { relayUrl: string }) {
|
export function RelayEventList({ relayUrl }: { relayUrl: string }) {
|
||||||
const { fetcher } = useNDK();
|
const { fetcher } = useNDK();
|
||||||
const { status, data } = useQuery(
|
const { status, data } = useQuery({
|
||||||
['relay-event'],
|
queryKey: ['relay-events', relayUrl],
|
||||||
async () => {
|
queryFn: async () => {
|
||||||
const url = 'wss://' + relayUrl;
|
const url = 'wss://' + relayUrl;
|
||||||
const events = await fetcher.fetchLatestEvents(
|
const events = await fetcher.fetchLatestEvents(
|
||||||
[url],
|
[url],
|
||||||
{
|
{
|
||||||
kinds: [NDKKind.Text, NDKKind.Repost, 1063, NDKKind.Article],
|
kinds: [NDKKind.Text, NDKKind.Repost],
|
||||||
},
|
},
|
||||||
100
|
20
|
||||||
);
|
);
|
||||||
return events as unknown as NDKEvent[];
|
return events as unknown as NDKEvent[];
|
||||||
},
|
},
|
||||||
{ refetchOnWindowFocus: false }
|
refetchOnWindowFocus: false,
|
||||||
);
|
});
|
||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
(event: NDKEvent) => {
|
(event: NDKEvent) => {
|
||||||
switch (event.kind) {
|
switch (event.kind) {
|
||||||
case NDKKind.Text:
|
case NDKKind.Text:
|
||||||
return (
|
return <MemoizedTextNote key={event.id} event={event} />;
|
||||||
<NoteWrapper key={event.id} event={event}>
|
|
||||||
<TextNote />
|
|
||||||
</NoteWrapper>
|
|
||||||
);
|
|
||||||
case NDKKind.Repost:
|
case NDKKind.Repost:
|
||||||
return <Repost key={event.id} event={event} />;
|
return <MemoizedRepost key={event.id} event={event} />;
|
||||||
case 1063:
|
|
||||||
return (
|
|
||||||
<NoteWrapper key={event.id} event={event}>
|
|
||||||
<FileNote />
|
|
||||||
</NoteWrapper>
|
|
||||||
);
|
|
||||||
case NDKKind.Article:
|
|
||||||
return (
|
|
||||||
<NoteWrapper key={event.id} event={event}>
|
|
||||||
<ArticleNote />
|
|
||||||
</NoteWrapper>
|
|
||||||
);
|
|
||||||
default:
|
default:
|
||||||
return (
|
return <UnknownNote key={event.id} event={event} />;
|
||||||
<NoteWrapper key={event.id} event={event}>
|
|
||||||
<UnknownNote />
|
|
||||||
</NoteWrapper>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[data]
|
[data]
|
||||||
@@ -69,8 +42,8 @@ export function RelayEventList({ relayUrl }: { relayUrl: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full">
|
<div className="h-full">
|
||||||
<div className="mx-auto w-full max-w-[500px]">
|
<VList className="mx-auto w-full max-w-[500px] scrollbar-none">
|
||||||
{status === 'loading' ? (
|
{status === 'pending' ? (
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
<div className="inline-flex flex-col items-center justify-center gap-2">
|
<div className="inline-flex flex-col items-center justify-center gap-2">
|
||||||
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
|
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
|
||||||
@@ -78,13 +51,9 @@ export function RelayEventList({ relayUrl }: { relayUrl: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<VList className="scrollbar-none h-full">
|
data.map((item) => renderItem(item))
|
||||||
<div className="h-10" />
|
|
||||||
{data.map((item) => renderItem(item))}
|
|
||||||
<div className="h-16" />
|
|
||||||
</VList>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</VList>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,10 @@ export function RelayForm() {
|
|||||||
const res = await db.createRelay(url);
|
const res = await db.createRelay(url);
|
||||||
if (!res) return setError("You're already using this relay");
|
if (!res) return setError("You're already using this relay");
|
||||||
|
|
||||||
queryClient.invalidateQueries(['user-relay']);
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['user-relay'],
|
||||||
|
});
|
||||||
|
|
||||||
setError('');
|
setError('');
|
||||||
setUrl('');
|
setUrl('');
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -17,18 +17,16 @@ export function RelayList() {
|
|||||||
|
|
||||||
const { getAllRelaysByUsers } = useNostr();
|
const { getAllRelaysByUsers } = useNostr();
|
||||||
const { db } = useStorage();
|
const { db } = useStorage();
|
||||||
const { status, data } = useQuery(
|
const { status, data } = useQuery({
|
||||||
['relays'],
|
queryKey: ['relays'],
|
||||||
async () => {
|
queryFn: async () => {
|
||||||
return await getAllRelaysByUsers();
|
return await getAllRelaysByUsers();
|
||||||
},
|
},
|
||||||
{
|
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnMount: false,
|
refetchOnMount: false,
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const inspectRelay = (relayUrl: string) => {
|
const inspectRelay = (relayUrl: string) => {
|
||||||
const url = new URL(relayUrl);
|
const url = new URL(relayUrl);
|
||||||
@@ -40,12 +38,14 @@ export function RelayList() {
|
|||||||
const res = await db.createRelay(url);
|
const res = await db.createRelay(url);
|
||||||
|
|
||||||
if (!res) await message("You're aldready connected to this relay");
|
if (!res) await message("You're aldready connected to this relay");
|
||||||
queryClient.invalidateQueries(['user-relay']);
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['user-relay'],
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="col-span-2 border-r border-neutral-100 dark:border-neutral-900">
|
<div className="col-span-2 border-r border-neutral-100 dark:border-neutral-900">
|
||||||
{status === 'loading' ? (
|
{status === 'pending' ? (
|
||||||
<div className="flex h-full w-full items-center justify-center pb-10">
|
<div className="flex h-full w-full items-center justify-center pb-10">
|
||||||
<div className="inline-flex flex-col items-center justify-center gap-2">
|
<div className="inline-flex flex-col items-center justify-center gap-2">
|
||||||
<LoaderIcon className="h-5 w-5 animate-spin text-neutral-900 dark:text-neutral-100" />
|
<LoaderIcon className="h-5 w-5 animate-spin text-neutral-900 dark:text-neutral-100" />
|
||||||
@@ -56,7 +56,7 @@ export function RelayList() {
|
|||||||
<VList className="h-full">
|
<VList className="h-full">
|
||||||
<div className="inline-flex h-16 w-full items-center border-b border-neutral-100 px-3 dark:border-neutral-900">
|
<div className="inline-flex h-16 w-full items-center border-b border-neutral-100 px-3 dark:border-neutral-900">
|
||||||
<h3 className="font-semibold text-neutral-950 dark:text-neutral-50">
|
<h3 className="font-semibold text-neutral-950 dark:text-neutral-50">
|
||||||
All relays used by your follows
|
All relays
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
{[...data].map(([key, value]) => (
|
{[...data].map(([key, value]) => (
|
||||||
|
|||||||
@@ -12,29 +12,31 @@ export function UserRelay() {
|
|||||||
|
|
||||||
const { relayUrls } = useNDK();
|
const { relayUrls } = useNDK();
|
||||||
const { db } = useStorage();
|
const { db } = useStorage();
|
||||||
const { status, data } = useQuery(
|
const { status, data } = useQuery({
|
||||||
['user-relay'],
|
queryKey: ['user-relay'],
|
||||||
async () => {
|
queryFn: async () => {
|
||||||
return await db.getExplicitRelayUrls();
|
return await db.getExplicitRelayUrls();
|
||||||
},
|
},
|
||||||
{ refetchOnWindowFocus: false }
|
refetchOnWindowFocus: false,
|
||||||
);
|
});
|
||||||
|
|
||||||
const removeRelay = async (relayUrl: string) => {
|
const removeRelay = async (relayUrl: string) => {
|
||||||
await db.removeRelay(relayUrl);
|
await db.removeRelay(relayUrl);
|
||||||
queryClient.invalidateQueries(['user-relay']);
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['user-relay'],
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 px-3">
|
<div className="mt-3 px-3">
|
||||||
{status === 'loading' ? (
|
{status === 'pending' ? (
|
||||||
<p>Loading...</p>
|
<p>Loading...</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{data.map((item) => (
|
{data.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={item}
|
key={item}
|
||||||
className="group flex h-10 items-center justify-between rounded-lg bg-neutral-200 pl-3 pr-1.5 dark:bg-neutral-800"
|
className="group flex h-10 items-center justify-between rounded-lg bg-neutral-100 pl-3 pr-1.5 dark:bg-neutral-900"
|
||||||
>
|
>
|
||||||
<div className="inline-flex items-center gap-2.5">
|
<div className="inline-flex items-center gap-2.5">
|
||||||
{relayUrls.includes(item) ? (
|
{relayUrls.includes(item) ? (
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { Await, useLoaderData, useNavigate, useParams } from 'react-router-dom';
|
|||||||
import { ArrowLeftIcon, LoaderIcon } from '@shared/icons';
|
import { ArrowLeftIcon, LoaderIcon } from '@shared/icons';
|
||||||
import { User } from '@shared/user';
|
import { User } from '@shared/user';
|
||||||
|
|
||||||
|
import { NIP11 } from '@utils/types';
|
||||||
|
|
||||||
import { RelayEventList } from './components/relayEventList';
|
import { RelayEventList } from './components/relayEventList';
|
||||||
|
|
||||||
export function RelayScreen() {
|
export function RelayScreen() {
|
||||||
@@ -59,7 +61,7 @@ export function RelayScreen() {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(resolvedRelay) => (
|
{(resolvedRelay: NIP11) => (
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold leading-tight text-neutral-900 dark:text-neutral-100">
|
<h3 className="font-semibold leading-tight text-neutral-900 dark:text-neutral-100">
|
||||||
@@ -114,7 +116,7 @@ export function RelayScreen() {
|
|||||||
Supported NIPs:
|
Supported NIPs:
|
||||||
</h5>
|
</h5>
|
||||||
<div className="mt-2 grid grid-cols-7 gap-2">
|
<div className="mt-2 grid grid-cols-7 gap-2">
|
||||||
{resolvedRelay.supported_nips.map((item: string) => (
|
{resolvedRelay.supported_nips.map((item) => (
|
||||||
<a
|
<a
|
||||||
key={item}
|
key={item}
|
||||||
href={`https://nips.be/${item}`}
|
href={`https://nips.be/${item}`}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function AboutScreen() {
|
||||||
|
return <div></div>;
|
||||||
|
}
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
import { nip19 } from 'nostr-tools';
|
|
||||||
import { useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import { useStorage } from '@libs/storage/provider';
|
|
||||||
|
|
||||||
import { EyeOffIcon, EyeOnIcon } from '@shared/icons';
|
|
||||||
|
|
||||||
export function AccountSettingsScreen() {
|
|
||||||
const { db } = useStorage();
|
|
||||||
|
|
||||||
const [privType, setPrivType] = useState('password');
|
|
||||||
const [nsecType, setNsecType] = useState('password');
|
|
||||||
|
|
||||||
const privkey = 'todo';
|
|
||||||
const nsec = useMemo(() => nip19.nsecEncode(privkey), [privkey]);
|
|
||||||
|
|
||||||
const showPrivkey = () => {
|
|
||||||
if (privType === 'password') {
|
|
||||||
setPrivType('text');
|
|
||||||
} else {
|
|
||||||
setPrivType('password');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const showNsec = () => {
|
|
||||||
if (nsecType === 'password') {
|
|
||||||
setNsecType('text');
|
|
||||||
} else {
|
|
||||||
setNsecType('password');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full w-full px-3 pt-11">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<h1 className="text-xl font-semibold text-white">Account</h1>
|
|
||||||
<div className="flex flex-col gap-4 rounded-xl bg-white/10 p-3 backdrop-blur-xl">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label
|
|
||||||
htmlFor="pubkey"
|
|
||||||
className="text-base font-semibold text-neutral-600 dark:text-neutral-400"
|
|
||||||
>
|
|
||||||
Public Key
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
readOnly
|
|
||||||
value={db.account.pubkey}
|
|
||||||
className="relative w-full rounded-lg bg-white/10 py-3 pl-3.5 pr-11 text-white !outline-none backdrop-blur-xl placeholder:text-neutral-600 dark:text-neutral-400"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label
|
|
||||||
htmlFor="npub"
|
|
||||||
className="text-base font-semibold text-neutral-600 dark:text-neutral-400"
|
|
||||||
>
|
|
||||||
Npub
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
readOnly
|
|
||||||
value={db.account.npub}
|
|
||||||
className="relative w-full rounded-lg bg-white/10 py-3 pl-3.5 pr-11 text-white !outline-none backdrop-blur-xl placeholder:text-neutral-600 dark:text-neutral-400"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label
|
|
||||||
htmlFor="privkey"
|
|
||||||
className="text-base font-semibold text-neutral-600 dark:text-neutral-400"
|
|
||||||
>
|
|
||||||
Private Key
|
|
||||||
</label>
|
|
||||||
<div className="relative w-full">
|
|
||||||
<input
|
|
||||||
readOnly
|
|
||||||
type={privType}
|
|
||||||
value={privkey}
|
|
||||||
className="relative w-full rounded-lg bg-white/10 py-3 pl-3.5 pr-11 text-white !outline-none backdrop-blur-xl placeholder:text-neutral-600 dark:text-neutral-400"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => showPrivkey()}
|
|
||||||
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-neutral-700"
|
|
||||||
>
|
|
||||||
{privType === 'password' ? (
|
|
||||||
<EyeOffIcon
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
className="text-neutral-600 group-hover:text-white dark:text-neutral-400"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<EyeOnIcon
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
className="text-neutral-600 group-hover:text-white dark:text-neutral-400"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label
|
|
||||||
htmlFor="privkey"
|
|
||||||
className="text-base font-semibold text-neutral-600 dark:text-neutral-400"
|
|
||||||
>
|
|
||||||
Nsec
|
|
||||||
</label>
|
|
||||||
<div className="relative w-full">
|
|
||||||
<input
|
|
||||||
readOnly
|
|
||||||
type={nsecType}
|
|
||||||
value={nsec}
|
|
||||||
className="relative w-full rounded-lg bg-white/10 py-3 pl-3.5 pr-11 text-white !outline-none backdrop-blur-xl placeholder:text-neutral-600 dark:text-neutral-400"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => showNsec()}
|
|
||||||
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-neutral-700"
|
|
||||||
>
|
|
||||||
{privType === 'password' ? (
|
|
||||||
<EyeOffIcon
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
className="text-neutral-600 group-hover:text-white dark:text-neutral-400"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<EyeOnIcon
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
className="text-neutral-600 group-hover:text-white dark:text-neutral-400"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function AdvancedSettingScreen() {
|
||||||
|
return <div></div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function BackupSettingScreen() {
|
||||||
|
return <div></div>;
|
||||||
|
}
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
export function AutoStartSetting() {
|
|
||||||
return (
|
|
||||||
<div className="inline-flex items-center justify-between px-5 py-4">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="font-medium leading-none text-neutral-200">Auto start</span>
|
|
||||||
<span className="text-sm leading-none text-neutral-600 dark:text-neutral-400">
|
|
||||||
Auto start at login
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { CheckCircleIcon } from '@shared/icons';
|
|
||||||
|
|
||||||
export function CacheTimeSetting() {
|
|
||||||
const [time, setTime] = useState('0');
|
|
||||||
|
|
||||||
const update = async () => {
|
|
||||||
// await updateSetting('cache_time', time);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="inline-flex items-center justify-between px-5 py-4">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="font-medium leading-none text-neutral-200">
|
|
||||||
Cache time (milliseconds)
|
|
||||||
</span>
|
|
||||||
<span className="text-sm leading-none text-neutral-600 dark:text-neutral-400">
|
|
||||||
The length of time before inactive data gets removed from the cache
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="inline-flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
value={time}
|
|
||||||
onChange={(e) => setTime(e.currentTarget.value)}
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect="none"
|
|
||||||
className="h-8 w-24 rounded-md bg-neutral-800 px-2 text-right font-medium text-neutral-300 focus:outline-none"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => update()}
|
|
||||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-neutral-800 font-medium hover:bg-blue-600"
|
|
||||||
>
|
|
||||||
<CheckCircleIcon className="h-4 w-4 text-white" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { useNDK } from '@libs/ndk/provider';
|
||||||
|
import { useStorage } from '@libs/storage/provider';
|
||||||
|
|
||||||
|
import { EditIcon, LoaderIcon } from '@shared/icons';
|
||||||
|
|
||||||
|
import { compactNumber } from '@utils/number';
|
||||||
|
|
||||||
|
export function ContactCard() {
|
||||||
|
const { db } = useStorage();
|
||||||
|
const { ndk } = useNDK();
|
||||||
|
const { status, data } = useQuery({
|
||||||
|
queryKey: ['contacts'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const user = ndk.getUser({ pubkey: db.account.pubkey });
|
||||||
|
const follows = await user.follows();
|
||||||
|
return [...follows];
|
||||||
|
},
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="col-span-1 h-44 rounded-2xl bg-neutral-100 transition-all duration-150 ease-smooth hover:scale-105 dark:bg-neutral-900">
|
||||||
|
{status === 'pending' ? (
|
||||||
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
|
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full flex-col justify-between p-4">
|
||||||
|
<h3 className="pt-1 text-5xl font-semibold tabular-nums text-neutral-900 dark:text-neutral-100">
|
||||||
|
{compactNumber.format(data.length)}
|
||||||
|
</h3>
|
||||||
|
<div className="mt-auto flex h-6 w-full items-center justify-between">
|
||||||
|
<p className="text-xl font-medium leading-none text-neutral-600 dark:text-neutral-400">
|
||||||
|
Contacts
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
to="/settings/edit-contact"
|
||||||
|
className="inline-flex h-6 w-max items-center gap-1 rounded-full bg-neutral-200 px-2.5 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700"
|
||||||
|
>
|
||||||
|
<EditIcon className="h-3 w-3" />
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { appConfigDir } from '@tauri-apps/api/path';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
export function DataPath() {
|
|
||||||
const [path, setPath] = useState<string>('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function getPath() {
|
|
||||||
const dir = await appConfigDir();
|
|
||||||
setPath(dir);
|
|
||||||
}
|
|
||||||
getPath();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="inline-flex items-center justify-between px-5 py-4">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="font-medium leading-none text-neutral-200">App data path</span>
|
|
||||||
<span className="text-sm leading-none text-neutral-600 dark:text-neutral-400">
|
|
||||||
Where the local data is stored
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="inline-flex items-center gap-2">
|
|
||||||
<span className="font-medium text-neutral-300">{path}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { fetch } from '@tauri-apps/plugin-http';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { useStorage } from '@libs/storage/provider';
|
||||||
|
|
||||||
|
import { LoaderIcon } from '@shared/icons';
|
||||||
|
|
||||||
|
import { compactNumber } from '@utils/number';
|
||||||
|
|
||||||
|
export function PostCard() {
|
||||||
|
const { db } = useStorage();
|
||||||
|
const { status, data } = useQuery({
|
||||||
|
queryKey: ['user-stats', db.account.pubkey],
|
||||||
|
queryFn: async ({ signal }: { signal: AbortSignal }) => {
|
||||||
|
const res = await fetch(
|
||||||
|
`https://api.nostr.band/v0/stats/profile/${db.account.pubkey}`,
|
||||||
|
{
|
||||||
|
signal,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('Error');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await res.json();
|
||||||
|
},
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="col-span-1 h-44 rounded-2xl bg-neutral-100 transition-all duration-150 ease-smooth hover:scale-105 dark:bg-neutral-900">
|
||||||
|
{status === 'pending' ? (
|
||||||
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
|
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full flex-col justify-between p-4">
|
||||||
|
<h3 className="pt-1 text-5xl font-semibold tabular-nums text-neutral-900 dark:text-neutral-100">
|
||||||
|
{compactNumber.format(data.stats[db.account.pubkey].pub_note_count)}
|
||||||
|
</h3>
|
||||||
|
<div className="mt-auto flex h-6 w-full items-center justify-between">
|
||||||
|
<p className="text-xl font-medium leading-none text-neutral-600 dark:text-neutral-400">
|
||||||
|
Posts
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
to={`/users/${db.account.pubkey}`}
|
||||||
|
className="inline-flex h-6 w-max items-center gap-1 rounded-full bg-neutral-200 px-2.5 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700"
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import * as Avatar from '@radix-ui/react-avatar';
|
||||||
|
import { minidenticon } from 'minidenticons';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { useStorage } from '@libs/storage/provider';
|
||||||
|
|
||||||
|
import { EditIcon, LoaderIcon } from '@shared/icons';
|
||||||
|
|
||||||
|
import { useProfile } from '@utils/hooks/useProfile';
|
||||||
|
import { displayNpub } from '@utils/shortenKey';
|
||||||
|
|
||||||
|
export function ProfileCard() {
|
||||||
|
const { db } = useStorage();
|
||||||
|
const { status, user } = useProfile(db.account.pubkey);
|
||||||
|
|
||||||
|
const svgURI =
|
||||||
|
'data:image/svg+xml;utf8,' +
|
||||||
|
encodeURIComponent(minidenticon(db.account.pubkey, 90, 50));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-4 h-56 w-full rounded-2xl bg-neutral-100 transition-all duration-150 ease-smooth hover:scale-105 dark:bg-neutral-900">
|
||||||
|
{status === 'pending' ? (
|
||||||
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
|
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full flex-col justify-between p-4">
|
||||||
|
<div className="flex h-10 w-full justify-end">
|
||||||
|
<Link
|
||||||
|
to="/settings/edit-profile"
|
||||||
|
className="inline-flex h-8 w-20 items-center justify-center gap-1.5 rounded-full bg-neutral-200 text-sm font-medium hover:bg-neutral-400 dark:bg-neutral-800 dark:hover:bg-neutral-600"
|
||||||
|
>
|
||||||
|
<EditIcon className="h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2.5">
|
||||||
|
<Avatar.Root className="shrink-0">
|
||||||
|
<Avatar.Image
|
||||||
|
src={user?.picture || user?.image}
|
||||||
|
alt={db.account.pubkey}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
style={{ contentVisibility: 'auto' }}
|
||||||
|
className="h-16 w-16 rounded-xl border border-neutral-200/50 shadow-[rgba(17,_17,_26,_0.1)_0px_0px_16px] dark:border-neutral-800/50"
|
||||||
|
/>
|
||||||
|
<Avatar.Fallback delayMs={300}>
|
||||||
|
<img
|
||||||
|
src={svgURI}
|
||||||
|
alt={db.account.pubkey}
|
||||||
|
className="h-16 w-16 rounded-xl bg-black dark:bg-white"
|
||||||
|
/>
|
||||||
|
</Avatar.Fallback>
|
||||||
|
</Avatar.Root>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-3xl font-semibold leading-8 text-neutral-900 dark:text-neutral-100">
|
||||||
|
{user?.display_name || user?.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-lg text-neutral-700 dark:text-neutral-300">
|
||||||
|
{user.nip05 || displayNpub(db.account.pubkey, 16)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { useNDK } from '@libs/ndk/provider';
|
||||||
|
import { useStorage } from '@libs/storage/provider';
|
||||||
|
|
||||||
|
import { EditIcon, LoaderIcon } from '@shared/icons';
|
||||||
|
|
||||||
|
import { compactNumber } from '@utils/number';
|
||||||
|
|
||||||
|
export function RelayCard() {
|
||||||
|
const { db } = useStorage();
|
||||||
|
const { ndk } = useNDK();
|
||||||
|
const { status, data } = useQuery({
|
||||||
|
queryKey: ['relays'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const user = ndk.getUser({ pubkey: db.account.pubkey });
|
||||||
|
const relays = await user.relayList();
|
||||||
|
|
||||||
|
if (!relays) return Promise.reject(new Error("user's relay set not found"));
|
||||||
|
return relays;
|
||||||
|
},
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="col-span-1 h-44 rounded-2xl bg-neutral-100 transition-all duration-150 ease-smooth hover:scale-105 dark:bg-neutral-900">
|
||||||
|
{status === 'pending' ? (
|
||||||
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
|
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full flex-col justify-between p-4">
|
||||||
|
<h3 className="pt-1 text-5xl font-semibold tabular-nums text-neutral-900 dark:text-neutral-100">
|
||||||
|
{compactNumber.format(data?.relays?.length || 0)}
|
||||||
|
</h3>
|
||||||
|
<div className="mt-auto flex h-6 w-full items-center justify-between">
|
||||||
|
<p className="text-xl font-medium leading-none text-neutral-600 dark:text-neutral-400">
|
||||||
|
Relays
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
to="/relays"
|
||||||
|
className="inline-flex h-6 w-max items-center gap-1 rounded-full bg-neutral-200 px-2.5 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700"
|
||||||
|
>
|
||||||
|
<EditIcon className="h-3 w-3" />
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { getVersion } from '@tauri-apps/plugin-app';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
export function VersionSetting() {
|
|
||||||
const [version, setVersion] = useState<string>('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function checkVersion() {
|
|
||||||
const appVersion = await getVersion();
|
|
||||||
setVersion(appVersion);
|
|
||||||
}
|
|
||||||
checkVersion();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="inline-flex items-center justify-between px-5 py-4">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="font-medium leading-none text-neutral-200">Version</span>
|
|
||||||
<span className="text-sm leading-none text-neutral-600 dark:text-neutral-400">
|
|
||||||
You're using latest version
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="inline-flex items-center gap-2">
|
|
||||||
<span className="font-medium text-neutral-300">{version}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { fetch } from '@tauri-apps/plugin-http';
|
||||||
|
|
||||||
|
import { useStorage } from '@libs/storage/provider';
|
||||||
|
|
||||||
|
import { LoaderIcon } from '@shared/icons';
|
||||||
|
|
||||||
|
import { compactNumber } from '@utils/number';
|
||||||
|
|
||||||
|
export function ZapCard() {
|
||||||
|
const { db } = useStorage();
|
||||||
|
const { status, data } = useQuery({
|
||||||
|
queryKey: ['user-stats', db.account.pubkey],
|
||||||
|
queryFn: async ({ signal }: { signal: AbortSignal }) => {
|
||||||
|
const res = await fetch(
|
||||||
|
`https://api.nostr.band/v0/stats/profile/${db.account.pubkey}`,
|
||||||
|
{
|
||||||
|
signal,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('Error');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await res.json();
|
||||||
|
},
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="col-span-1 h-44 rounded-2xl bg-neutral-100 transition-all duration-150 ease-smooth hover:scale-105 dark:bg-neutral-900">
|
||||||
|
{status === 'pending' ? (
|
||||||
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
|
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full flex-col justify-between p-4">
|
||||||
|
<h3 className="pt-1 text-5xl font-semibold tabular-nums text-neutral-900 dark:text-neutral-100">
|
||||||
|
{compactNumber.format(
|
||||||
|
data?.stats[db.account.pubkey]?.zaps_received?.msats / 1000 || 0
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<div className="mt-auto flex h-6 items-center text-xl font-medium leading-none text-neutral-600 dark:text-neutral-400">
|
||||||
|
Sats received
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { useNDK } from '@libs/ndk/provider';
|
||||||
|
import { useStorage } from '@libs/storage/provider';
|
||||||
|
|
||||||
|
import { LoaderIcon } from '@shared/icons';
|
||||||
|
import { User } from '@shared/user';
|
||||||
|
|
||||||
|
export function EditContactScreen() {
|
||||||
|
const { db } = useStorage();
|
||||||
|
const { ndk } = useNDK();
|
||||||
|
const { status, data } = useQuery({
|
||||||
|
queryKey: ['contacts'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const user = ndk.getUser({ pubkey: db.account.pubkey });
|
||||||
|
|
||||||
|
const follows = await user.follows();
|
||||||
|
return [...follows];
|
||||||
|
},
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-xl flex-col gap-3">
|
||||||
|
{status === 'pending' ? (
|
||||||
|
<div className="flex h-10 w-full items-center justify-center">
|
||||||
|
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
data.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.pubkey}
|
||||||
|
className="flex h-16 w-full items-center justify-between rounded-xl bg-neutral-100 px-2.5 dark:bg-neutral-900"
|
||||||
|
>
|
||||||
|
<User pubkey={item.pubkey} variant="simple" />
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import { NDKEvent, NDKKind, NDKUserProfile } from '@nostr-dev-kit/ndk';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { message } from '@tauri-apps/plugin-dialog';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { useNDK } from '@libs/ndk/provider';
|
||||||
|
import { useStorage } from '@libs/storage/provider';
|
||||||
|
|
||||||
|
import { CheckCircleIcon, LoaderIcon, PlusIcon, UnverifiedIcon } from '@shared/icons';
|
||||||
|
|
||||||
|
import { useNostr } from '@utils/hooks/useNostr';
|
||||||
|
|
||||||
|
export function EditProfileScreen() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [picture, setPicture] = useState('');
|
||||||
|
const [banner, setBanner] = useState('');
|
||||||
|
const [nip05, setNIP05] = useState({ verified: true, text: '' });
|
||||||
|
|
||||||
|
const { db } = useStorage();
|
||||||
|
const { ndk } = useNDK();
|
||||||
|
const { upload } = useNostr();
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
setError,
|
||||||
|
formState: { isValid, errors },
|
||||||
|
} = useForm({
|
||||||
|
defaultValues: async () => {
|
||||||
|
const res: NDKUserProfile = queryClient.getQueryData(['user', db.account.pubkey]);
|
||||||
|
if (res.image) {
|
||||||
|
setPicture(res.image);
|
||||||
|
}
|
||||||
|
if (res.banner) {
|
||||||
|
setBanner(res.banner);
|
||||||
|
}
|
||||||
|
if (res.nip05) {
|
||||||
|
setNIP05((prev) => ({ ...prev, text: res.nip05 }));
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadAvatar = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const image = await upload();
|
||||||
|
|
||||||
|
if (image) {
|
||||||
|
setPicture(image);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setLoading(false);
|
||||||
|
await message(`Upload failed, error: ${e}`, { title: 'Lume', type: 'error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadBanner = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const image = await upload();
|
||||||
|
|
||||||
|
if (image) {
|
||||||
|
setBanner(image);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setLoading(false);
|
||||||
|
await message(`Upload failed, error: ${e}`, { title: 'Lume', type: 'error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (data: NDKUserProfile) => {
|
||||||
|
// start loading
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const content = {
|
||||||
|
...data,
|
||||||
|
username: data.name,
|
||||||
|
display_name: data.name,
|
||||||
|
bio: data.about,
|
||||||
|
image: data.picture,
|
||||||
|
};
|
||||||
|
|
||||||
|
const event = new NDKEvent(ndk);
|
||||||
|
event.kind = NDKKind.Metadata;
|
||||||
|
event.tags = [];
|
||||||
|
|
||||||
|
if (data.nip05) {
|
||||||
|
const user = ndk.getUser({ pubkey: db.account.pubkey });
|
||||||
|
const verify = await user.validateNip05(data.nip05);
|
||||||
|
if (verify) {
|
||||||
|
event.content = JSON.stringify({ ...content, nip05: data.nip05 });
|
||||||
|
} else {
|
||||||
|
setNIP05((prev) => ({ ...prev, verified: false }));
|
||||||
|
setError('nip05', {
|
||||||
|
type: 'manual',
|
||||||
|
message: "Can't verify your Lume ID / NIP-05, please check again",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
event.content = JSON.stringify(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
const publishedRelays = await event.publish();
|
||||||
|
|
||||||
|
if (publishedRelays) {
|
||||||
|
// invalid cache
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['user', db.account.pubkey],
|
||||||
|
});
|
||||||
|
// reset form
|
||||||
|
reset();
|
||||||
|
// reset state
|
||||||
|
setLoading(false);
|
||||||
|
setPicture(null);
|
||||||
|
setBanner(null);
|
||||||
|
} else {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-md">
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="mb-0">
|
||||||
|
<input type={'hidden'} {...register('picture')} value={picture} />
|
||||||
|
<input type={'hidden'} {...register('banner')} value={banner} />
|
||||||
|
<div className="flex flex-col items-center justify-center">
|
||||||
|
<div className="relative h-36 w-full">
|
||||||
|
{banner ? (
|
||||||
|
<img
|
||||||
|
src={banner}
|
||||||
|
alt="user's banner"
|
||||||
|
className="h-full w-full rounded-xl object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="h-full w-full rounded-xl bg-neutral-200 dark:bg-neutral-900" />
|
||||||
|
)}
|
||||||
|
<div className="absolute left-1/2 top-1/2 z-10 h-full w-full -translate-x-1/2 -translate-y-1/2 transform overflow-hidden rounded-xl">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => uploadBanner()}
|
||||||
|
className="inline-flex h-full w-full items-center justify-center bg-black/20 text-white"
|
||||||
|
>
|
||||||
|
<PlusIcon className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mb-5 px-4">
|
||||||
|
<div className="relative z-10 -mt-7 h-14 w-14 overflow-hidden rounded-xl ring-2 ring-white dark:ring-black">
|
||||||
|
<img
|
||||||
|
src={picture}
|
||||||
|
alt="user's avatar"
|
||||||
|
className="h-14 w-14 rounded-xl object-cover"
|
||||||
|
/>
|
||||||
|
<div className="absolute left-1/2 top-1/2 z-10 h-full w-full -translate-x-1/2 -translate-y-1/2 transform">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => uploadAvatar()}
|
||||||
|
className="inline-flex h-full w-full items-center justify-center rounded-xl bg-black/50 text-white"
|
||||||
|
>
|
||||||
|
<PlusIcon className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label
|
||||||
|
htmlFor="display_name"
|
||||||
|
className="text-sm font-semibold uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
Display Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type={'text'}
|
||||||
|
{...register('display_name', {
|
||||||
|
required: true,
|
||||||
|
minLength: 4,
|
||||||
|
})}
|
||||||
|
spellCheck={false}
|
||||||
|
className="relative h-11 w-full rounded-lg bg-neutral-100 px-3 py-1 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 dark:bg-neutral-900 dark:text-neutral-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label
|
||||||
|
htmlFor="name"
|
||||||
|
className="text-sm font-semibold uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type={'text'}
|
||||||
|
{...register('name', {
|
||||||
|
required: true,
|
||||||
|
minLength: 4,
|
||||||
|
})}
|
||||||
|
spellCheck={false}
|
||||||
|
className="relative h-11 w-full rounded-lg bg-neutral-100 px-3 py-1 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 dark:bg-neutral-900 dark:text-neutral-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label
|
||||||
|
htmlFor="nip05"
|
||||||
|
className="text-sm font-semibold uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
NIP-05
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
{...register('nip05', {
|
||||||
|
required: true,
|
||||||
|
minLength: 4,
|
||||||
|
})}
|
||||||
|
spellCheck={false}
|
||||||
|
className="relative h-11 w-full rounded-lg bg-neutral-100 px-3 py-1 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 dark:bg-neutral-900 dark:text-neutral-100"
|
||||||
|
/>
|
||||||
|
<div className="absolute right-2 top-1/2 -translate-y-1/2 transform">
|
||||||
|
{nip05.verified ? (
|
||||||
|
<span className="inline-flex h-6 items-center gap-1 rounded-full bg-teal-500 px-1 pr-1.5 text-xs font-medium text-white">
|
||||||
|
<CheckCircleIcon className="h-4 w-4" />
|
||||||
|
Verified
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex h-6 items-center gap-1 rounded bg-red-500 pl-1 pr-1.5 text-xs font-medium text-white">
|
||||||
|
<UnverifiedIcon className="h-4 w-4" />
|
||||||
|
Unverified
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{errors.nip05 && (
|
||||||
|
<p className="mt-1 text-sm text-red-400">
|
||||||
|
{errors.nip05.message.toString()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label
|
||||||
|
htmlFor="website"
|
||||||
|
className="text-sm font-semibold uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
Website
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type={'text'}
|
||||||
|
{...register('website', { required: false })}
|
||||||
|
spellCheck={false}
|
||||||
|
className="relative h-11 w-full rounded-lg bg-neutral-100 px-3 py-1 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 dark:bg-neutral-900 dark:text-neutral-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label
|
||||||
|
htmlFor="website"
|
||||||
|
className="text-sm font-semibold uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
Lightning address
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type={'text'}
|
||||||
|
{...register('lud16', { required: false })}
|
||||||
|
spellCheck={false}
|
||||||
|
className="relative h-11 w-full rounded-lg bg-neutral-100 px-3 py-1 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 dark:bg-neutral-900 dark:text-neutral-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label
|
||||||
|
htmlFor="about"
|
||||||
|
className="text-sm font-semibold uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
Bio
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
{...register('about')}
|
||||||
|
spellCheck={false}
|
||||||
|
className="relative h-20 w-full resize-none rounded-lg bg-neutral-100 px-3 py-2 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 dark:bg-neutral-900 dark:text-neutral-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!isValid}
|
||||||
|
className="mx-auto inline-flex h-9 w-full transform items-center justify-center gap-1 rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600 focus:outline-none active:translate-y-1 disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-white" />
|
||||||
|
) : (
|
||||||
|
'Update'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,20 +1,3 @@
|
|||||||
import { AutoStartSetting } from '@app/settings/components/autoStart';
|
export function GeneralSettingScreen() {
|
||||||
import { DataPath } from '@app/settings/components/dataPath';
|
return <div></div>;
|
||||||
import { VersionSetting } from '@app/settings/components/version';
|
|
||||||
|
|
||||||
export function GeneralSettingsScreen() {
|
|
||||||
return (
|
|
||||||
<div className="h-full w-full px-3 pt-11">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<h1 className="text-xl font-semibold text-white">General</h1>
|
|
||||||
<div className="w-full rounded-xl bg-neutral-400 dark:bg-neutral-600">
|
|
||||||
<div className="flex h-full w-full flex-col divide-y divide-white/5">
|
|
||||||
<AutoStartSetting />
|
|
||||||
<DataPath />
|
|
||||||
<VersionSetting />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { ContactCard } from '@app/settings/components/contactCard';
|
||||||
|
import { PostCard } from '@app/settings/components/postCard';
|
||||||
|
import { ProfileCard } from '@app/settings/components/profileCard';
|
||||||
|
import { RelayCard } from '@app/settings/components/relayCard';
|
||||||
|
import { ZapCard } from '@app/settings/components/zapCard';
|
||||||
|
|
||||||
|
export function UserSettingScreen() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-xl">
|
||||||
|
<ProfileCard />
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<ContactCard />
|
||||||
|
<RelayCard />
|
||||||
|
<PostCard />
|
||||||
|
<ZapCard />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
import { CommandIcon } from '@shared/icons';
|
|
||||||
|
|
||||||
export function ShortcutsSettingsScreen() {
|
|
||||||
return (
|
|
||||||
<div className="h-full w-full px-3 pt-12">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<h1 className="text-lg font-semibold text-white">Shortcuts</h1>
|
|
||||||
<div className="w-full rounded-xl bg-neutral-400 dark:bg-neutral-600">
|
|
||||||
<div className="flex h-full w-full flex-col divide-y divide-white/5">
|
|
||||||
<div className="inline-flex items-center justify-between px-5 py-4">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="font-medium leading-none text-white">Open composer</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-neutral-400 dark:bg-neutral-600">
|
|
||||||
<CommandIcon
|
|
||||||
width={12}
|
|
||||||
height={12}
|
|
||||||
className="text-neutral-600 dark:text-neutral-400"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-neutral-400 dark:bg-neutral-600">
|
|
||||||
<span className="text-sm leading-none text-neutral-600 dark:text-neutral-400">
|
|
||||||
N
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="inline-flex items-center justify-between px-5 py-4">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="font-medium leading-none text-white">
|
|
||||||
Add image block
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-neutral-400 dark:bg-neutral-600">
|
|
||||||
<CommandIcon
|
|
||||||
width={12}
|
|
||||||
height={12}
|
|
||||||
className="text-neutral-600 dark:text-neutral-400"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-neutral-400 dark:bg-neutral-600">
|
|
||||||
<span className="text-sm leading-none text-neutral-600 dark:text-neutral-400">
|
|
||||||
I
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="inline-flex items-center justify-between px-5 py-4">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="font-medium leading-none text-white">
|
|
||||||
Add newsfeed block
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-neutral-400 dark:bg-neutral-600">
|
|
||||||
<CommandIcon
|
|
||||||
width={12}
|
|
||||||
height={12}
|
|
||||||
className="text-neutral-600 dark:text-neutral-400"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-neutral-400 dark:bg-neutral-600">
|
|
||||||
<span className="text-sm leading-none text-neutral-600 dark:text-neutral-400">
|
|
||||||
F
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="inline-flex items-center justify-between px-5 py-4">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="font-medium leading-none text-white">
|
|
||||||
Open personal page
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-neutral-400 dark:bg-neutral-600">
|
|
||||||
<CommandIcon
|
|
||||||
width={12}
|
|
||||||
height={12}
|
|
||||||
className="text-neutral-600 dark:text-neutral-400"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-neutral-400 dark:bg-neutral-600">
|
|
||||||
<span className="text-sm leading-none text-neutral-600 dark:text-neutral-400">
|
|
||||||
P
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="inline-flex items-center justify-between px-5 py-4">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="font-medium leading-none text-white">
|
|
||||||
Open notification
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-neutral-400 dark:bg-neutral-600">
|
|
||||||
<CommandIcon
|
|
||||||
width={12}
|
|
||||||
height={12}
|
|
||||||
className="text-neutral-600 dark:text-neutral-400"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-neutral-400 dark:bg-neutral-600">
|
|
||||||
<span className="text-sm leading-none text-neutral-600 dark:text-neutral-400">
|
|
||||||
B
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { useStorage } from '@libs/storage/provider';
|
|
||||||
|
|
||||||
import { HandArrowDownIcon, PlusIcon } from '@shared/icons';
|
|
||||||
|
|
||||||
import { WidgetKinds, useWidgets } from '@stores/widgets';
|
|
||||||
|
|
||||||
export function ToggleWidgetList() {
|
|
||||||
const { db } = useStorage();
|
|
||||||
const setWidget = useWidgets((state) => state.setWidget);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-[420px] items-center justify-center border-r border-neutral-100 dark:border-neutral-900">
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute -top-44 left-1/2 -translate-x-1/2 transform">
|
|
||||||
<HandArrowDownIcon className="text-neutral-100 dark:text-neutral-900" />
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
setWidget(db, { kind: WidgetKinds.tmp.list, title: '', content: '' })
|
|
||||||
}
|
|
||||||
className="inline-flex h-9 items-center gap-2 rounded-full bg-neutral-200 px-3 text-neutral-900 hover:bg-neutral-300 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700"
|
|
||||||
>
|
|
||||||
<PlusIcon className="h-4 w-4 text-neutral-900 dark:text-zinc-100" />
|
|
||||||
<p className="text-sm font-semibold leading-none">Add widget</p>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
import { useCallback } from 'react';
|
|
||||||
|
|
||||||
import { useStorage } from '@libs/storage/provider';
|
|
||||||
|
|
||||||
import {
|
|
||||||
ArticleIcon,
|
|
||||||
BellIcon,
|
|
||||||
FileIcon,
|
|
||||||
FollowsIcon,
|
|
||||||
GroupFeedsIcon,
|
|
||||||
HashtagIcon,
|
|
||||||
ThreadsIcon,
|
|
||||||
TrendingIcon,
|
|
||||||
} from '@shared/icons';
|
|
||||||
import { TitleBar } from '@shared/titleBar';
|
|
||||||
|
|
||||||
import { DefaultWidgets, WidgetKinds, useWidgets } from '@stores/widgets';
|
|
||||||
|
|
||||||
import { Widget, WidgetGroup, WidgetGroupItem } from '@utils/types';
|
|
||||||
|
|
||||||
export function WidgetList({ params }: { params: Widget }) {
|
|
||||||
const { db } = useStorage();
|
|
||||||
const [setWidget, removeWidget] = useWidgets((state) => [
|
|
||||||
state.setWidget,
|
|
||||||
state.removeWidget,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const openWidget = (widget: WidgetGroupItem) => {
|
|
||||||
setWidget(db, { kind: widget.kind, title: widget.title, content: '' });
|
|
||||||
removeWidget(db, params.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderIcon = useCallback(
|
|
||||||
(kind: number) => {
|
|
||||||
switch (kind) {
|
|
||||||
case WidgetKinds.tmp.xfeed:
|
|
||||||
return (
|
|
||||||
<GroupFeedsIcon className="h-5 w-5 text-neutral-900 dark:text-neutral-100" />
|
|
||||||
);
|
|
||||||
case WidgetKinds.local.follows:
|
|
||||||
return (
|
|
||||||
<FollowsIcon className="h-5 w-5 text-neutral-900 dark:text-neutral-100" />
|
|
||||||
);
|
|
||||||
case WidgetKinds.local.files:
|
|
||||||
case WidgetKinds.global.files:
|
|
||||||
return <FileIcon className="h-5 w-5 text-neutral-900 dark:text-neutral-100" />;
|
|
||||||
case WidgetKinds.local.articles:
|
|
||||||
case WidgetKinds.global.articles:
|
|
||||||
return (
|
|
||||||
<ArticleIcon className="h-5 w-5 text-neutral-900 dark:text-neutral-100" />
|
|
||||||
);
|
|
||||||
case WidgetKinds.tmp.xhashtag:
|
|
||||||
return (
|
|
||||||
<HashtagIcon className="h-5 w-5 text-neutral-900 dark:text-neutral-100" />
|
|
||||||
);
|
|
||||||
case WidgetKinds.nostrBand.trendingAccounts:
|
|
||||||
case WidgetKinds.nostrBand.trendingNotes:
|
|
||||||
return (
|
|
||||||
<TrendingIcon className="h-5 w-5 text-neutral-900 dark:text-neutral-100" />
|
|
||||||
);
|
|
||||||
case WidgetKinds.local.notification:
|
|
||||||
return <BellIcon className="h-5 w-5 text-neutral-900 dark:text-neutral-100" />;
|
|
||||||
case WidgetKinds.other.learnNostr:
|
|
||||||
return (
|
|
||||||
<ThreadsIcon className="h-5 w-5 text-neutral-900 dark:text-neutral-100" />
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[DefaultWidgets]
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderItem = useCallback(
|
|
||||||
(row: WidgetGroup, index: number) => {
|
|
||||||
return (
|
|
||||||
<div key={index} className="flex flex-col gap-2">
|
|
||||||
<h3 className="text-sm font-semibold text-neutral-500 dark:text-neutral-300">
|
|
||||||
{row.title}
|
|
||||||
</h3>
|
|
||||||
<div className="flex flex-col divide-y divide-neutral-100 overflow-hidden rounded-xl bg-neutral-50 dark:divide-neutral-900 dark:bg-neutral-950">
|
|
||||||
{row.data.map((item, index) => (
|
|
||||||
<button
|
|
||||||
onClick={() => openWidget(item)}
|
|
||||||
key={index}
|
|
||||||
className="group flex items-center gap-2.5 px-4 hover:bg-neutral-200 dark:hover:bg-neutral-800"
|
|
||||||
>
|
|
||||||
{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-neutral-200 group-hover:bg-neutral-300 dark:bg-neutral-800 dark:group-hover:bg-neutral-700">
|
|
||||||
{renderIcon(item.kind)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="inline-flex h-16 w-full flex-col items-start justify-center">
|
|
||||||
<h5 className="line-clamp-1 text-sm font-semibold text-neutral-900 dark:text-neutral-100">
|
|
||||||
{item.title}
|
|
||||||
</h5>
|
|
||||||
<p className="line-clamp-1 text-sm text-neutral-500 dark:text-neutral-300">
|
|
||||||
{item.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[DefaultWidgets]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full w-[420px] border-r border-neutral-100 dark:border-neutral-900">
|
|
||||||
<TitleBar id={params.id} title="Add widget" />
|
|
||||||
<div className="h-full overflow-y-auto pb-20 scrollbar-none">
|
|
||||||
<div className="flex flex-col gap-6 px-3">
|
|
||||||
{DefaultWidgets.map((row: WidgetGroup, index: number) =>
|
|
||||||
renderItem(row, index)
|
|
||||||
)}
|
|
||||||
<div className="border-t border-neutral-200 pt-6 dark:border-neutral-800">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled
|
|
||||||
className="inline-flex h-14 w-full items-center justify-center gap-2.5 rounded-xl bg-neutral-100 text-sm font-medium text-neutral-900 dark:bg-neutral-900 dark:text-neutral-100"
|
|
||||||
>
|
|
||||||
Build your own widget{' '}
|
|
||||||
<div className="-rotate-3 transform-gpu rounded-md border border-neutral-300 bg-neutral-200 px-1.5 py-1 dark:border-neutral-700 dark:bg-neutral-800">
|
|
||||||
<span className="bg-gradient-to-r from-blue-400 via-red-400 to-orange-500 bg-clip-text text-xs text-transparent dark:from-blue-200 dark:via-red-200 dark:to-orange-300">
|
|
||||||
Coming soon
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
import { useCallback, useEffect, useRef } from 'react';
|
|
||||||
import { VList, VListHandle } from 'virtua';
|
|
||||||
|
|
||||||
import { ToggleWidgetList } from '@app/space/components/toggle';
|
|
||||||
import { WidgetList } from '@app/space/components/widgetList';
|
|
||||||
|
|
||||||
import { useStorage } from '@libs/storage/provider';
|
|
||||||
|
|
||||||
import { LoaderIcon } from '@shared/icons';
|
|
||||||
import {
|
|
||||||
GlobalArticlesWidget,
|
|
||||||
GlobalFilesWidget,
|
|
||||||
GlobalHashtagWidget,
|
|
||||||
LearnNostrWidget,
|
|
||||||
LocalArticlesWidget,
|
|
||||||
LocalFeedsWidget,
|
|
||||||
LocalFilesWidget,
|
|
||||||
LocalFollowsWidget,
|
|
||||||
LocalNetworkWidget,
|
|
||||||
LocalNotificationWidget,
|
|
||||||
LocalThreadWidget,
|
|
||||||
LocalUserWidget,
|
|
||||||
TrendingAccountsWidget,
|
|
||||||
TrendingNotesWidget,
|
|
||||||
XfeedsWidget,
|
|
||||||
XhashtagWidget,
|
|
||||||
} from '@shared/widgets';
|
|
||||||
|
|
||||||
import { WidgetKinds, useWidgets } from '@stores/widgets';
|
|
||||||
|
|
||||||
import { Widget } from '@utils/types';
|
|
||||||
|
|
||||||
export function SpaceScreen() {
|
|
||||||
const { db } = useStorage();
|
|
||||||
const vlistRef = useRef<VListHandle>(null);
|
|
||||||
|
|
||||||
const [widgets, fetchWidgets] = useWidgets((state) => [
|
|
||||||
state.widgets,
|
|
||||||
state.fetchWidgets,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const renderItem = useCallback(
|
|
||||||
(widget: Widget) => {
|
|
||||||
if (!widget) return;
|
|
||||||
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:
|
|
||||||
return <LocalFilesWidget key={widget.id} params={widget} />;
|
|
||||||
case WidgetKinds.local.articles:
|
|
||||||
return <LocalArticlesWidget key={widget.id} params={widget} />;
|
|
||||||
case WidgetKinds.local.user:
|
|
||||||
return <LocalUserWidget key={widget.id} params={widget} />;
|
|
||||||
case WidgetKinds.local.thread:
|
|
||||||
return <LocalThreadWidget key={widget.id} params={widget} />;
|
|
||||||
case WidgetKinds.global.hashtag:
|
|
||||||
return <GlobalHashtagWidget key={widget.id} params={widget} />;
|
|
||||||
case WidgetKinds.global.articles:
|
|
||||||
return <GlobalArticlesWidget key={widget.id} params={widget} />;
|
|
||||||
case WidgetKinds.global.files:
|
|
||||||
return <GlobalFilesWidget key={widget.id} params={widget} />;
|
|
||||||
case WidgetKinds.nostrBand.trendingAccounts:
|
|
||||||
return <TrendingAccountsWidget key={widget.id} params={widget} />;
|
|
||||||
case WidgetKinds.nostrBand.trendingNotes:
|
|
||||||
return <TrendingNotesWidget key={widget.id} params={widget} />;
|
|
||||||
case WidgetKinds.tmp.xfeed:
|
|
||||||
return <XfeedsWidget key={widget.id} params={widget} />;
|
|
||||||
case WidgetKinds.tmp.xhashtag:
|
|
||||||
return <XhashtagWidget 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} />;
|
|
||||||
case WidgetKinds.local.notification:
|
|
||||||
return <LocalNotificationWidget key={widget.id} params={widget} />;
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[widgets]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchWidgets(db);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VList
|
|
||||||
className="h-full w-full flex-nowrap overflow-x-auto !overflow-y-hidden scrollbar-none focus:outline-none"
|
|
||||||
horizontal
|
|
||||||
ref={vlistRef}
|
|
||||||
initialItemSize={420}
|
|
||||||
aria-current="step"
|
|
||||||
tabIndex={0}
|
|
||||||
>
|
|
||||||
{!widgets ? (
|
|
||||||
<div className="flex h-full w-[420px] flex-col items-center justify-center">
|
|
||||||
<LoaderIcon className="h-5 w-5 animate-spin text-neutral-900 dark:text-neutral-100" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
widgets.map((widget) => renderItem(widget))
|
|
||||||
)}
|
|
||||||
<ToggleWidgetList />
|
|
||||||
</VList>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,416 +0,0 @@
|
|||||||
import { NDKEvent, NDKKind, NDKUserProfile } from '@nostr-dev-kit/ndk';
|
|
||||||
import * as Dialog from '@radix-ui/react-dialog';
|
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { message, open } from '@tauri-apps/plugin-dialog';
|
|
||||||
import { readBinaryFile } from '@tauri-apps/plugin-fs';
|
|
||||||
import { fetch } from '@tauri-apps/plugin-http';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
|
|
||||||
import { useNDK } from '@libs/ndk/provider';
|
|
||||||
import { useStorage } from '@libs/storage/provider';
|
|
||||||
|
|
||||||
import {
|
|
||||||
CancelIcon,
|
|
||||||
CheckCircleIcon,
|
|
||||||
LoaderIcon,
|
|
||||||
PlusIcon,
|
|
||||||
UnverifiedIcon,
|
|
||||||
} from '@shared/icons';
|
|
||||||
|
|
||||||
interface NIP05 {
|
|
||||||
names: {
|
|
||||||
[key: string]: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EditProfileModal() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [picture, setPicture] = useState('');
|
|
||||||
const [banner, setBanner] = useState('');
|
|
||||||
const [nip05, setNIP05] = useState({ verified: false, text: '' });
|
|
||||||
|
|
||||||
const { db } = useStorage();
|
|
||||||
const { ndk } = useNDK();
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
handleSubmit,
|
|
||||||
reset,
|
|
||||||
setError,
|
|
||||||
formState: { isValid, errors },
|
|
||||||
} = useForm({
|
|
||||||
defaultValues: async () => {
|
|
||||||
const res: NDKUserProfile = queryClient.getQueryData(['user', db.account.pubkey]);
|
|
||||||
if (res.image) {
|
|
||||||
setPicture(res.image);
|
|
||||||
}
|
|
||||||
if (res.banner) {
|
|
||||||
setBanner(res.banner);
|
|
||||||
}
|
|
||||||
if (res.nip05) {
|
|
||||||
setNIP05((prev) => ({ ...prev, text: res.nip05 }));
|
|
||||||
}
|
|
||||||
return res;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const verifyNIP05 = async (nip05: string) => {
|
|
||||||
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',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json; charset=utf-8',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) throw new Error(`Failed to fetch NIP-05 service: ${nip05}`);
|
|
||||||
|
|
||||||
const data: NIP05 = await res.json();
|
|
||||||
if (data.names) {
|
|
||||||
if (data.names[localPath] !== db.account.pubkey) return false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const uploadAvatar = async () => {
|
|
||||||
try {
|
|
||||||
// start loading
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const selected = await open({
|
|
||||||
multiple: false,
|
|
||||||
filters: [
|
|
||||||
{
|
|
||||||
name: 'Image',
|
|
||||||
extensions: ['png', 'jpeg', 'jpg', 'gif'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!selected) {
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const file = await readBinaryFile(selected.path);
|
|
||||||
const blob = new Blob([file]);
|
|
||||||
|
|
||||||
const data = new FormData();
|
|
||||||
data.append('fileToUpload', blob);
|
|
||||||
data.append('submit', 'Upload Image');
|
|
||||||
|
|
||||||
const res = await fetch('https://nostr.build/api/v2/upload/files', {
|
|
||||||
method: 'POST',
|
|
||||||
body: data,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
const json = await res.json();
|
|
||||||
const content = json.data[0];
|
|
||||||
|
|
||||||
setPicture(content.url);
|
|
||||||
|
|
||||||
// stop loading
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// stop loading
|
|
||||||
setLoading(false);
|
|
||||||
await message(`Upload failed, error: ${e}`, { title: 'Lume', type: 'error' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const uploadBanner = async () => {
|
|
||||||
try {
|
|
||||||
// start loading
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const selected = await open({
|
|
||||||
multiple: false,
|
|
||||||
filters: [
|
|
||||||
{
|
|
||||||
name: 'Image',
|
|
||||||
extensions: ['png', 'jpeg', 'jpg', 'gif'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!selected) {
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const file = await readBinaryFile(selected.path);
|
|
||||||
const blob = new Blob([file]);
|
|
||||||
|
|
||||||
const data = new FormData();
|
|
||||||
data.append('fileToUpload', blob);
|
|
||||||
data.append('submit', 'Upload Image');
|
|
||||||
|
|
||||||
const res = await fetch('https://nostr.build/api/v2/upload/files', {
|
|
||||||
method: 'POST',
|
|
||||||
body: data,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
const json = await res.json();
|
|
||||||
const content = json.data[0];
|
|
||||||
|
|
||||||
setBanner(content.url);
|
|
||||||
|
|
||||||
// stop loading
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// stop loading
|
|
||||||
setLoading(false);
|
|
||||||
await message(`Upload failed, error: ${e}`, { title: 'Lume', type: 'error' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSubmit = async (data: NDKUserProfile) => {
|
|
||||||
// start loading
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const content = {
|
|
||||||
...data,
|
|
||||||
username: data.name,
|
|
||||||
display_name: data.name,
|
|
||||||
bio: data.about,
|
|
||||||
image: data.picture,
|
|
||||||
};
|
|
||||||
|
|
||||||
const event = new NDKEvent(ndk);
|
|
||||||
event.kind = NDKKind.Metadata;
|
|
||||||
event.tags = [];
|
|
||||||
|
|
||||||
if (data.nip05) {
|
|
||||||
const nip05IsVerified = await verifyNIP05(data.nip05);
|
|
||||||
if (nip05IsVerified) {
|
|
||||||
event.content = JSON.stringify({ ...content, nip05: data.nip05 });
|
|
||||||
} else {
|
|
||||||
setNIP05((prev) => ({ ...prev, verified: false }));
|
|
||||||
setError('nip05', {
|
|
||||||
type: 'manual',
|
|
||||||
message: "Can't verify your Lume ID / NIP-05, please check again",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
event.content = JSON.stringify(content);
|
|
||||||
}
|
|
||||||
|
|
||||||
const publishedRelays = await event.publish();
|
|
||||||
|
|
||||||
if (publishedRelays) {
|
|
||||||
// invalid cache
|
|
||||||
queryClient.invalidateQueries(['user', db.account.pubkey]);
|
|
||||||
// reset form
|
|
||||||
reset();
|
|
||||||
// reset state
|
|
||||||
setLoading(false);
|
|
||||||
setIsOpen(false);
|
|
||||||
setPicture('https://void.cat/d/5VKmKyuHyxrNMf9bWSVPih');
|
|
||||||
setBanner(null);
|
|
||||||
} else {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!nip05.verified && /\S+@\S+\.\S+/.test(nip05.text)) {
|
|
||||||
verifyNIP05(nip05.text);
|
|
||||||
}
|
|
||||||
}, [nip05.text]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
|
|
||||||
<Dialog.Trigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-neutral-200 text-sm font-medium text-neutral-900 backdrop-blur-xl hover:bg-blue-600 hover:text-neutral-100 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-blue-600 dark:hover:text-neutral-100"
|
|
||||||
>
|
|
||||||
Edit profile
|
|
||||||
</button>
|
|
||||||
</Dialog.Trigger>
|
|
||||||
<Dialog.Portal>
|
|
||||||
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/20 backdrop-blur-sm dark:bg-black/20" />
|
|
||||||
<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-neutral-100 dark:bg-neutral-900">
|
|
||||||
<div className="h-min w-full shrink-0 rounded-t-xl border-b border-neutral-200 px-5 py-5 dark:border-neutral-800">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Dialog.Title className="text-lg font-semibold leading-none text-neutral-900 dark:text-neutral-100">
|
|
||||||
Edit profile
|
|
||||||
</Dialog.Title>
|
|
||||||
<Dialog.Close className="inline-flex h-6 w-6 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-200 dark:text-neutral-100 dark:hover:bg-neutral-800">
|
|
||||||
<CancelIcon className="h-4 w-4" />
|
|
||||||
</Dialog.Close>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex h-full w-full flex-col overflow-y-auto">
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="mb-0">
|
|
||||||
<input type={'hidden'} {...register('picture')} value={picture} />
|
|
||||||
<input type={'hidden'} {...register('banner')} value={banner} />
|
|
||||||
<div className="relative">
|
|
||||||
<div className="relative h-44 w-full">
|
|
||||||
{banner ? (
|
|
||||||
<img
|
|
||||||
src={banner}
|
|
||||||
alt="user's banner"
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="h-full w-full bg-black dark:bg-white" />
|
|
||||||
)}
|
|
||||||
<div className="absolute left-1/2 top-1/2 z-10 h-full w-full -translate-x-1/2 -translate-y-1/2 transform">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => uploadBanner()}
|
|
||||||
className="inline-flex h-full w-full items-center justify-center bg-black/50"
|
|
||||||
>
|
|
||||||
<PlusIcon className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mb-5 px-4">
|
|
||||||
<div className="relative z-10 -mt-7 h-14 w-14 overflow-hidden rounded-xl ring-2 ring-neutral-900">
|
|
||||||
<img
|
|
||||||
src={picture}
|
|
||||||
alt="user's avatar"
|
|
||||||
className="h-14 w-14 rounded-xl object-cover"
|
|
||||||
/>
|
|
||||||
<div className="absolute left-1/2 top-1/2 z-10 h-full w-full -translate-x-1/2 -translate-y-1/2 transform">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => uploadAvatar()}
|
|
||||||
className="inline-flex h-full w-full items-center justify-center rounded-xl bg-black/50"
|
|
||||||
>
|
|
||||||
<PlusIcon className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</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-neutral-500 dark:text-neutral-400"
|
|
||||||
>
|
|
||||||
Name
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type={'text'}
|
|
||||||
{...register('name', {
|
|
||||||
required: true,
|
|
||||||
minLength: 4,
|
|
||||||
})}
|
|
||||||
spellCheck={false}
|
|
||||||
className="relative h-11 w-full rounded-lg bg-neutral-200 px-3 py-1 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 dark:bg-neutral-800 dark:text-neutral-100"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label
|
|
||||||
htmlFor="nip05"
|
|
||||||
className="text-sm font-semibold uppercase tracking-wider text-neutral-500 dark:text-neutral-400"
|
|
||||||
>
|
|
||||||
NIP-05
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
{...register('nip05', {
|
|
||||||
required: true,
|
|
||||||
minLength: 4,
|
|
||||||
})}
|
|
||||||
spellCheck={false}
|
|
||||||
className="relative h-11 w-full rounded-lg bg-neutral-200 px-3 py-1 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 dark:bg-neutral-800 dark:text-neutral-100"
|
|
||||||
/>
|
|
||||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 transform">
|
|
||||||
{nip05.verified ? (
|
|
||||||
<span className="inline-flex h-6 items-center gap-1 rounded bg-green-500 px-2 text-sm font-medium text-white">
|
|
||||||
<CheckCircleIcon className="h-4 w-4 text-black dark:text-white" />
|
|
||||||
Verified
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="inline-flex h-6 items-center gap-1 rounded bg-red-500 px-2 text-sm font-medium text-white">
|
|
||||||
<UnverifiedIcon className="h-4 w-4 text-black dark:text-white" />
|
|
||||||
Unverified
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{errors.nip05 && (
|
|
||||||
<p className="mt-1 text-sm text-red-400">
|
|
||||||
{errors.nip05.message.toString()}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label
|
|
||||||
htmlFor="about"
|
|
||||||
className="text-sm font-semibold uppercase tracking-wider text-neutral-500 dark:text-neutral-400"
|
|
||||||
>
|
|
||||||
Bio
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
{...register('about')}
|
|
||||||
spellCheck={false}
|
|
||||||
className="relative h-20 w-full resize-none rounded-lg bg-neutral-200 px-3 py-2 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 dark:bg-neutral-800 dark:text-neutral-100"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label
|
|
||||||
htmlFor="website"
|
|
||||||
className="text-sm font-semibold uppercase tracking-wider text-neutral-500 dark:text-neutral-400"
|
|
||||||
>
|
|
||||||
Website
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type={'text'}
|
|
||||||
{...register('website', { required: false })}
|
|
||||||
spellCheck={false}
|
|
||||||
className="relative h-11 w-full rounded-lg bg-neutral-200 px-3 py-1 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 dark:bg-neutral-800 dark:text-neutral-100"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label
|
|
||||||
htmlFor="website"
|
|
||||||
className="text-sm font-semibold uppercase tracking-wider text-neutral-500 dark:text-neutral-400"
|
|
||||||
>
|
|
||||||
Lightning address
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type={'text'}
|
|
||||||
{...register('lud16', { required: false })}
|
|
||||||
spellCheck={false}
|
|
||||||
className="relative h-11 w-full rounded-lg bg-neutral-200 px-3 py-1 text-neutral-900 !outline-none backdrop-blur-xl placeholder:text-neutral-500 dark:bg-neutral-800 dark:text-neutral-100"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={!isValid}
|
|
||||||
className="inline-flex h-11 w-full transform items-center justify-center gap-1 rounded-lg bg-blue-500 font-medium text-white hover:bg-blue-600 focus:outline-none active:translate-y-1 disabled:pointer-events-none disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-white" />
|
|
||||||
) : (
|
|
||||||
'Update'
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dialog.Content>
|
|
||||||
</Dialog.Portal>
|
|
||||||
</Dialog.Root>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
import { NDKEvent, NDKKind, NDKUser } from '@nostr-dev-kit/ndk';
|
import { NDKEvent, NDKKind, NDKUser } from '@nostr-dev-kit/ndk';
|
||||||
|
import * as Avatar from '@radix-ui/react-avatar';
|
||||||
|
import { minidenticon } from 'minidenticons';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import { EditProfileModal } from '@app/users/components/modal';
|
|
||||||
import { UserStats } from '@app/users/components/stats';
|
import { UserStats } from '@app/users/components/stats';
|
||||||
|
|
||||||
import { useNDK } from '@libs/ndk/provider';
|
import { useNDK } from '@libs/ndk/provider';
|
||||||
import { useStorage } from '@libs/storage/provider';
|
import { useStorage } from '@libs/storage/provider';
|
||||||
|
|
||||||
import { Image } from '@shared/image';
|
|
||||||
import { NIP05 } from '@shared/nip05';
|
import { NIP05 } from '@shared/nip05';
|
||||||
|
|
||||||
import { useProfile } from '@utils/hooks/useProfile';
|
import { useProfile } from '@utils/hooks/useProfile';
|
||||||
@@ -22,11 +22,14 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
|
|||||||
|
|
||||||
const [followed, setFollowed] = useState(false);
|
const [followed, setFollowed] = useState(false);
|
||||||
|
|
||||||
|
const svgURI =
|
||||||
|
'data:image/svg+xml;utf8,' + encodeURIComponent(minidenticon(pubkey, 90, 50));
|
||||||
|
|
||||||
const follow = async (pubkey: string) => {
|
const follow = async (pubkey: string) => {
|
||||||
try {
|
try {
|
||||||
const user = ndk.getUser({ hexpubkey: db.account.pubkey });
|
const user = ndk.getUser({ pubkey: db.account.pubkey });
|
||||||
const contacts = await user.follows();
|
const contacts = await user.follows();
|
||||||
const add = await user.follow(new NDKUser({ hexpubkey: pubkey }), contacts);
|
const add = await user.follow(new NDKUser({ pubkey: pubkey }), contacts);
|
||||||
|
|
||||||
if (add) {
|
if (add) {
|
||||||
setFollowed(true);
|
setFollowed(true);
|
||||||
@@ -40,14 +43,12 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
|
|||||||
|
|
||||||
const unfollow = async (pubkey: string) => {
|
const unfollow = async (pubkey: string) => {
|
||||||
try {
|
try {
|
||||||
const user = ndk.getUser({ hexpubkey: db.account.pubkey });
|
const user = ndk.getUser({ pubkey: db.account.pubkey });
|
||||||
const contacts = await user.follows();
|
const contacts = await user.follows();
|
||||||
contacts.delete(new NDKUser({ hexpubkey: pubkey }));
|
contacts.delete(new NDKUser({ pubkey: pubkey }));
|
||||||
|
|
||||||
let list: string[][];
|
let list: string[][];
|
||||||
contacts.forEach((el) =>
|
contacts.forEach((el) => list.push(['p', el.pubkey, el.relayUrls?.[0] || '', '']));
|
||||||
list.push(['p', el.pubkey, el.relayUrls?.[0] || '', el.profile?.name || ''])
|
|
||||||
);
|
|
||||||
|
|
||||||
const event = new NDKEvent(ndk);
|
const event = new NDKEvent(ndk);
|
||||||
event.content = '';
|
event.content = '';
|
||||||
@@ -85,11 +86,23 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="-mt-7 flex w-full flex-col items-center px-5">
|
<div className="-mt-7 flex w-full flex-col items-center px-5">
|
||||||
<Image
|
<Avatar.Root className="shrink-0">
|
||||||
src={user.picture || user.image}
|
<Avatar.Image
|
||||||
|
src={user?.picture || user?.image}
|
||||||
alt={pubkey}
|
alt={pubkey}
|
||||||
className="h-14 w-14 rounded-lg ring-2 ring-neutral-100 dark:ring-neutral-900"
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
style={{ contentVisibility: 'auto' }}
|
||||||
|
className="h-14 w-14 rounded-lg bg-white ring-2 ring-neutral-100 dark:ring-neutral-900"
|
||||||
/>
|
/>
|
||||||
|
<Avatar.Fallback delayMs={300}>
|
||||||
|
<img
|
||||||
|
src={svgURI}
|
||||||
|
alt={pubkey}
|
||||||
|
className="h-14 w-14 rounded-lg bg-black dark:bg-white"
|
||||||
|
/>
|
||||||
|
</Avatar.Fallback>
|
||||||
|
</Avatar.Root>
|
||||||
<div className="mt-2 flex flex-1 flex-col gap-6">
|
<div className="mt-2 flex flex-1 flex-col gap-6">
|
||||||
<div className="flex flex-col items-center gap-1">
|
<div className="flex flex-col items-center gap-1">
|
||||||
<div className="inline-flex flex-col items-center">
|
<div className="inline-flex flex-col items-center">
|
||||||
@@ -100,7 +113,7 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
|
|||||||
<NIP05
|
<NIP05
|
||||||
pubkey={pubkey}
|
pubkey={pubkey}
|
||||||
nip05={user?.nip05}
|
nip05={user?.nip05}
|
||||||
className="max-w-[15rem] truncate text-sm text-neutral-500 dark:text-neutral-400"
|
className="text-neutral-600 dark:text-neutral-400"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="max-w-[15rem] truncate text-sm text-neutral-500 dark:text-neutral-400">
|
<span className="max-w-[15rem] truncate text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
@@ -143,12 +156,6 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
|
|||||||
>
|
>
|
||||||
Message
|
Message
|
||||||
</Link>
|
</Link>
|
||||||
{db.account.pubkey === pubkey && (
|
|
||||||
<>
|
|
||||||
<span className="mx-2 inline-flex h-4 w-px bg-neutral-200 dark:bg-neutral-800" />
|
|
||||||
<EditProfileModal />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,19 +1,31 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { fetch } from '@tauri-apps/plugin-http';
|
||||||
|
|
||||||
import { LoaderIcon } from '@shared/icons';
|
import { LoaderIcon } from '@shared/icons';
|
||||||
|
|
||||||
import { compactNumber } from '@utils/number';
|
import { compactNumber } from '@utils/number';
|
||||||
|
|
||||||
export function UserStats({ pubkey }: { pubkey: string }) {
|
export function UserStats({ pubkey }: { pubkey: string }) {
|
||||||
const { status, data } = useQuery(['user-metadata', pubkey], async () => {
|
const { status, data } = useQuery({
|
||||||
const res = await fetch(`https://api.nostr.band/v0/stats/profile/${pubkey}`);
|
queryKey: ['user-stats', pubkey],
|
||||||
|
queryFn: async ({ signal }: { signal: AbortSignal }) => {
|
||||||
|
const res = await fetch(`https://api.nostr.band/v0/stats/profile/${pubkey}`, {
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error('Error');
|
throw new Error('Error');
|
||||||
}
|
}
|
||||||
|
|
||||||
return await res.json();
|
return await res.json();
|
||||||
|
},
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
staleTime: Infinity,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (status === 'loading') {
|
if (status === 'pending') {
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full items-center justify-center">
|
<div className="flex w-full items-center justify-center">
|
||||||
<LoaderIcon className="h-5 w-5 animate-spin text-neutral-900 dark:text-neutral-100" />
|
<LoaderIcon className="h-5 w-5 animate-spin text-neutral-900 dark:text-neutral-100" />
|
||||||
@@ -29,7 +41,7 @@ export function UserStats({ pubkey }: { pubkey: string }) {
|
|||||||
<div className="flex w-full items-center justify-center gap-10">
|
<div className="flex w-full items-center justify-center gap-10">
|
||||||
<div className="inline-flex flex-col items-center gap-1">
|
<div className="inline-flex flex-col items-center gap-1">
|
||||||
<span className="font-semibold leading-none text-neutral-900 dark:text-neutral-100">
|
<span className="font-semibold leading-none text-neutral-900 dark:text-neutral-100">
|
||||||
{compactNumber.format(data.stats[pubkey].followers_pubkey_count) ?? 0}
|
{compactNumber.format(data?.stats[pubkey]?.followers_pubkey_count) ?? 0}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm leading-none text-neutral-500 dark:text-neutral-400">
|
<span className="text-sm leading-none text-neutral-500 dark:text-neutral-400">
|
||||||
Followers
|
Followers
|
||||||
@@ -37,7 +49,7 @@ export function UserStats({ pubkey }: { pubkey: string }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="inline-flex flex-col items-center gap-1">
|
<div className="inline-flex flex-col items-center gap-1">
|
||||||
<span className="font-semibold leading-none text-neutral-900 dark:text-neutral-100">
|
<span className="font-semibold leading-none text-neutral-900 dark:text-neutral-100">
|
||||||
{compactNumber.format(data.stats[pubkey].pub_following_pubkey_count) ?? 0}
|
{compactNumber.format(data?.stats[pubkey]?.pub_following_pubkey_count) ?? 0}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm leading-none text-neutral-500 dark:text-neutral-400">
|
<span className="text-sm leading-none text-neutral-500 dark:text-neutral-400">
|
||||||
Following
|
Following
|
||||||
@@ -45,9 +57,7 @@ export function UserStats({ pubkey }: { pubkey: string }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="inline-flex flex-col items-center gap-1">
|
<div className="inline-flex flex-col items-center gap-1">
|
||||||
<span className="font-semibold leading-none text-neutral-900 dark:text-neutral-100">
|
<span className="font-semibold leading-none text-neutral-900 dark:text-neutral-100">
|
||||||
{data.stats[pubkey].zaps_received
|
{compactNumber.format(data?.stats[pubkey]?.zaps_received?.msats / 1000 ?? 0)}
|
||||||
? compactNumber.format(data.stats[pubkey].zaps_received.msats / 1000)
|
|
||||||
: 0}
|
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm leading-none text-neutral-500 dark:text-neutral-400">
|
<span className="text-sm leading-none text-neutral-500 dark:text-neutral-400">
|
||||||
Zaps received
|
Zaps received
|
||||||
@@ -55,9 +65,7 @@ export function UserStats({ pubkey }: { pubkey: string }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="inline-flex flex-col items-center gap-1">
|
<div className="inline-flex flex-col items-center gap-1">
|
||||||
<span className="font-semibold leading-none text-neutral-900 dark:text-neutral-100">
|
<span className="font-semibold leading-none text-neutral-900 dark:text-neutral-100">
|
||||||
{data.stats[pubkey].zaps_sent
|
{compactNumber.format(data?.stats[pubkey]?.zaps_sent?.msats / 1000 ?? 0)}
|
||||||
? compactNumber.format(data.stats[pubkey].zaps_sent.msats / 1000)
|
|
||||||
: 0}
|
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm leading-none text-neutral-500 dark:text-neutral-400">
|
<span className="text-sm leading-none text-neutral-500 dark:text-neutral-400">
|
||||||
Zaps sent
|
Zaps sent
|
||||||
|
|||||||
@@ -7,26 +7,23 @@ import { UserProfile } from '@app/users/components/profile';
|
|||||||
|
|
||||||
import { useNDK } from '@libs/ndk/provider';
|
import { useNDK } from '@libs/ndk/provider';
|
||||||
|
|
||||||
import {
|
import { MemoizedRepost, MemoizedTextNote, UnknownNote } from '@shared/notes';
|
||||||
ArticleNote,
|
|
||||||
FileNote,
|
|
||||||
NoteWrapper,
|
|
||||||
Repost,
|
|
||||||
TextNote,
|
|
||||||
UnknownNote,
|
|
||||||
} from '@shared/notes';
|
|
||||||
|
|
||||||
export function UserScreen() {
|
export function UserScreen() {
|
||||||
const { pubkey } = useParams();
|
const { pubkey } = useParams();
|
||||||
const { ndk } = useNDK();
|
const { ndk } = useNDK();
|
||||||
const { status, data } = useQuery(['user-feed', pubkey], async () => {
|
const { status, data } = useQuery({
|
||||||
|
queryKey: ['user-feed', pubkey],
|
||||||
|
queryFn: async () => {
|
||||||
const events = await ndk.fetchEvents({
|
const events = await ndk.fetchEvents({
|
||||||
kinds: [NDKKind.Text, NDKKind.Repost, NDKKind.Article],
|
kinds: [NDKKind.Text, NDKKind.Repost],
|
||||||
authors: [pubkey],
|
authors: [pubkey],
|
||||||
limit: 50,
|
limit: 20,
|
||||||
});
|
});
|
||||||
const sorted = [...events].sort((a, b) => b.created_at - a.created_at);
|
const sorted = [...events].sort((a, b) => b.created_at - a.created_at);
|
||||||
return sorted;
|
return sorted;
|
||||||
|
},
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// render event match event kind
|
// render event match event kind
|
||||||
@@ -34,31 +31,11 @@ export function UserScreen() {
|
|||||||
(event: NDKEvent) => {
|
(event: NDKEvent) => {
|
||||||
switch (event.kind) {
|
switch (event.kind) {
|
||||||
case NDKKind.Text:
|
case NDKKind.Text:
|
||||||
return (
|
return <MemoizedTextNote key={event.id} event={event} />;
|
||||||
<NoteWrapper key={event.id} event={event}>
|
|
||||||
<TextNote />
|
|
||||||
</NoteWrapper>
|
|
||||||
);
|
|
||||||
case NDKKind.Repost:
|
case NDKKind.Repost:
|
||||||
return <Repost key={event.id} event={event} />;
|
return <MemoizedRepost key={event.id} event={event} />;
|
||||||
case 1063:
|
|
||||||
return (
|
|
||||||
<NoteWrapper key={event.id} event={event}>
|
|
||||||
<FileNote />
|
|
||||||
</NoteWrapper>
|
|
||||||
);
|
|
||||||
case NDKKind.Article:
|
|
||||||
return (
|
|
||||||
<NoteWrapper key={event.id} event={event}>
|
|
||||||
<ArticleNote />
|
|
||||||
</NoteWrapper>
|
|
||||||
);
|
|
||||||
default:
|
default:
|
||||||
return (
|
return <UnknownNote key={event.id} event={event} />;
|
||||||
<NoteWrapper key={event.id} event={event}>
|
|
||||||
<UnknownNote />
|
|
||||||
</NoteWrapper>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[data]
|
[data]
|
||||||
@@ -66,14 +43,13 @@ export function UserScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-full w-full overflow-y-auto">
|
<div className="relative h-full w-full overflow-y-auto">
|
||||||
<div data-tauri-drag-region className="absolute left-0 top-0 h-11 w-full" />
|
|
||||||
<UserProfile pubkey={pubkey} />
|
<UserProfile pubkey={pubkey} />
|
||||||
<div className="mt-6 h-full w-full border-t border-neutral-100 px-1.5 dark:border-neutral-900">
|
<div className="mt-6 h-full w-full border-t border-neutral-100 px-1.5 dark:border-neutral-900">
|
||||||
<h3 className="mb-2 pt-4 text-center text-lg font-semibold leading-none text-neutral-900 dark:text-neutral-100">
|
<h3 className="mb-2 pt-4 text-center text-lg font-semibold leading-none text-neutral-900 dark:text-neutral-100">
|
||||||
Latest posts
|
Latest posts
|
||||||
</h3>
|
</h3>
|
||||||
<div className="mx-auto flex h-full max-w-[500px] flex-col justify-between gap-1.5 pb-4 pt-1.5">
|
<div className="mx-auto flex h-full max-w-[500px] flex-col justify-between gap-1.5 pb-4 pt-1.5">
|
||||||
{status === 'loading' ? (
|
{status === 'pending' ? (
|
||||||
<div>Loading...</div>
|
<div>Loading...</div>
|
||||||
) : data.length === 0 ? (
|
) : data.length === 0 ? (
|
||||||
<div className="px-3 py-1.5">
|
<div className="px-3 py-1.5">
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// inspired by: https://github.com/nostr-dev-kit/ndk/tree/master/ndk-cache-dexie
|
||||||
import { NDKEvent, NDKRelay, profileFromEvent } from '@nostr-dev-kit/ndk';
|
import { NDKEvent, NDKRelay, profileFromEvent } from '@nostr-dev-kit/ndk';
|
||||||
import type {
|
import type {
|
||||||
Hexpubkey,
|
Hexpubkey,
|
||||||
@@ -5,61 +6,31 @@ import type {
|
|||||||
NDKFilter,
|
NDKFilter,
|
||||||
NDKSubscription,
|
NDKSubscription,
|
||||||
NDKUserProfile,
|
NDKUserProfile,
|
||||||
|
NostrEvent,
|
||||||
} from '@nostr-dev-kit/ndk';
|
} from '@nostr-dev-kit/ndk';
|
||||||
import _debug from 'debug';
|
import { LRUCache } from 'lru-cache';
|
||||||
import { matchFilter } from 'nostr-tools';
|
import { matchFilter } from 'nostr-tools';
|
||||||
import { LRUCache } from 'typescript-lru-cache';
|
|
||||||
|
|
||||||
import { createDatabase, db } from './db';
|
import { LumeStorage } from '@libs/storage/instance';
|
||||||
|
|
||||||
export { db } from './db';
|
export default class NDKCacheAdapterTauri implements NDKCacheAdapter {
|
||||||
|
public db: LumeStorage;
|
||||||
interface NDKCacheAdapterDexieOptions {
|
|
||||||
/**
|
|
||||||
* The name of the database to use
|
|
||||||
*/
|
|
||||||
dbName?: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Debug instance to use for logging
|
|
||||||
*/
|
|
||||||
debug?: debug.IDebugger;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The number of seconds to store events in Dexie (IndexedDB) before they expire
|
|
||||||
* Defaults to 3600 seconds (1 hour)
|
|
||||||
*/
|
|
||||||
expirationTime?: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Number of profiles to keep in an LRU cache
|
|
||||||
*/
|
|
||||||
profileCacheSize?: number | 'disabled';
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class NDKCacheAdapterDexie implements NDKCacheAdapter {
|
|
||||||
public debug: debug.Debugger;
|
|
||||||
private expirationTime;
|
|
||||||
readonly locking;
|
|
||||||
public profiles?: LRUCache<Hexpubkey, NDKUserProfile>;
|
public profiles?: LRUCache<Hexpubkey, NDKUserProfile>;
|
||||||
private dirtyProfiles: Set<Hexpubkey> = new Set();
|
private dirtyProfiles: Set<Hexpubkey> = new Set();
|
||||||
|
readonly locking: boolean;
|
||||||
|
|
||||||
constructor(opts: NDKCacheAdapterDexieOptions = {}) {
|
constructor(db: LumeStorage) {
|
||||||
createDatabase(opts.dbName || 'ndk');
|
this.db = db;
|
||||||
this.debug = opts.debug || _debug('ndk:dexie-adapter');
|
|
||||||
this.locking = true;
|
this.locking = true;
|
||||||
this.expirationTime = opts.expirationTime || 3600;
|
|
||||||
|
|
||||||
if (opts.profileCacheSize !== 'disabled') {
|
|
||||||
this.profiles = new LRUCache({
|
this.profiles = new LRUCache({
|
||||||
maxSize: opts.profileCacheSize || 100000,
|
max: 100000,
|
||||||
});
|
});
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
this.dumpProfiles();
|
this.dumpProfiles();
|
||||||
}, 1000 * 10);
|
}, 1000 * 10);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
public async query(subscription: NDKSubscription): Promise<void> {
|
public async query(subscription: NDKSubscription): Promise<void> {
|
||||||
Promise.allSettled(
|
Promise.allSettled(
|
||||||
@@ -73,9 +44,9 @@ export default class NDKCacheAdapterDexie implements NDKCacheAdapter {
|
|||||||
let profile = this.profiles.get(pubkey);
|
let profile = this.profiles.get(pubkey);
|
||||||
|
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
const user = await db.users.get({ pubkey });
|
const user = await this.db.getCacheUser(pubkey);
|
||||||
if (user) {
|
if (user) {
|
||||||
profile = user.profile;
|
profile = user.profile as NDKUserProfile;
|
||||||
this.profiles.set(pubkey, profile);
|
this.profiles.set(pubkey, profile);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,7 +97,7 @@ export default class NDKCacheAdapterDexie implements NDKCacheAdapter {
|
|||||||
|
|
||||||
if (event.isParamReplaceable()) {
|
if (event.isParamReplaceable()) {
|
||||||
const replaceableId = `${event.kind}:${event.pubkey}:${event.tagId()}`;
|
const replaceableId = `${event.kind}:${event.pubkey}:${event.tagId()}`;
|
||||||
const existingEvent = await db.events.where({ id: replaceableId }).first();
|
const existingEvent = await this.db.getCacheEvent(replaceableId);
|
||||||
if (
|
if (
|
||||||
existingEvent &&
|
existingEvent &&
|
||||||
event.created_at &&
|
event.created_at &&
|
||||||
@@ -137,7 +108,7 @@ export default class NDKCacheAdapterDexie implements NDKCacheAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (addEvent) {
|
if (addEvent) {
|
||||||
db.events.put({
|
this.db.setCacheEvent({
|
||||||
id: event.tagId(),
|
id: event.tagId(),
|
||||||
pubkey: event.pubkey,
|
pubkey: event.pubkey,
|
||||||
content: event.content,
|
content: event.content,
|
||||||
@@ -153,7 +124,7 @@ export default class NDKCacheAdapterDexie implements NDKCacheAdapter {
|
|||||||
event.tags.forEach((tag) => {
|
event.tags.forEach((tag) => {
|
||||||
if (tag[0].length !== 1) return;
|
if (tag[0].length !== 1) return;
|
||||||
|
|
||||||
db.eventTags.put({
|
this.db.setCacheEventTag({
|
||||||
id: `${event.id}:${tag[0]}:${tag[1]}`,
|
id: `${event.id}:${tag[0]}:${tag[1]}`,
|
||||||
eventId: event.id,
|
eventId: event.id,
|
||||||
tag: tag[0],
|
tag: tag[0],
|
||||||
@@ -182,9 +153,9 @@ export default class NDKCacheAdapterDexie implements NDKCacheAdapter {
|
|||||||
|
|
||||||
if (hasAllKeys && filter.authors) {
|
if (hasAllKeys && filter.authors) {
|
||||||
for (const pubkey of filter.authors) {
|
for (const pubkey of filter.authors) {
|
||||||
const events = await db.events.where({ pubkey }).toArray();
|
const events = await this.db.getCacheEventsByPubkey(pubkey);
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
let rawEvent;
|
let rawEvent: NostrEvent;
|
||||||
try {
|
try {
|
||||||
rawEvent = JSON.parse(event.event);
|
rawEvent = JSON.parse(event.event);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -218,9 +189,9 @@ export default class NDKCacheAdapterDexie implements NDKCacheAdapter {
|
|||||||
|
|
||||||
if (hasAllKeys && filter.kinds) {
|
if (hasAllKeys && filter.kinds) {
|
||||||
for (const kind of filter.kinds) {
|
for (const kind of filter.kinds) {
|
||||||
const events = await db.events.where({ kind }).toArray();
|
const events = await this.db.getCacheEventsByKind(kind);
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
let rawEvent;
|
let rawEvent: NostrEvent;
|
||||||
try {
|
try {
|
||||||
rawEvent = JSON.parse(event.event);
|
rawEvent = JSON.parse(event.event);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -252,10 +223,10 @@ export default class NDKCacheAdapterDexie implements NDKCacheAdapter {
|
|||||||
|
|
||||||
if (hasAllKeys && filter.ids) {
|
if (hasAllKeys && filter.ids) {
|
||||||
for (const id of filter.ids) {
|
for (const id of filter.ids) {
|
||||||
const event = await db.events.where({ id }).first();
|
const event = await this.db.getCacheEvent(id);
|
||||||
if (!event) continue;
|
if (!event) continue;
|
||||||
|
|
||||||
let rawEvent;
|
let rawEvent: NostrEvent;
|
||||||
try {
|
try {
|
||||||
rawEvent = JSON.parse(event.event);
|
rawEvent = JSON.parse(event.event);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -295,10 +266,10 @@ export default class NDKCacheAdapterDexie implements NDKCacheAdapter {
|
|||||||
for (const author of filter.authors) {
|
for (const author of filter.authors) {
|
||||||
for (const dTag of filter['#d']) {
|
for (const dTag of filter['#d']) {
|
||||||
const replaceableId = `${kind}:${author}:${dTag}`;
|
const replaceableId = `${kind}:${author}:${dTag}`;
|
||||||
const event = await db.events.where({ id: replaceableId }).first();
|
const event = await this.db.getCacheEvent(replaceableId);
|
||||||
if (!event) continue;
|
if (!event) continue;
|
||||||
|
|
||||||
let rawEvent;
|
let rawEvent: NostrEvent;
|
||||||
try {
|
try {
|
||||||
rawEvent = JSON.parse(event.event);
|
rawEvent = JSON.parse(event.event);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -335,10 +306,10 @@ export default class NDKCacheAdapterDexie implements NDKCacheAdapter {
|
|||||||
if (filter.kinds && filter.authors) {
|
if (filter.kinds && filter.authors) {
|
||||||
for (const kind of filter.kinds) {
|
for (const kind of filter.kinds) {
|
||||||
for (const author of filter.authors) {
|
for (const author of filter.authors) {
|
||||||
const events = await db.events.where({ kind, pubkey: author }).toArray();
|
const events = await this.db.getCacheEventsByKindAndAuthor(kind, author);
|
||||||
|
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
let rawEvent;
|
let rawEvent: NostrEvent;
|
||||||
try {
|
try {
|
||||||
rawEvent = JSON.parse(event.event);
|
rawEvent = JSON.parse(event.event);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -400,12 +371,12 @@ export default class NDKCacheAdapterDexie implements NDKCacheAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const value of values) {
|
for (const value of values) {
|
||||||
const eventTags = await db.eventTags.where({ tagValue: tag + value }).toArray();
|
const eventTags = await this.db.getCacheEventTagsByTagValue(tag + value);
|
||||||
if (!eventTags.length) continue;
|
if (!eventTags.length) continue;
|
||||||
|
|
||||||
const eventIds = eventTags.map((t) => t.eventId);
|
const eventIds = eventTags.map((t) => t.eventId);
|
||||||
|
|
||||||
const events = await db.events.where('id').anyOf(eventIds).toArray();
|
const events = await this.db.getCacheEvents(eventIds);
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
let rawEvent;
|
let rawEvent;
|
||||||
try {
|
try {
|
||||||
@@ -441,13 +412,13 @@ export default class NDKCacheAdapterDexie implements NDKCacheAdapter {
|
|||||||
|
|
||||||
profiles.push({
|
profiles.push({
|
||||||
pubkey,
|
pubkey,
|
||||||
profile,
|
profile: JSON.stringify(profile),
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (profiles.length) {
|
if (profiles.length) {
|
||||||
await db.users.bulkPut(profiles);
|
await this.db.setCacheProfiles(profiles);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.dirtyProfiles.clear();
|
this.dirtyProfiles.clear();
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import NDK, { NDKNip46Signer, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
|
import NDK, { NDKNip46Signer, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
|
||||||
import NDKCacheAdapterDexie from '@nostr-dev-kit/ndk-cache-dexie';
|
|
||||||
import { ndkAdapter } from '@nostr-fetch/adapter-ndk';
|
import { ndkAdapter } from '@nostr-fetch/adapter-ndk';
|
||||||
import { message } from '@tauri-apps/plugin-dialog';
|
import { message } from '@tauri-apps/plugin-dialog';
|
||||||
import { fetch } from '@tauri-apps/plugin-http';
|
import { fetch } from '@tauri-apps/plugin-http';
|
||||||
import { NostrFetcher } from 'nostr-fetch';
|
import { NostrFetcher } from 'nostr-fetch';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import NDKCacheAdapterTauri from '@libs/ndk/cache';
|
||||||
import { useStorage } from '@libs/storage/provider';
|
import { useStorage } from '@libs/storage/provider';
|
||||||
|
|
||||||
export const NDKInstance = () => {
|
export const NDKInstance = () => {
|
||||||
@@ -25,6 +26,9 @@ export const NDKInstance = () => {
|
|||||||
const relays = await db.getExplicitRelayUrls();
|
const relays = await db.getExplicitRelayUrls();
|
||||||
const onlineRelays = new Set(relays);
|
const onlineRelays = new Set(relays);
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 8000);
|
||||||
|
|
||||||
for (const relay of relays) {
|
for (const relay of relays) {
|
||||||
try {
|
try {
|
||||||
const url = new URL(relay);
|
const url = new URL(relay);
|
||||||
@@ -33,15 +37,20 @@ export const NDKInstance = () => {
|
|||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/nostr+json',
|
Accept: 'application/nostr+json',
|
||||||
},
|
},
|
||||||
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
console.info(`${relay} is not working, skipping...`);
|
toast.warning(`${relay} is not working, skipping...`);
|
||||||
onlineRelays.delete(relay);
|
onlineRelays.delete(relay);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toast.success(`Connected to ${relay}`);
|
||||||
} catch {
|
} catch {
|
||||||
console.warn(`${relay} is not working, skipping...`);
|
toast.warning(`${relay} is not working, skipping...`);
|
||||||
onlineRelays.delete(relay);
|
onlineRelays.delete(relay);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,10 +86,10 @@ export const NDKInstance = () => {
|
|||||||
const outboxSetting = await db.getSettingValue('outbox');
|
const outboxSetting = await db.getSettingValue('outbox');
|
||||||
const explicitRelayUrls = await getExplicitRelays();
|
const explicitRelayUrls = await getExplicitRelays();
|
||||||
|
|
||||||
const dexieAdapter = new NDKCacheAdapterDexie({ dbName: 'lume_ndkcache' });
|
const tauriAdapter = new NDKCacheAdapterTauri(db);
|
||||||
const instance = new NDK({
|
const instance = new NDK({
|
||||||
explicitRelayUrls,
|
explicitRelayUrls,
|
||||||
cacheAdapter: dexieAdapter,
|
cacheAdapter: tauriAdapter,
|
||||||
outboxRelayUrls: ['wss://purplepag.es'],
|
outboxRelayUrls: ['wss://purplepag.es'],
|
||||||
enableOutboxModel: outboxSetting === '1',
|
enableOutboxModel: outboxSetting === '1',
|
||||||
});
|
});
|
||||||
@@ -97,7 +106,7 @@ export const NDKInstance = () => {
|
|||||||
if (db.account) {
|
if (db.account) {
|
||||||
const circleSetting = await db.getSettingValue('circles');
|
const circleSetting = await db.getSettingValue('circles');
|
||||||
|
|
||||||
const user = instance.getUser({ hexpubkey: db.account.pubkey });
|
const user = instance.getUser({ pubkey: db.account.pubkey });
|
||||||
const follows = await user.follows();
|
const follows = await user.follows();
|
||||||
const relayList = await user.relayList();
|
const relayList = await user.relayList();
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// source: https://github.com/nostr-dev-kit/ndk-react/
|
// source: https://github.com/nostr-dev-kit/ndk-react/
|
||||||
import NDK from '@nostr-dev-kit/ndk';
|
import NDK from '@nostr-dev-kit/ndk';
|
||||||
|
import Markdown from 'markdown-to-jsx';
|
||||||
import { NostrFetcher } from 'nostr-fetch';
|
import { NostrFetcher } from 'nostr-fetch';
|
||||||
import { PropsWithChildren, createContext, useContext } from 'react';
|
import { PropsWithChildren, createContext, useContext } from 'react';
|
||||||
|
|
||||||
@@ -7,34 +8,53 @@ import { NDKInstance } from '@libs/ndk/instance';
|
|||||||
|
|
||||||
import { LoaderIcon } from '@shared/icons';
|
import { LoaderIcon } from '@shared/icons';
|
||||||
|
|
||||||
|
import { QUOTES } from '@stores/constants';
|
||||||
|
|
||||||
interface NDKContext {
|
interface NDKContext {
|
||||||
ndk: undefined | NDK;
|
ndk: undefined | NDK;
|
||||||
|
fetcher: undefined | NostrFetcher;
|
||||||
relayUrls: string[];
|
relayUrls: string[];
|
||||||
fetcher: NostrFetcher;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const NDKContext = createContext<NDKContext>({
|
const NDKContext = createContext<NDKContext>({
|
||||||
ndk: undefined,
|
ndk: undefined,
|
||||||
relayUrls: [],
|
|
||||||
fetcher: undefined,
|
fetcher: undefined,
|
||||||
|
relayUrls: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const NDKProvider = ({ children }: PropsWithChildren<object>) => {
|
const NDKProvider = ({ children }: PropsWithChildren<object>) => {
|
||||||
const { ndk, relayUrls, fetcher } = NDKInstance();
|
const { ndk, relayUrls, fetcher } = NDKInstance();
|
||||||
|
|
||||||
if (!ndk) {
|
if (!ndk)
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
className="flex h-screen w-screen items-center justify-center bg-neutral-50 dark:bg-neutral-950"
|
className="relative flex h-screen w-screen items-center justify-center bg-neutral-50 dark:bg-neutral-950"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center justify-center gap-2 text-center">
|
<div className="flex max-w-2xl flex-col items-start gap-1">
|
||||||
<LoaderIcon className="h-7 w-7 animate-spin text-neutral-950 dark:text-neutral-50" />
|
<h5 className="font-semibold uppercase">TIP:</h5>
|
||||||
<p className="font-semibold">Connecting to relays</p>
|
<Markdown
|
||||||
|
options={{
|
||||||
|
overrides: {
|
||||||
|
a: {
|
||||||
|
props: {
|
||||||
|
className: 'text-blue-500 hover:text-blue-600',
|
||||||
|
target: '_blank',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
className="text-4xl font-semibold leading-snug text-neutral-300 dark:text-neutral-700"
|
||||||
|
>
|
||||||
|
{QUOTES[Math.floor(Math.random() * QUOTES.length)]}
|
||||||
|
</Markdown>
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-5 right-5 inline-flex items-center gap-2.5">
|
||||||
|
<LoaderIcon className="h-6 w-6 animate-spin text-blue-500" />
|
||||||
|
<p className="font-semibold">Connecting to relays...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NDKContext.Provider
|
<NDKContext.Provider
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
import { NDKEvent } from '@nostr-dev-kit/ndk';
|
import { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||||
import { invoke } from '@tauri-apps/api';
|
import { invoke } from '@tauri-apps/api/primitives';
|
||||||
import { Platform } from '@tauri-apps/plugin-os';
|
import { Platform } from '@tauri-apps/plugin-os';
|
||||||
import Database from '@tauri-apps/plugin-sql';
|
import Database from '@tauri-apps/plugin-sql';
|
||||||
|
|
||||||
import { FULL_RELAYS } from '@stores/constants';
|
import { FULL_RELAYS } from '@stores/constants';
|
||||||
|
|
||||||
import { rawEvent } from '@utils/transform';
|
import { rawEvent } from '@utils/transform';
|
||||||
import { Account, DBEvent, Relays, Widget } from '@utils/types';
|
import type {
|
||||||
|
Account,
|
||||||
|
DBEvent,
|
||||||
|
NDKCacheEvent,
|
||||||
|
NDKCacheEventTag,
|
||||||
|
NDKCacheUser,
|
||||||
|
Relays,
|
||||||
|
Widget,
|
||||||
|
} from '@utils/types';
|
||||||
|
|
||||||
export class LumeStorage {
|
export class LumeStorage {
|
||||||
public db: Database;
|
public db: Database;
|
||||||
@@ -37,6 +45,115 @@ export class LumeStorage {
|
|||||||
return await invoke('secure_remove', { key });
|
return await invoke('secure_remove', { key });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getCacheUser(pubkey: string) {
|
||||||
|
const results: Array<NDKCacheUser> = await this.db.select(
|
||||||
|
'SELECT * FROM ndk_users WHERE pubkey = $1 ORDER BY pubkey DESC LIMIT 1;',
|
||||||
|
[pubkey]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (results.length < 1) return null;
|
||||||
|
|
||||||
|
if (typeof results[0].profile === 'string')
|
||||||
|
results[0].profile = JSON.parse(results[0].profile);
|
||||||
|
|
||||||
|
return results[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getCacheEvent(id: string) {
|
||||||
|
const results: Array<NDKCacheEvent> = await this.db.select(
|
||||||
|
'SELECT * FROM ndk_events WHERE id = $1 ORDER BY id DESC LIMIT 1;',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (results.length < 1) return null;
|
||||||
|
return results[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getCacheEvents(ids: string[]) {
|
||||||
|
const idsArr = `'${ids.join("','")}'`;
|
||||||
|
|
||||||
|
const results: Array<NDKCacheEvent> = await this.db.select(
|
||||||
|
`SELECT * FROM ndk_events WHERE id IN (${idsArr}) ORDER BY id;`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (results.length < 1) return [];
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getCacheEventsByPubkey(pubkey: string) {
|
||||||
|
const results: Array<NDKCacheEvent> = await this.db.select(
|
||||||
|
'SELECT * FROM ndk_events WHERE pubkey = $1 ORDER BY id;',
|
||||||
|
[pubkey]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (results.length < 1) return [];
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getCacheEventsByKind(kind: number) {
|
||||||
|
const results: Array<NDKCacheEvent> = await this.db.select(
|
||||||
|
'SELECT * FROM ndk_events WHERE kind = $1 ORDER BY id;',
|
||||||
|
[kind]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (results.length < 1) return [];
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getCacheEventsByKindAndAuthor(kind: number, pubkey: string) {
|
||||||
|
const results: Array<NDKCacheEvent> = await this.db.select(
|
||||||
|
'SELECT * FROM ndk_events WHERE kind = $1 AND pubkey = $2 ORDER BY id;',
|
||||||
|
[kind, pubkey]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (results.length < 1) return [];
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getCacheEventTagsByTagValue(tagValue: string) {
|
||||||
|
const results: Array<NDKCacheEventTag> = await this.db.select(
|
||||||
|
'SELECT * FROM ndk_eventtags WHERE tagValue = $1 ORDER BY id;',
|
||||||
|
[tagValue]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (results.length < 1) return [];
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setCacheEvent({
|
||||||
|
id,
|
||||||
|
pubkey,
|
||||||
|
content,
|
||||||
|
kind,
|
||||||
|
createdAt,
|
||||||
|
relay,
|
||||||
|
event,
|
||||||
|
}: NDKCacheEvent) {
|
||||||
|
return await this.db.execute(
|
||||||
|
'INSERT OR IGNORE INTO ndk_events (id, pubkey, content, kind, createdAt, relay, event) VALUES ($1, $2, $3, $4, $5, $6, $7);',
|
||||||
|
[id, pubkey, content, kind, createdAt, relay, event]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setCacheEventTag({ id, eventId, tag, value, tagValue }: NDKCacheEventTag) {
|
||||||
|
return await this.db.execute(
|
||||||
|
'INSERT OR IGNORE INTO ndk_eventtags (id, eventId, tag, value, tagValue) VALUES ($1, $2, $3, $4, $5);',
|
||||||
|
[id, eventId, tag, value, tagValue]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setCacheProfiles(profiles: Array<NDKCacheUser>) {
|
||||||
|
return await Promise.all(
|
||||||
|
profiles.map(
|
||||||
|
async (profile) =>
|
||||||
|
await this.db.execute(
|
||||||
|
'INSERT OR IGNORE INTO ndk_users (pubkey, profile, createdAt) VALUES ($1, $2, $3);',
|
||||||
|
[profile.pubkey, profile.profile, profile.createdAt]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public async checkAccount() {
|
public async checkAccount() {
|
||||||
const result: Array<{ total: string }> = await this.db.select(
|
const result: Array<{ total: string }> = await this.db.select(
|
||||||
'SELECT COUNT(*) AS "total" FROM accounts;'
|
'SELECT COUNT(*) AS "total" FROM accounts;'
|
||||||
@@ -137,7 +254,8 @@ export class LumeStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async removeWidget(id: string) {
|
public async removeWidget(id: string) {
|
||||||
return await this.db.execute('DELETE FROM widgets WHERE id = $1;', [id]);
|
const res = await this.db.execute('DELETE FROM widgets WHERE id = $1;', [id]);
|
||||||
|
if (res) return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createEvent(event: NDKEvent) {
|
public async createEvent(event: NDKEvent) {
|
||||||
@@ -288,7 +406,7 @@ export class LumeStorage {
|
|||||||
`SELECT * FROM relays WHERE account_id = "${this.account.id}" ORDER BY id DESC LIMIT 50;`
|
`SELECT * FROM relays WHERE account_id = "${this.account.id}" ORDER BY id DESC LIMIT 50;`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!result || result.length < 1) return FULL_RELAYS;
|
if (!result || !result.length) return FULL_RELAYS;
|
||||||
return result.map((el) => el.relay);
|
return result.map((el) => el.relay);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,15 @@ import { platform } from '@tauri-apps/plugin-os';
|
|||||||
import { relaunch } from '@tauri-apps/plugin-process';
|
import { relaunch } from '@tauri-apps/plugin-process';
|
||||||
import Database from '@tauri-apps/plugin-sql';
|
import Database from '@tauri-apps/plugin-sql';
|
||||||
import { check } from '@tauri-apps/plugin-updater';
|
import { check } from '@tauri-apps/plugin-updater';
|
||||||
|
import Markdown from 'markdown-to-jsx';
|
||||||
import { PropsWithChildren, createContext, useContext, useEffect, useState } from 'react';
|
import { PropsWithChildren, createContext, useContext, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { LumeStorage } from '@libs/storage/instance';
|
import { LumeStorage } from '@libs/storage/instance';
|
||||||
|
|
||||||
import { LoaderIcon } from '@shared/icons';
|
import { LoaderIcon } from '@shared/icons';
|
||||||
|
|
||||||
|
import { QUOTES } from '@stores/constants';
|
||||||
|
|
||||||
interface StorageContext {
|
interface StorageContext {
|
||||||
db: LumeStorage;
|
db: LumeStorage;
|
||||||
}
|
}
|
||||||
@@ -54,21 +57,38 @@ const StorageProvider = ({ children }: PropsWithChildren<object>) => {
|
|||||||
if (!db) initLumeStorage();
|
if (!db) initLumeStorage();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!db) {
|
if (!db)
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
className="flex h-screen w-screen items-center justify-center bg-neutral-50 dark:bg-neutral-950"
|
className="relative flex h-screen w-screen items-center justify-center bg-neutral-50 dark:bg-neutral-950"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center justify-center gap-2 text-center">
|
<div className="flex max-w-2xl flex-col items-start gap-1">
|
||||||
<LoaderIcon className="h-7 w-7 animate-spin text-neutral-950 dark:text-neutral-50" />
|
<h5 className="font-semibold uppercase">TIP:</h5>
|
||||||
|
<Markdown
|
||||||
|
options={{
|
||||||
|
overrides: {
|
||||||
|
a: {
|
||||||
|
props: {
|
||||||
|
className: 'text-blue-500 hover:text-blue-600',
|
||||||
|
target: '_blank',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
className="text-4xl font-semibold leading-snug text-neutral-300 dark:text-neutral-700"
|
||||||
|
>
|
||||||
|
{QUOTES[Math.floor(Math.random() * QUOTES.length)]}
|
||||||
|
</Markdown>
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-5 right-5 inline-flex items-center gap-2.5">
|
||||||
|
<LoaderIcon className="h-6 w-6 animate-spin text-blue-500" />
|
||||||
<p className="font-semibold">
|
<p className="font-semibold">
|
||||||
{isNewVersion ? 'Found a new version, updating' : 'Checking for updates'}
|
{isNewVersion ? 'Found a new version, updating' : 'Checking for updates...'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
return <StorageContext.Provider value={{ db }}>{children}</StorageContext.Provider>;
|
return <StorageContext.Provider value={{ db }}>{children}</StorageContext.Provider>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,16 +7,22 @@ import { StorageProvider } from '@libs/storage/provider';
|
|||||||
|
|
||||||
import App from './app';
|
import App from './app';
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
gcTime: 1000 * 60 * 60 * 24, // 24 hours
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const container = document.getElementById('root');
|
const container = document.getElementById('root');
|
||||||
const root = createRoot(container);
|
const root = createRoot(container);
|
||||||
|
|
||||||
root.render(
|
root.render(
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Toaster position="top-center" closeButton />
|
||||||
<StorageProvider>
|
<StorageProvider>
|
||||||
<NDKProvider>
|
<NDKProvider>
|
||||||
<Toaster position="top-center" closeButton />
|
|
||||||
<App />
|
<App />
|
||||||
</NDKProvider>
|
</NDKProvider>
|
||||||
</StorageProvider>
|
</StorageProvider>
|
||||||
|
|||||||
@@ -1,108 +1,31 @@
|
|||||||
import { NDKFilter, NDKKind } from '@nostr-dev-kit/ndk';
|
|
||||||
import * as Avatar from '@radix-ui/react-avatar';
|
import * as Avatar from '@radix-ui/react-avatar';
|
||||||
import { minidenticon } from 'minidenticons';
|
import { minidenticon } from 'minidenticons';
|
||||||
import { useEffect } from 'react';
|
import { Link } from 'react-router-dom';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { useNDK } from '@libs/ndk/provider';
|
|
||||||
import { useStorage } from '@libs/storage/provider';
|
import { useStorage } from '@libs/storage/provider';
|
||||||
|
|
||||||
import { AccountMoreActions } from '@shared/accounts/more';
|
import { AccountMoreActions } from '@shared/accounts/more';
|
||||||
import { NetworkStatusIndicator } from '@shared/networkStatusIndicator';
|
import { NetworkStatusIndicator } from '@shared/networkStatusIndicator';
|
||||||
|
|
||||||
import { useActivities } from '@stores/activities';
|
|
||||||
|
|
||||||
import { useNostr } from '@utils/hooks/useNostr';
|
|
||||||
import { useProfile } from '@utils/hooks/useProfile';
|
import { useProfile } from '@utils/hooks/useProfile';
|
||||||
import { sendNativeNotification } from '@utils/notification';
|
|
||||||
|
|
||||||
export function ActiveAccount() {
|
export function ActiveAccount() {
|
||||||
const { db } = useStorage();
|
const { db } = useStorage();
|
||||||
const { ndk } = useNDK();
|
|
||||||
const { status, user } = useProfile(db.account.pubkey);
|
const { status, user } = useProfile(db.account.pubkey);
|
||||||
const { sub } = useNostr();
|
|
||||||
|
|
||||||
const location = useLocation();
|
|
||||||
const addActivity = useActivities((state) => state.addActivity);
|
|
||||||
const addNewMessage = useActivities((state) => state.addNewMessage);
|
|
||||||
|
|
||||||
const svgURI =
|
const svgURI =
|
||||||
'data:image/svg+xml;utf8,' +
|
'data:image/svg+xml;utf8,' +
|
||||||
encodeURIComponent(minidenticon(db.account.pubkey, 90, 50));
|
encodeURIComponent(minidenticon(db.account.pubkey, 90, 50));
|
||||||
|
|
||||||
useEffect(() => {
|
if (status === 'pending') {
|
||||||
const filter: NDKFilter = {
|
|
||||||
kinds: [
|
|
||||||
NDKKind.Text,
|
|
||||||
NDKKind.EncryptedDirectMessage,
|
|
||||||
NDKKind.Repost,
|
|
||||||
NDKKind.Reaction,
|
|
||||||
NDKKind.Zap,
|
|
||||||
],
|
|
||||||
since: Math.floor(Date.now() / 1000),
|
|
||||||
'#p': [db.account.pubkey],
|
|
||||||
};
|
|
||||||
|
|
||||||
sub(
|
|
||||||
filter,
|
|
||||||
async (event) => {
|
|
||||||
console.log('receive event: ', event.id);
|
|
||||||
|
|
||||||
if (event.kind !== NDKKind.EncryptedDirectMessage) {
|
|
||||||
addActivity(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = ndk.getUser({ hexpubkey: event.pubkey });
|
|
||||||
await user.fetchProfile();
|
|
||||||
|
|
||||||
switch (event.kind) {
|
|
||||||
case NDKKind.Text:
|
|
||||||
return await sendNativeNotification(
|
|
||||||
`${user.profile.displayName || user.profile.name} has replied to your note`
|
|
||||||
);
|
|
||||||
case NDKKind.EncryptedDirectMessage: {
|
|
||||||
if (location.pathname !== '/chats') {
|
|
||||||
addNewMessage();
|
|
||||||
return await sendNativeNotification(
|
|
||||||
`${
|
|
||||||
user.profile.displayName || user.profile.name
|
|
||||||
} has send you a encrypted message`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case NDKKind.Repost:
|
|
||||||
return await sendNativeNotification(
|
|
||||||
`${user.profile.displayName || user.profile.name} has reposted to your note`
|
|
||||||
);
|
|
||||||
case NDKKind.Reaction:
|
|
||||||
return await sendNativeNotification(
|
|
||||||
`${user.profile.displayName || user.profile.name} has reacted ${
|
|
||||||
event.content
|
|
||||||
} to your note`
|
|
||||||
);
|
|
||||||
case NDKKind.Zap:
|
|
||||||
return await sendNativeNotification(
|
|
||||||
`${user.profile.displayName || user.profile.name} has zapped to your note`
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
false
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (status === 'loading') {
|
|
||||||
return (
|
return (
|
||||||
<div className="aspect-square h-auto w-full animate-pulse rounded-lg bg-neutral-300 dark:bg-neutral-700" />
|
<div className="aspect-square h-auto w-full animate-pulse rounded-lg bg-neutral-300 dark:bg-neutral-700" />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1 rounded-lg bg-neutral-100 p-1 hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800">
|
<div className="flex flex-col gap-1 rounded-lg bg-neutral-100 p-1 ring-1 ring-transparent hover:bg-neutral-200 hover:ring-blue-500 dark:bg-neutral-900 dark:hover:bg-neutral-800">
|
||||||
<Link to={`/users/${db.account.pubkey}`} className="relative inline-block">
|
<Link to="/settings/" className="relative inline-block">
|
||||||
<Avatar.Root>
|
<Avatar.Root>
|
||||||
<Avatar.Image
|
<Avatar.Image
|
||||||
src={user?.picture || user?.image}
|
src={user?.picture || user?.image}
|
||||||
@@ -115,14 +38,14 @@ export function ActiveAccount() {
|
|||||||
<Avatar.Fallback delayMs={150}>
|
<Avatar.Fallback delayMs={150}>
|
||||||
<img
|
<img
|
||||||
src={svgURI}
|
src={svgURI}
|
||||||
alt={db.account.pubkeypubkey}
|
alt={db.account.pubkey}
|
||||||
className="aspect-square h-auto w-full rounded-md bg-black dark:bg-white"
|
className="aspect-square h-auto w-full rounded-md bg-black dark:bg-white"
|
||||||
/>
|
/>
|
||||||
</Avatar.Fallback>
|
</Avatar.Fallback>
|
||||||
</Avatar.Root>
|
</Avatar.Root>
|
||||||
<NetworkStatusIndicator />
|
<NetworkStatusIndicator />
|
||||||
</Link>
|
</Link>
|
||||||
<AccountMoreActions pubkey={db.account.pubkey} />
|
<AccountMoreActions />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||||
import { useState } from 'react';
|
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import { HorizontalDotsIcon } from '@shared/icons';
|
import { HorizontalDotsIcon } from '@shared/icons';
|
||||||
import { Logout } from '@shared/logout';
|
import { Logout } from '@shared/logout';
|
||||||
|
|
||||||
export function AccountMoreActions({ pubkey }: { pubkey: string }) {
|
export function AccountMoreActions() {
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu.Root open={open} onOpenChange={setOpen}>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger asChild>
|
<DropdownMenu.Trigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -22,23 +19,7 @@ export function AccountMoreActions({ pubkey }: { pubkey: string }) {
|
|||||||
<DropdownMenu.Content className="ml-2 flex w-[200px] flex-col overflow-hidden rounded-xl bg-blue-500 p-2 focus:outline-none">
|
<DropdownMenu.Content className="ml-2 flex w-[200px] flex-col overflow-hidden rounded-xl bg-blue-500 p-2 focus:outline-none">
|
||||||
<DropdownMenu.Item asChild>
|
<DropdownMenu.Item asChild>
|
||||||
<Link
|
<Link
|
||||||
to={`/users/${pubkey}`}
|
to="/settings/"
|
||||||
className="inline-flex h-10 items-center rounded-lg px-2 text-sm font-medium text-white hover:bg-blue-600 focus:outline-none"
|
|
||||||
>
|
|
||||||
Profile
|
|
||||||
</Link>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item asChild>
|
|
||||||
<Link
|
|
||||||
to={`/settings/backup`}
|
|
||||||
className="inline-flex h-10 items-center rounded-lg px-2 text-sm font-medium text-white hover:bg-blue-600 focus:outline-none"
|
|
||||||
>
|
|
||||||
Backup
|
|
||||||
</Link>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item asChild>
|
|
||||||
<Link
|
|
||||||
to={`/settings/`}
|
|
||||||
className="inline-flex h-10 items-center rounded-lg px-2 text-sm font-medium text-white hover:bg-blue-600 focus:outline-none"
|
className="inline-flex h-10 items-center rounded-lg px-2 text-sm font-medium text-white hover:bg-blue-600 focus:outline-none"
|
||||||
>
|
>
|
||||||
Settings
|
Settings
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { message, open } from '@tauri-apps/plugin-dialog';
|
import { message } from '@tauri-apps/plugin-dialog';
|
||||||
import { readBinaryFile } from '@tauri-apps/plugin-fs';
|
|
||||||
import { Dispatch, SetStateAction, useState } from 'react';
|
import { Dispatch, SetStateAction, useState } from 'react';
|
||||||
|
|
||||||
import { LoaderIcon, PlusIcon } from '@shared/icons';
|
import { LoaderIcon, PlusIcon } from '@shared/icons';
|
||||||
|
|
||||||
|
import { useNostr } from '@utils/hooks/useNostr';
|
||||||
|
|
||||||
export function AvatarUploader({
|
export function AvatarUploader({
|
||||||
setPicture,
|
setPicture,
|
||||||
}: {
|
}: {
|
||||||
setPicture: Dispatch<SetStateAction<string>>;
|
setPicture: Dispatch<SetStateAction<string>>;
|
||||||
}) {
|
}) {
|
||||||
|
const { upload } = useNostr();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const uploadAvatar = async () => {
|
const uploadAvatar = async () => {
|
||||||
@@ -16,42 +18,14 @@ export function AvatarUploader({
|
|||||||
// start loading
|
// start loading
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const selected = await open({
|
const image = await upload();
|
||||||
multiple: false,
|
|
||||||
filters: [
|
|
||||||
{
|
|
||||||
name: 'Image',
|
|
||||||
extensions: ['png', 'jpeg', 'jpg', 'gif'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!selected) {
|
if (image) {
|
||||||
|
setPicture(image);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
const file = await readBinaryFile(selected.path);
|
|
||||||
const blob = new Blob([file]);
|
|
||||||
|
|
||||||
const data = new FormData();
|
|
||||||
data.append('fileToUpload', blob);
|
|
||||||
data.append('submit', 'Upload Image');
|
|
||||||
|
|
||||||
const res = await fetch('https://nostr.build/api/v2/upload/files', {
|
|
||||||
method: 'POST',
|
|
||||||
body: data,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
const json = await res.json();
|
|
||||||
const content = json.data[0];
|
|
||||||
|
|
||||||
setPicture(content.url);
|
|
||||||
|
|
||||||
// stop loading
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// stop loading
|
// stop loading
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { message, open } from '@tauri-apps/plugin-dialog';
|
import { message } from '@tauri-apps/plugin-dialog';
|
||||||
import { readBinaryFile } from '@tauri-apps/plugin-fs';
|
|
||||||
import { Dispatch, SetStateAction, useState } from 'react';
|
import { Dispatch, SetStateAction, useState } from 'react';
|
||||||
|
|
||||||
import { LoaderIcon, PlusIcon } from '@shared/icons';
|
import { LoaderIcon, PlusIcon } from '@shared/icons';
|
||||||
|
|
||||||
|
import { useNostr } from '@utils/hooks/useNostr';
|
||||||
|
|
||||||
export function BannerUploader({
|
export function BannerUploader({
|
||||||
setBanner,
|
setBanner,
|
||||||
}: {
|
}: {
|
||||||
setBanner: Dispatch<SetStateAction<string>>;
|
setBanner: Dispatch<SetStateAction<string>>;
|
||||||
}) {
|
}) {
|
||||||
|
const { upload } = useNostr();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const uploadBanner = async () => {
|
const uploadBanner = async () => {
|
||||||
@@ -16,42 +18,14 @@ export function BannerUploader({
|
|||||||
// start loading
|
// start loading
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const selected = await open({
|
const image = await upload();
|
||||||
multiple: false,
|
|
||||||
filters: [
|
|
||||||
{
|
|
||||||
name: 'Image',
|
|
||||||
extensions: ['png', 'jpeg', 'jpg', 'gif'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!selected) {
|
if (image) {
|
||||||
|
setBanner(image);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
const file = await readBinaryFile(selected.path);
|
|
||||||
const blob = new Blob([file]);
|
|
||||||
|
|
||||||
const data = new FormData();
|
|
||||||
data.append('fileToUpload', blob);
|
|
||||||
data.append('submit', 'Upload Image');
|
|
||||||
|
|
||||||
const res = await fetch('https://nostr.build/api/v2/upload/files', {
|
|
||||||
method: 'POST',
|
|
||||||
body: data,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
const json = await res.json();
|
|
||||||
const content = json.data[0];
|
|
||||||
|
|
||||||
setBanner(content.url);
|
|
||||||
|
|
||||||
// stop loading
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// stop loading
|
// stop loading
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import { minidenticon } from 'minidenticons';
|
|
||||||
import { ImgHTMLAttributes, memo, useState } from 'react';
|
|
||||||
|
|
||||||
export const Image = memo(function Image({
|
|
||||||
src,
|
|
||||||
...props
|
|
||||||
}: ImgHTMLAttributes<HTMLImageElement>) {
|
|
||||||
const [isError, setIsError] = useState(false);
|
|
||||||
|
|
||||||
if (isError || !src) {
|
|
||||||
const svgURI =
|
|
||||||
'data:image/svg+xml;utf8,' + encodeURIComponent(minidenticon(props.alt, 90, 50));
|
|
||||||
return (
|
|
||||||
<img src={svgURI} alt={props.alt} {...props} style={{ backgroundColor: '#000' }} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
{...props}
|
|
||||||
src={src}
|
|
||||||
onError={({ currentTarget }) => {
|
|
||||||
currentTarget.onerror = null;
|
|
||||||
setIsError(true);
|
|
||||||
}}
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
alt="lume default img"
|
|
||||||
style={{ contentVisibility: 'auto' }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -13,7 +13,6 @@ export function NoteLayout() {
|
|||||||
) : (
|
) : (
|
||||||
<div data-tauri-drag-region className="h-9" />
|
<div data-tauri-drag-region className="h-9" />
|
||||||
)}
|
)}
|
||||||
<div data-tauri-drag-region className="h-6" />
|
|
||||||
<div className="flex h-full min-h-0 w-full">
|
<div className="flex h-full min-h-0 w-full">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
<ScrollRestoration />
|
<ScrollRestoration />
|
||||||
|
|||||||
@@ -1,68 +1,96 @@
|
|||||||
import { Link, NavLink, Outlet, ScrollRestoration } from 'react-router-dom';
|
import { NavLink, Outlet, ScrollRestoration } from 'react-router-dom';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
import { WindowTitlebar } from 'tauri-controls';
|
||||||
|
|
||||||
import { ArrowLeftIcon, SecureIcon, SettingsIcon } from '@shared/icons';
|
import { useStorage } from '@libs/storage/provider';
|
||||||
|
|
||||||
|
import { SecureIcon, SettingsIcon } from '@shared/icons';
|
||||||
|
|
||||||
export function SettingsLayout() {
|
export function SettingsLayout() {
|
||||||
|
const { db } = useStorage();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-screen">
|
<div className="flex h-screen w-screen flex-col bg-neutral-50 dark:bg-neutral-950">
|
||||||
<div className="relative flex h-full w-[232px] flex-col">
|
{db.platform !== 'macos' ? (
|
||||||
<div data-tauri-drag-region className="h-11 w-full shrink-0" />
|
<WindowTitlebar />
|
||||||
<div className="flex h-full flex-1 flex-col gap-2 overflow-y-auto pb-32 scrollbar-none">
|
) : (
|
||||||
<div className="inline-flex items-center gap-2 border-l-2 border-transparent pl-4">
|
<div data-tauri-drag-region className="h-9" />
|
||||||
<Link
|
)}
|
||||||
to="/"
|
<div className="flex h-full min-h-0 w-full flex-col gap-8 overflow-y-auto pb-10">
|
||||||
className="inline-flex h-7 w-7 items-center justify-center rounded hover:bg-white/10"
|
<div className="flex h-20 w-full items-end justify-center gap-0.5 border-b border-neutral-200 pb-2 dark:border-neutral-800">
|
||||||
>
|
|
||||||
<ArrowLeftIcon className="h-4 w-4 text-neutral-600 dark:text-neutral-400" />
|
|
||||||
</Link>
|
|
||||||
<h3 className="text-[11px] font-bold uppercase tracking-widest text-neutral-600 dark:text-neutral-400">
|
|
||||||
Settings
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col pr-2">
|
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/settings/"
|
to="/settings/"
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'flex h-10 items-center gap-2.5 rounded-r-lg border-l-2 pl-4 pr-2',
|
'flex w-20 shrink-0 flex-col items-center justify-center rounded-lg px-2 py-2 hover:bg-neutral-100 dark:hover:bg-neutral-900',
|
||||||
isActive
|
isActive
|
||||||
? 'border-blue-500 bg-white/5 text-white'
|
? 'bg-neutral-100 text-blue-500 hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800'
|
||||||
: 'border-transparent text-white/80'
|
: ''
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded bg-neutral-400 dark:bg-neutral-600">
|
<SettingsIcon className="h-6 w-6" />
|
||||||
<SettingsIcon className="h-4 w-4 text-white" />
|
<p className="text-sm font-medium">User</p>
|
||||||
</span>
|
</NavLink>
|
||||||
<span className="font-medium">General</span>
|
<NavLink
|
||||||
|
to="/settings/general"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
twMerge(
|
||||||
|
'flex w-20 shrink-0 flex-col items-center justify-center rounded-lg px-2 py-2 text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-900',
|
||||||
|
isActive
|
||||||
|
? 'bg-neutral-100 text-blue-500 hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800'
|
||||||
|
: ''
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SettingsIcon className="h-6 w-6" />
|
||||||
|
<p className="text-sm font-medium">General</p>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/settings/backup"
|
to="/settings/backup"
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'flex h-10 items-center gap-2.5 rounded-r-lg border-l-2 pl-4 pr-2',
|
'flex w-20 shrink-0 flex-col items-center justify-center rounded-lg px-2 py-2 text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-900',
|
||||||
isActive
|
isActive
|
||||||
? 'border-blue-500 bg-white/5 text-white'
|
? 'bg-neutral-100 text-blue-500 hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800'
|
||||||
: 'border-transparent text-white/80'
|
: ''
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded bg-neutral-400 dark:bg-neutral-600">
|
<SecureIcon className="h-6 w-6" />
|
||||||
<SecureIcon className="h-4 w-4 text-white" />
|
<p className="text-sm font-medium">Backup</p>
|
||||||
</span>
|
</NavLink>
|
||||||
<span className="font-medium">Backup</span>
|
<NavLink
|
||||||
|
to="/settings/advanced"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
twMerge(
|
||||||
|
'flex w-20 shrink-0 flex-col items-center justify-center rounded-lg px-2 py-2 text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-900',
|
||||||
|
isActive
|
||||||
|
? 'bg-neutral-100 text-blue-500 hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800'
|
||||||
|
: ''
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SettingsIcon className="h-6 w-6" />
|
||||||
|
<p className="text-sm font-medium">Advanced</p>
|
||||||
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
to="/settings/about"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
twMerge(
|
||||||
|
'flex w-20 shrink-0 flex-col items-center justify-center rounded-lg px-2 py-2 text-neutral-700 hover:bg-neutral-100 dark:text-neutral-300 dark:hover:bg-neutral-900',
|
||||||
|
isActive
|
||||||
|
? 'bg-neutral-100 text-blue-500 hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800'
|
||||||
|
: ''
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SettingsIcon className="h-6 w-6" />
|
||||||
|
<p className="text-sm font-medium">About</p>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="h-full w-full flex-1 bg-black/90 backdrop-blur-xl">
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
<ScrollRestoration
|
<ScrollRestoration />
|
||||||
getKey={(location) => {
|
|
||||||
return location.pathname;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import { useNDK } from '@libs/ndk/provider';
|
|||||||
import { useStorage } from '@libs/storage/provider';
|
import { useStorage } from '@libs/storage/provider';
|
||||||
|
|
||||||
export function Logout() {
|
export function Logout() {
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const { db } = useStorage();
|
const { db } = useStorage();
|
||||||
const { ndk } = useNDK();
|
const { ndk } = useNDK();
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
ndk.signer = null;
|
ndk.signer = null;
|
||||||
|
|
||||||
|
|||||||
@@ -2,21 +2,12 @@ import { Link, NavLink } from 'react-router-dom';
|
|||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
import { ActiveAccount } from '@shared/accounts/active';
|
import { ActiveAccount } from '@shared/accounts/active';
|
||||||
import {
|
import { ChatsIcon, ComposeIcon, HomeIcon, NwcIcon, RelayIcon } from '@shared/icons';
|
||||||
ChatsIcon,
|
|
||||||
ComposeIcon,
|
|
||||||
ExploreIcon,
|
|
||||||
HomeIcon,
|
|
||||||
NwcIcon,
|
|
||||||
RelayIcon,
|
|
||||||
} from '@shared/icons';
|
|
||||||
|
|
||||||
import { useActivities } from '@stores/activities';
|
|
||||||
|
|
||||||
import { compactNumber } from '@utils/number';
|
import { compactNumber } from '@utils/number';
|
||||||
|
|
||||||
export function Navigation() {
|
export function Navigation() {
|
||||||
const newMessages = useActivities((state) => state.newMessages);
|
const newMessages = 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col justify-between p-3">
|
<div className="flex h-full w-full flex-col justify-between p-3">
|
||||||
@@ -89,29 +80,6 @@ export function Navigation() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink
|
|
||||||
to="/explore"
|
|
||||||
preventScrollReset={true}
|
|
||||||
className="inline-flex flex-col items-center justify-center"
|
|
||||||
>
|
|
||||||
{({ isActive }) => (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className={twMerge(
|
|
||||||
'inline-flex aspect-square h-auto w-full items-center justify-center rounded-lg',
|
|
||||||
isActive
|
|
||||||
? 'bg-black/10 text-black dark:bg-white/10 dark:text-white'
|
|
||||||
: 'text-black/50 dark:text-neutral-400'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ExploreIcon className="h-6 w-6" />
|
|
||||||
</div>
|
|
||||||
<div className="text-sm font-medium text-black dark:text-white">
|
|
||||||
Explore
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</NavLink>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 flex-col gap-3 p-1">
|
<div className="flex shrink-0 flex-col gap-3 p-1">
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ export const NIP05 = memo(function NIP05({
|
|||||||
nip05: string;
|
nip05: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const { status, data } = useQuery(
|
const { status, data } = useQuery({
|
||||||
['nip05', nip05],
|
queryKey: ['nip05', nip05],
|
||||||
async () => {
|
queryFn: async ({ signal }: { signal: AbortSignal }) => {
|
||||||
try {
|
try {
|
||||||
const localPath = nip05.split('@')[0];
|
const localPath = nip05.split('@')[0];
|
||||||
const service = nip05.split('@')[1];
|
const service = nip05.split('@')[1];
|
||||||
@@ -33,11 +33,13 @@ export const NIP05 = memo(function NIP05({
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json; charset=utf-8',
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
},
|
},
|
||||||
|
signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) throw new Error(`Failed to fetch NIP-05 service: ${nip05}`);
|
if (!res.ok) throw new Error(`Failed to fetch NIP-05 service: ${nip05}`);
|
||||||
|
|
||||||
const data: NIP05 = await res.json();
|
const data: NIP05 = await res.json();
|
||||||
|
|
||||||
if (data.names) {
|
if (data.names) {
|
||||||
if (data.names[localPath] !== pubkey) return false;
|
if (data.names[localPath] !== pubkey) return false;
|
||||||
return true;
|
return true;
|
||||||
@@ -47,28 +49,30 @@ export const NIP05 = memo(function NIP05({
|
|||||||
throw new Error(`Failed to verify NIP-05, error: ${e}`);
|
throw new Error(`Failed to verify NIP-05, error: ${e}`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
refetchOnMount: false,
|
refetchOnMount: false,
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
if (status === 'loading') {
|
if (status === 'pending') {
|
||||||
<div className="h-3 w-20 animate-pulse rounded bg-white/10" />;
|
<div className="h-3 w-20 animate-pulse rounded bg-white/10" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={twMerge('inline-flex items-center gap-1', className)}>
|
<div className="inline-flex items-center gap-1">
|
||||||
<p className="text-sm">{nip05}</p>
|
<p className={twMerge('text-sm font-medium', className)}>{nip05}</p>
|
||||||
<div className="shrink-0">
|
|
||||||
{data === true ? (
|
{data === true ? (
|
||||||
<VerifiedIcon className="h-3 w-3 text-green-500" />
|
<div className="inline-flex h-5 w-max shrink-0 items-center justify-center gap-1 rounded-full bg-teal-500 pl-0.5 pr-1.5 text-xs font-medium text-white">
|
||||||
) : (
|
<VerifiedIcon className="h-4 w-4" />
|
||||||
<UnverifiedIcon className="h-3 w-3 text-red-500" />
|
Verified
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="inline-flex h-5 w-max shrink-0 items-center justify-center gap-1.5 rounded-full bg-red-500 pl-0.5 pr-1.5 text-xs font-medium text-white">
|
||||||
|
<UnverifiedIcon className="h-4 w-4" />
|
||||||
|
Unverified
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,54 +1,48 @@
|
|||||||
|
import { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||||
|
import { createSearchParams, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import { useStorage } from '@libs/storage/provider';
|
import { FocusIcon, ReplyIcon } from '@shared/icons';
|
||||||
|
|
||||||
import { FocusIcon } from '@shared/icons';
|
|
||||||
import { NoteReaction } from '@shared/notes/actions/reaction';
|
import { NoteReaction } from '@shared/notes/actions/reaction';
|
||||||
import { NoteReply } from '@shared/notes/actions/reply';
|
|
||||||
import { NoteRepost } from '@shared/notes/actions/repost';
|
import { NoteRepost } from '@shared/notes/actions/repost';
|
||||||
import { NoteZap } from '@shared/notes/actions/zap';
|
import { NoteZap } from '@shared/notes/actions/zap';
|
||||||
|
|
||||||
import { WidgetKinds, useWidgets } from '@stores/widgets';
|
import { WIDGET_KIND } from '@stores/constants';
|
||||||
|
|
||||||
|
import { useWidget } from '@utils/hooks/useWidget';
|
||||||
|
|
||||||
export function NoteActions({
|
export function NoteActions({
|
||||||
id,
|
event,
|
||||||
pubkey,
|
rootEventId,
|
||||||
extraButtons = true,
|
canOpenEvent = true,
|
||||||
root,
|
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
event: NDKEvent;
|
||||||
pubkey: string;
|
rootEventId?: string;
|
||||||
extraButtons?: boolean;
|
canOpenEvent?: boolean;
|
||||||
root?: string;
|
|
||||||
}) {
|
}) {
|
||||||
const { db } = useStorage();
|
const { addWidget } = useWidget();
|
||||||
const setWidget = useWidgets((state) => state.setWidget);
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip.Provider>
|
<Tooltip.Provider>
|
||||||
<div className="-ml-1 mt-2 inline-flex w-full items-center">
|
<div className="flex h-14 items-center justify-between px-3">
|
||||||
<div className="inline-flex items-center gap-10">
|
{canOpenEvent && (
|
||||||
<NoteReply id={id} pubkey={pubkey} root={root} />
|
<div className="inline-flex items-center gap-3">
|
||||||
<NoteReaction id={id} pubkey={pubkey} />
|
|
||||||
<NoteRepost id={id} pubkey={pubkey} />
|
|
||||||
<NoteZap id={id} pubkey={pubkey} />
|
|
||||||
</div>
|
|
||||||
{extraButtons && (
|
|
||||||
<div className="ml-auto inline-flex items-center gap-3">
|
|
||||||
<Tooltip.Root delayDuration={150}>
|
<Tooltip.Root delayDuration={150}>
|
||||||
<Tooltip.Trigger asChild>
|
<Tooltip.Trigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setWidget(db, {
|
addWidget.mutate({
|
||||||
kind: WidgetKinds.local.thread,
|
kind: WIDGET_KIND.thread,
|
||||||
title: 'Thread',
|
title: 'Thread',
|
||||||
content: id,
|
content: event.id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="group inline-flex h-7 w-7 items-center justify-center text-neutral-500 dark:text-neutral-300"
|
className="inline-flex h-7 w-max items-center justify-center gap-2 rounded-full bg-neutral-100 px-2 text-sm font-medium dark:bg-neutral-900"
|
||||||
>
|
>
|
||||||
<FocusIcon className="h-5 w-5 group-hover:text-blue-500" />
|
<FocusIcon className="h-4 w-4" />
|
||||||
|
Open
|
||||||
</button>
|
</button>
|
||||||
</Tooltip.Trigger>
|
</Tooltip.Trigger>
|
||||||
<Tooltip.Portal>
|
<Tooltip.Portal>
|
||||||
@@ -60,6 +54,36 @@ export function NoteActions({
|
|||||||
</Tooltip.Root>
|
</Tooltip.Root>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="inline-flex items-center gap-10">
|
||||||
|
<Tooltip.Root delayDuration={150}>
|
||||||
|
<Tooltip.Trigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
navigate({
|
||||||
|
pathname: '/new/',
|
||||||
|
search: createSearchParams({
|
||||||
|
replyTo: event.id,
|
||||||
|
rootReplyTo: rootEventId,
|
||||||
|
}).toString(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="group inline-flex h-7 w-7 items-center justify-center text-neutral-600 dark:text-neutral-400"
|
||||||
|
>
|
||||||
|
<ReplyIcon className="h-5 w-5 group-hover:text-blue-500" />
|
||||||
|
</button>
|
||||||
|
</Tooltip.Trigger>
|
||||||
|
<Tooltip.Portal>
|
||||||
|
<Tooltip.Content className="-left-10 inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-200 px-3.5 text-sm text-neutral-900 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 dark:bg-neutral-800 dark:text-neutral-100">
|
||||||
|
Quick reply
|
||||||
|
<Tooltip.Arrow className="fill-neutral-200 dark:fill-neutral-800" />
|
||||||
|
</Tooltip.Content>
|
||||||
|
</Tooltip.Portal>
|
||||||
|
</Tooltip.Root>
|
||||||
|
<NoteReaction event={event} />
|
||||||
|
<NoteRepost event={event} />
|
||||||
|
<NoteZap event={event} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Tooltip.Provider>
|
</Tooltip.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -30,20 +30,12 @@ export function MoreActions({ id, pubkey }: { id: string; pubkey: string }) {
|
|||||||
</button>
|
</button>
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
<DropdownMenu.Portal>
|
<DropdownMenu.Portal>
|
||||||
<DropdownMenu.Content className="flex w-[200px] flex-col overflow-hidden rounded-xl border border-neutral-300 bg-neutral-200 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800">
|
<DropdownMenu.Content className="flex w-[200px] flex-col overflow-hidden rounded-xl border border-neutral-200 bg-neutral-100 focus:outline-none dark:border-neutral-800 dark:bg-neutral-900">
|
||||||
<DropdownMenu.Item asChild>
|
|
||||||
<Link
|
|
||||||
to={`/notes/text/${id}`}
|
|
||||||
className="inline-flex h-10 items-center px-4 text-sm text-neutral-900 hover:bg-neutral-300 focus:outline-none dark:text-neutral-100 dark:hover:bg-neutral-700"
|
|
||||||
>
|
|
||||||
Focus
|
|
||||||
</Link>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item asChild>
|
<DropdownMenu.Item asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => copyLink()}
|
onClick={() => copyLink()}
|
||||||
className="inline-flex h-10 items-center px-4 text-sm text-neutral-900 hover:bg-neutral-300 focus:outline-none dark:text-neutral-100 dark:hover:bg-neutral-700"
|
className="inline-flex h-10 items-center px-4 text-sm text-neutral-900 hover:bg-neutral-200 focus:outline-none dark:text-neutral-100 dark:hover:bg-neutral-800"
|
||||||
>
|
>
|
||||||
Copy shareable link
|
Copy shareable link
|
||||||
</button>
|
</button>
|
||||||
@@ -52,7 +44,7 @@ export function MoreActions({ id, pubkey }: { id: string; pubkey: string }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => copyID()}
|
onClick={() => copyID()}
|
||||||
className="inline-flex h-10 items-center px-4 text-sm text-neutral-900 hover:bg-neutral-300 focus:outline-none dark:text-neutral-100 dark:hover:bg-neutral-700"
|
className="inline-flex h-10 items-center px-4 text-sm text-neutral-900 hover:bg-neutral-200 focus:outline-none dark:text-neutral-100 dark:hover:bg-neutral-800"
|
||||||
>
|
>
|
||||||
Copy ID
|
Copy ID
|
||||||
</button>
|
</button>
|
||||||
@@ -60,7 +52,7 @@ export function MoreActions({ id, pubkey }: { id: string; pubkey: string }) {
|
|||||||
<DropdownMenu.Item asChild>
|
<DropdownMenu.Item asChild>
|
||||||
<Link
|
<Link
|
||||||
to={`/users/${pubkey}`}
|
to={`/users/${pubkey}`}
|
||||||
className="inline-flex h-10 items-center px-4 text-sm text-neutral-900 hover:bg-neutral-300 focus:outline-none dark:text-neutral-100 dark:hover:bg-neutral-700"
|
className="inline-flex h-10 items-center px-4 text-sm text-neutral-900 hover:bg-neutral-200 focus:outline-none dark:text-neutral-100 dark:hover:bg-neutral-800"
|
||||||
>
|
>
|
||||||
View profile
|
View profile
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
|
import { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||||
import * as Popover from '@radix-ui/react-popover';
|
import * as Popover from '@radix-ui/react-popover';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { useNDK } from '@libs/ndk/provider';
|
|
||||||
|
|
||||||
import { ReactionIcon } from '@shared/icons';
|
import { ReactionIcon } from '@shared/icons';
|
||||||
|
|
||||||
@@ -29,9 +28,7 @@ const REACTIONS = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function NoteReaction({ id, pubkey }: { id: string; pubkey: string }) {
|
export function NoteReaction({ event }: { event: NDKEvent }) {
|
||||||
const { ndk } = useNDK();
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [reaction, setReaction] = useState<string | null>(null);
|
const [reaction, setReaction] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -41,19 +38,14 @@ export function NoteReaction({ id, pubkey }: { id: string; pubkey: string }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const react = async (content: string) => {
|
const react = async (content: string) => {
|
||||||
|
try {
|
||||||
setReaction(content);
|
setReaction(content);
|
||||||
|
|
||||||
const event = new NDKEvent(ndk);
|
// react
|
||||||
event.content = content;
|
await event.react(content);
|
||||||
event.kind = NDKKind.Reaction;
|
|
||||||
event.tags = [
|
|
||||||
['e', id],
|
|
||||||
['p', pubkey],
|
|
||||||
];
|
|
||||||
|
|
||||||
const publishedRelays = await event.publish();
|
|
||||||
if (publishedRelays) {
|
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
|
||||||
import { createSearchParams, useNavigate } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { ReplyIcon } from '@shared/icons';
|
|
||||||
|
|
||||||
export function NoteReply({
|
|
||||||
id,
|
|
||||||
pubkey,
|
|
||||||
root,
|
|
||||||
}: {
|
|
||||||
id: string;
|
|
||||||
pubkey: string;
|
|
||||||
root?: string;
|
|
||||||
}) {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip.Root delayDuration={150}>
|
|
||||||
<Tooltip.Trigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
navigate({
|
|
||||||
pathname: '/new/',
|
|
||||||
search: createSearchParams({
|
|
||||||
id,
|
|
||||||
pubkey,
|
|
||||||
root,
|
|
||||||
}).toString(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="group inline-flex h-7 w-7 items-center justify-center text-neutral-600 dark:text-neutral-400"
|
|
||||||
>
|
|
||||||
<ReplyIcon className="h-5 w-5 group-hover:text-blue-500" />
|
|
||||||
</button>
|
|
||||||
</Tooltip.Trigger>
|
|
||||||
<Tooltip.Portal>
|
|
||||||
<Tooltip.Content className="-left-10 inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-200 px-3.5 text-sm text-neutral-900 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 dark:bg-neutral-800 dark:text-neutral-100">
|
|
||||||
Quick reply
|
|
||||||
<Tooltip.Arrow className="fill-neutral-200 dark:fill-neutral-800" />
|
|
||||||
</Tooltip.Content>
|
|
||||||
</Tooltip.Portal>
|
|
||||||
</Tooltip.Root>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,41 +1,30 @@
|
|||||||
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
|
import { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||||
import * as AlertDialog from '@radix-ui/react-alert-dialog';
|
import * as AlertDialog from '@radix-ui/react-alert-dialog';
|
||||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
import { useNDK } from '@libs/ndk/provider';
|
|
||||||
|
|
||||||
import { LoaderIcon, RepostIcon } from '@shared/icons';
|
import { LoaderIcon, RepostIcon } from '@shared/icons';
|
||||||
|
|
||||||
export function NoteRepost({ id, pubkey }: { id: string; pubkey: string }) {
|
export function NoteRepost({ event }: { event: NDKEvent }) {
|
||||||
const { ndk, relayUrls } = useNDK();
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isRepost, setIsRepost] = useState(false);
|
const [isRepost, setIsRepost] = useState(false);
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
const tags = [
|
// repsot
|
||||||
['e', id, relayUrls[0], 'root'],
|
await event.repost(true);
|
||||||
['p', pubkey],
|
|
||||||
];
|
|
||||||
|
|
||||||
const event = new NDKEvent(ndk);
|
// reset state
|
||||||
event.content = '';
|
|
||||||
event.kind = NDKKind.Repost;
|
|
||||||
event.tags = tags;
|
|
||||||
|
|
||||||
const publishedRelays = await event.publish();
|
|
||||||
if (publishedRelays) {
|
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setIsRepost(true);
|
setIsRepost(true);
|
||||||
|
|
||||||
toast.success(`Broadcasted to ${publishedRelays.size} relays successfully.`);
|
toast.success("You've reposted this post successfully");
|
||||||
} else {
|
} catch (e) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
toast.error('Repost failed, try again later');
|
toast.error('Repost failed, try again later');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { webln } from '@getalby/sdk';
|
import { webln } from '@getalby/sdk';
|
||||||
import { SendPaymentResponse } from '@getalby/sdk/dist/types';
|
import { SendPaymentResponse } from '@getalby/sdk/dist/types';
|
||||||
|
import { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||||
import * as Dialog from '@radix-ui/react-dialog';
|
import * as Dialog from '@radix-ui/react-dialog';
|
||||||
import { invoke } from '@tauri-apps/api';
|
import { invoke } from '@tauri-apps/api/primitives';
|
||||||
import { message } from '@tauri-apps/plugin-dialog';
|
import { message } from '@tauri-apps/plugin-dialog';
|
||||||
import { QRCodeSVG } from 'qrcode.react';
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
@@ -9,16 +10,13 @@ import CurrencyInput from 'react-currency-input-field';
|
|||||||
|
|
||||||
import { CancelIcon, ZapIcon } from '@shared/icons';
|
import { CancelIcon, ZapIcon } from '@shared/icons';
|
||||||
|
|
||||||
import { useEvent } from '@utils/hooks/useEvent';
|
|
||||||
import { useNostr } from '@utils/hooks/useNostr';
|
|
||||||
import { useProfile } from '@utils/hooks/useProfile';
|
import { useProfile } from '@utils/hooks/useProfile';
|
||||||
import { sendNativeNotification } from '@utils/notification';
|
import { sendNativeNotification } from '@utils/notification';
|
||||||
import { compactNumber } from '@utils/number';
|
import { compactNumber } from '@utils/number';
|
||||||
|
|
||||||
export function NoteZap({ id, pubkey }: { id: string; pubkey: string }) {
|
export function NoteZap({ event }: { event: NDKEvent }) {
|
||||||
const { createZap } = useNostr();
|
const nwc = useRef(null);
|
||||||
const { user } = useProfile(pubkey);
|
const { user } = useProfile(event.pubkey);
|
||||||
const { data: event } = useEvent(id);
|
|
||||||
|
|
||||||
const [walletConnectURL, setWalletConnectURL] = useState<string>(null);
|
const [walletConnectURL, setWalletConnectURL] = useState<string>(null);
|
||||||
const [amount, setAmount] = useState<string>('21');
|
const [amount, setAmount] = useState<string>('21');
|
||||||
@@ -28,12 +26,10 @@ export function NoteZap({ id, pubkey }: { id: string; pubkey: string }) {
|
|||||||
const [isCompleted, setIsCompleted] = useState(false);
|
const [isCompleted, setIsCompleted] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const nwc = useRef(null);
|
|
||||||
|
|
||||||
const createZapRequest = async () => {
|
const createZapRequest = async () => {
|
||||||
try {
|
try {
|
||||||
const zapAmount = parseInt(amount) * 1000;
|
const zapAmount = parseInt(amount) * 1000;
|
||||||
const res = await createZap(event, zapAmount, zapMessage);
|
const res = await event.zap(zapAmount, zapMessage);
|
||||||
|
|
||||||
if (!res)
|
if (!res)
|
||||||
return await message('Cannot create zap request', {
|
return await message('Cannot create zap request', {
|
||||||
@@ -84,9 +80,7 @@ export function NoteZap({ id, pubkey }: { id: string; pubkey: string }) {
|
|||||||
if (uri) setWalletConnectURL(uri);
|
if (uri) setWalletConnectURL(uri);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isOpen) {
|
if (isOpen) getWalletConnectURL();
|
||||||
getWalletConnectURL();
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
setAmount('21');
|
setAmount('21');
|
||||||
@@ -107,16 +101,16 @@ export function NoteZap({ id, pubkey }: { id: string; pubkey: string }) {
|
|||||||
</button>
|
</button>
|
||||||
</Dialog.Trigger>
|
</Dialog.Trigger>
|
||||||
<Dialog.Portal>
|
<Dialog.Portal>
|
||||||
<Dialog.Overlay className="fixed inset-0 z-50 bg-black" />
|
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/20 backdrop-blur-sm dark:bg-black/20" />
|
||||||
<Dialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
|
<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-neutral-400 dark:bg-neutral-600">
|
<div className="relative h-min w-full max-w-xl rounded-xl bg-white dark:bg-black">
|
||||||
<div className="inline-flex w-full shrink-0 items-center justify-between px-5 py-3">
|
<div className="inline-flex w-full shrink-0 items-center justify-between px-5 py-3">
|
||||||
<div className="w-6" />
|
<div className="w-6" />
|
||||||
<Dialog.Title className="text-center text-sm font-semibold leading-none text-white">
|
<Dialog.Title className="text-center font-semibold">
|
||||||
Send tip to {user?.name || user?.display_name || user?.displayName}
|
Send tip to {user?.name || user?.display_name || user?.displayName}
|
||||||
</Dialog.Title>
|
</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 bg-neutral-100 dark:bg-neutral-900">
|
||||||
<CancelIcon className="h-4 w-4 text-neutral-600 dark:text-neutral-400" />
|
<CancelIcon className="h-4 w-4" />
|
||||||
</Dialog.Close>
|
</Dialog.Close>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-y-auto overflow-x-hidden px-5 pb-5">
|
<div className="overflow-y-auto overflow-x-hidden px-5 pb-5">
|
||||||
@@ -133,7 +127,7 @@ export function NoteZap({ id, pubkey }: { id: string; pubkey: string }) {
|
|||||||
max={10000} // 1M sats
|
max={10000} // 1M sats
|
||||||
maxLength={10000} // 1M sats
|
maxLength={10000} // 1M sats
|
||||||
onValueChange={(value) => setAmount(value)}
|
onValueChange={(value) => setAmount(value)}
|
||||||
className="w-full flex-1 bg-transparent text-right text-4xl font-semibold text-white placeholder:text-neutral-600 focus:outline-none dark:text-neutral-400"
|
className="w-full flex-1 bg-transparent text-right text-4xl font-semibold placeholder:text-neutral-600 focus:outline-none dark:text-neutral-400"
|
||||||
/>
|
/>
|
||||||
<span className="w-full flex-1 text-left text-4xl font-semibold text-neutral-600 dark:text-neutral-400">
|
<span className="w-full flex-1 text-left text-4xl font-semibold text-neutral-600 dark:text-neutral-400">
|
||||||
sats
|
sats
|
||||||
@@ -143,35 +137,35 @@ export function NoteZap({ id, pubkey }: { id: string; pubkey: string }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setAmount('69')}
|
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"
|
className="w-max rounded-full border border-neutral-200 bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||||
>
|
>
|
||||||
69 sats
|
69 sats
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setAmount('100')}
|
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"
|
className="w-max rounded-full border border-neutral-200 bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||||
>
|
>
|
||||||
100 sats
|
100 sats
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setAmount('200')}
|
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"
|
className="w-max rounded-full border border-neutral-200 bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||||
>
|
>
|
||||||
200 sats
|
200 sats
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setAmount('500')}
|
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"
|
className="w-max rounded-full border border-neutral-200 bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||||
>
|
>
|
||||||
500 sats
|
500 sats
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setAmount('1000')}
|
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"
|
className="w-max rounded-full border border-neutral-200 bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||||
>
|
>
|
||||||
1K sats
|
1K sats
|
||||||
</button>
|
</button>
|
||||||
@@ -187,28 +181,28 @@ export function NoteZap({ id, pubkey }: { id: string; pubkey: string }) {
|
|||||||
autoCorrect="off"
|
autoCorrect="off"
|
||||||
autoCapitalize="off"
|
autoCapitalize="off"
|
||||||
placeholder="Enter message (optional)"
|
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-neutral-600 dark:text-neutral-400"
|
className="w-full resize-none rounded-lg bg-neutral-100 px-3 py-3 !outline-none placeholder:text-neutral-600 dark:bg-neutral-900 dark:text-neutral-400"
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{walletConnectURL ? (
|
{walletConnectURL ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => createZapRequest()}
|
onClick={() => createZapRequest()}
|
||||||
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-blue-500 px-4 font-medium text-white hover:bg-blue-600"
|
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-blue-500 px-4 font-medium text-white hover:bg-blue-600"
|
||||||
>
|
>
|
||||||
{isCompleted ? (
|
{isCompleted ? (
|
||||||
<p>Successfully tipped</p>
|
<p>Successfully tipped</p>
|
||||||
) : isLoading ? (
|
) : isLoading ? (
|
||||||
<span className="flex flex-col">
|
<span className="flex flex-col">
|
||||||
<p className="mb-px leading-none">Waiting for approval</p>
|
<p>Waiting for approval</p>
|
||||||
<p className="text-xs leading-none text-neutral-600 dark:text-neutral-400">
|
<p className="text-xs text-neutral-600 dark:text-neutral-400">
|
||||||
Go to your wallet and approve payment request
|
Go to your wallet and approve payment request
|
||||||
</p>
|
</p>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="flex flex-col">
|
<span className="flex flex-col">
|
||||||
<p className="mb-px leading-none">Send tip</p>
|
<p>Send tip</p>
|
||||||
<p className="text-xs leading-none text-neutral-600 dark:text-neutral-400">
|
<p className="text-xs text-neutral-600 dark:text-neutral-400">
|
||||||
You're using nostr wallet connect
|
You're using nostr wallet connect
|
||||||
</p>
|
</p>
|
||||||
</span>
|
</span>
|
||||||
@@ -218,9 +212,9 @@ export function NoteZap({ id, pubkey }: { id: string; pubkey: string }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => createZapRequest()}
|
onClick={() => createZapRequest()}
|
||||||
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-blue-500 px-4 font-medium hover:bg-blue-600"
|
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-blue-500 px-4 font-medium text-white hover:bg-blue-600"
|
||||||
>
|
>
|
||||||
<p>Create Lightning invoice</p>
|
Create Lightning invoice
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -228,13 +222,11 @@ export function NoteZap({ id, pubkey }: { id: string; pubkey: string }) {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-3 flex flex-col items-center justify-center gap-4">
|
<div className="mt-3 flex flex-col items-center justify-center gap-4">
|
||||||
<div className="rounded-md bg-white p-3">
|
<div className="rounded-md bg-neutral-100 p-3 dark:bg-neutral-900">
|
||||||
<QRCodeSVG value={invoice} size={256} />
|
<QRCodeSVG value={invoice} size={256} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center gap-1">
|
<div className="flex flex-col items-center gap-1">
|
||||||
<h3 className="text-lg font-medium leading-none text-white">
|
<h3 className="text-lg font-medium">Scan to pay</h3>
|
||||||
Scan to pay
|
|
||||||
</h3>
|
|
||||||
<span className="text-center text-sm text-neutral-600 dark:text-neutral-400">
|
<span className="text-center text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
You must use Bitcoin wallet which support Lightning
|
You must use Bitcoin wallet which support Lightning
|
||||||
<br />
|
<br />
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||||
|
import { memo } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { User } from '@shared/user';
|
||||||
|
|
||||||
|
import { NoteActions } from './actions';
|
||||||
|
|
||||||
|
export function ArticleNote({ event }: { event: NDKEvent }) {
|
||||||
|
const getMetadata = () => {
|
||||||
|
const title = event.tags.find((tag) => tag[0] === 'title')?.[1];
|
||||||
|
const image = event.tags.find((tag) => tag[0] === 'image')?.[1];
|
||||||
|
const summary = event.tags.find((tag) => tag[0] === 'summary')?.[1];
|
||||||
|
|
||||||
|
let publishedAt: Date | string | number = event.tags.find(
|
||||||
|
(tag) => tag[0] === 'published_at'
|
||||||
|
)?.[1];
|
||||||
|
if (publishedAt) {
|
||||||
|
publishedAt = new Date(parseInt(publishedAt) * 1000).toLocaleDateString('en-US');
|
||||||
|
} else {
|
||||||
|
publishedAt = new Date(event.created_at * 1000).toLocaleDateString('en-US');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
image,
|
||||||
|
publishedAt,
|
||||||
|
summary,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const metadata = getMetadata();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-3 h-min w-full px-3">
|
||||||
|
<div className="relative flex flex-col gap-2 overflow-hidden rounded-xl bg-neutral-50 pt-3 dark:bg-neutral-950">
|
||||||
|
<User pubkey={event.pubkey} time={event.created_at} eventId={event.id} />
|
||||||
|
<div className="px-3">
|
||||||
|
<Link
|
||||||
|
to={`/notes/article/${event.id}`}
|
||||||
|
preventScrollReset={true}
|
||||||
|
className="flex w-full flex-col rounded-lg bg-neutral-100 dark:bg-neutral-900"
|
||||||
|
>
|
||||||
|
{metadata.image && (
|
||||||
|
<img
|
||||||
|
src={metadata.image}
|
||||||
|
alt={metadata.title}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
style={{ contentVisibility: 'auto' }}
|
||||||
|
className="h-auto w-full rounded-t-lg object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-1 rounded-b-lg rounded-t-lg bg-neutral-100 px-3 py-3 dark:bg-neutral-900">
|
||||||
|
<h5 className="break-all font-semibold text-neutral-900 dark:text-neutral-100">
|
||||||
|
{metadata.title}
|
||||||
|
</h5>
|
||||||
|
{metadata.summary ? (
|
||||||
|
<p className="line-clamp-3 break-all text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
|
{metadata.summary}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<span className="mt-2.5 text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
|
{metadata.publishedAt.toString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<NoteActions event={event} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MemoizedArticleNote = memo(ArticleNote);
|
||||||
@@ -1,86 +1,29 @@
|
|||||||
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
|
import { NoteSkeleton } from '@shared/notes';
|
||||||
import { nip19 } from 'nostr-tools';
|
|
||||||
|
|
||||||
import {
|
|
||||||
ArticleNote,
|
|
||||||
FileNote,
|
|
||||||
LinkPreview,
|
|
||||||
NoteActions,
|
|
||||||
NoteSkeleton,
|
|
||||||
TextNote,
|
|
||||||
UnknownNote,
|
|
||||||
} from '@shared/notes';
|
|
||||||
import { User } from '@shared/user';
|
import { User } from '@shared/user';
|
||||||
|
|
||||||
import { useEvent } from '@utils/hooks/useEvent';
|
import { useEvent } from '@utils/hooks/useEvent';
|
||||||
|
|
||||||
export function ChildNote({ id, root }: { id: string; root?: string }) {
|
export function ChildNote({ id, isRoot }: { id: string; isRoot?: boolean }) {
|
||||||
const { status, data } = useEvent(id);
|
const { status, data } = useEvent(id);
|
||||||
|
|
||||||
const renderKind = (event: NDKEvent) => {
|
if (status === 'pending' || !data) {
|
||||||
switch (event.kind) {
|
return <NoteSkeleton />;
|
||||||
case NDKKind.Text:
|
|
||||||
return <TextNote content={event.content} />;
|
|
||||||
case NDKKind.Article:
|
|
||||||
return <ArticleNote event={event} />;
|
|
||||||
case 1063:
|
|
||||||
return <FileNote event={event} />;
|
|
||||||
default:
|
|
||||||
return <UnknownNote event={event} />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (status === 'loading') {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="absolute bottom-0 left-[18px] h-[calc(100%-3.4rem)] w-0.5 bg-gradient-to-t from-black/20 to-black/10 dark:from-white/20 dark:to-white/10" />
|
|
||||||
<div className="relative mb-5 overflow-hidden">
|
|
||||||
<NoteSkeleton />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === 'error') {
|
|
||||||
const noteLink = `https://njump.me/${nip19.noteEncode(id)}`;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="absolute bottom-0 left-[18px] h-[calc(100%-3.4rem)] w-0.5 bg-gradient-to-t from-black/20 to-black/10 dark:from-white/20 dark:to-white/10" />
|
|
||||||
<div className="relative mb-5 flex flex-col">
|
|
||||||
<div className="relative z-10 flex items-start gap-3">
|
|
||||||
<div className="inline-flex h-10 w-10 shrink-0 items-end justify-center rounded-lg bg-black"></div>
|
|
||||||
<h5 className="truncate font-semibold leading-none text-neutral-900 dark:text-neutral-100">
|
|
||||||
Lume <span className="text-teal-500">(System)</span>
|
|
||||||
</h5>
|
|
||||||
</div>
|
|
||||||
<div className="-mt-4 flex items-start gap-3">
|
|
||||||
<div className="w-10 shrink-0" />
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="prose prose-neutral max-w-none select-text whitespace-pre-line break-all leading-normal dark:prose-invert prose-headings:mb-1 prose-headings:mt-3 prose-p:mb-0 prose-p:mt-0 prose-p:last:mb-1 prose-a:font-normal prose-a:text-blue-500 prose-blockquote:mb-1 prose-blockquote:mt-1 prose-blockquote:border-l-[2px] prose-blockquote:border-blue-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 hover:prose-a:text-blue-500">
|
|
||||||
Lume cannot find this post with your current relay set, but you can view
|
|
||||||
it via njump.me
|
|
||||||
</div>
|
|
||||||
<LinkPreview urls={[noteLink]} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="relative flex gap-3">
|
||||||
<div className="absolute bottom-0 left-[18px] h-[calc(100%-3.6rem)] w-0.5 bg-gradient-to-t from-black/20 to-black/10 dark:from-white/20 dark:to-white/10" />
|
<div className="relative flex-1 rounded-md bg-neutral-200 px-2 py-2 dark:bg-neutral-800">
|
||||||
<div className="mb-5 flex flex-col">
|
<div className="absolute right-0 top-[18px] h-3 w-3 -translate-y-1/2 translate-x-1/2 rotate-45 transform bg-neutral-200 dark:bg-neutral-800"></div>
|
||||||
<User pubkey={data.pubkey} time={data.created_at} eventId={data.id} />
|
<div className="break-p mt-6 line-clamp-3 select-text leading-normal text-neutral-900 dark:text-neutral-100">
|
||||||
<div className="-mt-4 flex items-start gap-3">
|
{data.content}
|
||||||
<div className="w-10 shrink-0" />
|
|
||||||
<div className="relative z-20 flex-1">
|
|
||||||
{renderKind(data)}
|
|
||||||
<NoteActions id={data.id} pubkey={data.pubkey} root={root} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<User
|
||||||
|
pubkey={data.pubkey}
|
||||||
|
time={data.created_at}
|
||||||
|
variant="childnote"
|
||||||
|
subtext={isRoot ? 'posted' : 'replied'}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||||
|
import { downloadDir } from '@tauri-apps/api/path';
|
||||||
|
import { download } from '@tauri-apps/plugin-upload';
|
||||||
|
import {
|
||||||
|
MediaControlBar,
|
||||||
|
MediaController,
|
||||||
|
MediaFullscreenButton,
|
||||||
|
MediaMuteButton,
|
||||||
|
MediaPlayButton,
|
||||||
|
MediaTimeRange,
|
||||||
|
} from 'media-chrome/dist/react';
|
||||||
|
import { memo } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { DownloadIcon } from '@shared/icons';
|
||||||
|
import { NoteActions } from '@shared/notes';
|
||||||
|
import { User } from '@shared/user';
|
||||||
|
|
||||||
|
import { fileType } from '@utils/nip94';
|
||||||
|
|
||||||
|
export function FileNote({ event }: { event: NDKEvent }) {
|
||||||
|
const downloadImage = async (url: string) => {
|
||||||
|
const downloadDirPath = await downloadDir();
|
||||||
|
const filename = url.substring(url.lastIndexOf('/') + 1);
|
||||||
|
return await download(url, downloadDirPath + `/${filename}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderFileType = () => {
|
||||||
|
const url = event.tags.find((el) => el[0] === 'url')[1];
|
||||||
|
const type = fileType(url);
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'image':
|
||||||
|
return (
|
||||||
|
<div key={url} className="group relative">
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt={url}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
style={{ contentVisibility: 'auto' }}
|
||||||
|
className="h-auto w-full object-cover"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => downloadImage(url)}
|
||||||
|
className="absolute right-2 top-2 hidden h-10 w-10 items-center justify-center rounded-lg bg-black/50 backdrop-blur-xl group-hover:inline-flex hover:bg-blue-500"
|
||||||
|
>
|
||||||
|
<DownloadIcon className="h-5 w-5 text-white" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'video':
|
||||||
|
return (
|
||||||
|
<MediaController
|
||||||
|
key={url}
|
||||||
|
className="aspect-video w-full overflow-hidden rounded-lg"
|
||||||
|
>
|
||||||
|
<video slot="media" src={url} preload="metadata" muted />
|
||||||
|
<MediaControlBar>
|
||||||
|
<MediaPlayButton></MediaPlayButton>
|
||||||
|
<MediaTimeRange></MediaTimeRange>
|
||||||
|
<MediaMuteButton></MediaMuteButton>
|
||||||
|
<MediaFullscreenButton></MediaFullscreenButton>
|
||||||
|
</MediaControlBar>
|
||||||
|
</MediaController>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-blue-500 hover:text-blue-600"
|
||||||
|
>
|
||||||
|
{url}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-3 h-min w-full px-3">
|
||||||
|
<div className="relative flex flex-col gap-2 overflow-hidden rounded-xl bg-neutral-50 pt-3 dark:bg-neutral-950">
|
||||||
|
<User pubkey={event.pubkey} time={event.created_at} eventId={event.id} />
|
||||||
|
<div className="relative mt-2">{renderFileType()}</div>
|
||||||
|
<NoteActions event={event} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MemoizedFileNote = memo(FileNote);
|
||||||
@@ -1,9 +1,16 @@
|
|||||||
|
export * from './text';
|
||||||
|
export * from './repost';
|
||||||
|
export * from './file';
|
||||||
|
export * from './article';
|
||||||
|
export * from './child';
|
||||||
|
export * from './notify';
|
||||||
|
export * from './unknown';
|
||||||
|
export * from './skeleton';
|
||||||
|
export * from './actions';
|
||||||
export * from './actions/reaction';
|
export * from './actions/reaction';
|
||||||
export * from './actions/reply';
|
|
||||||
export * from './actions/repost';
|
export * from './actions/repost';
|
||||||
export * from './actions/zap';
|
export * from './actions/zap';
|
||||||
export * from './mentions/note';
|
export * from './actions/more';
|
||||||
export * from './mentions/user';
|
|
||||||
export * from './preview/image';
|
export * from './preview/image';
|
||||||
export * from './preview/link';
|
export * from './preview/link';
|
||||||
export * from './preview/video';
|
export * from './preview/video';
|
||||||
@@ -11,20 +18,11 @@ export * from './replies/form';
|
|||||||
export * from './replies/item';
|
export * from './replies/item';
|
||||||
export * from './replies/list';
|
export * from './replies/list';
|
||||||
export * from './replies/sub';
|
export * from './replies/sub';
|
||||||
export * from './kinds/text';
|
|
||||||
export * from './kinds/file';
|
|
||||||
export * from './kinds/article';
|
|
||||||
export * from './kinds/articleDetail';
|
|
||||||
export * from './kinds/unknown';
|
|
||||||
export * from './metadata';
|
|
||||||
export * from './kinds/repost';
|
|
||||||
export * from './child';
|
|
||||||
export * from './skeleton';
|
|
||||||
export * from './actions';
|
|
||||||
export * from './mentions/hashtag';
|
|
||||||
export * from './mentions/boost';
|
|
||||||
export * from './mentions/invoice';
|
|
||||||
export * from './stats';
|
|
||||||
export * from './wrapper';
|
|
||||||
export * from './actions/more';
|
|
||||||
export * from './replies/replyMediaUploader';
|
export * from './replies/replyMediaUploader';
|
||||||
|
export * from './mentions/note';
|
||||||
|
export * from './mentions/user';
|
||||||
|
export * from './mentions/hashtag';
|
||||||
|
export * from './mentions/invoice';
|
||||||
|
export * from './kinds/text';
|
||||||
|
export * from './kinds/article';
|
||||||
|
export * from './kinds/file';
|
||||||
|
|||||||