Compare commits

...
59 Commits
Author SHA1 Message Date
Ren AmamiyaandGitHub a896300f23 Merge pull request #118 from luminous-devs/v2.1.2
v2.1.2
2023-11-22 16:18:02 +07:00
reya d3cf1200ba bump version 2023-11-22 16:13:06 +07:00
reya b5ac3df090 fix package 2023-11-22 16:10:20 +07:00
reya 3b40dd6903 update dependencies 2023-11-22 15:27:19 +07:00
Ren AmamiyaandGitHub efba6b20ea Merge pull request #117 from luminous-devs/feat/optional-updater
Make auto update is optional
2023-11-22 10:34:49 +07:00
Ren AmamiyaandGitHub 05fb56e5fc Merge pull request #116 from vivganes/patch-1
Little grammar corrections
2023-11-22 10:32:47 +07:00
Vivek GanesanandGitHub 59d9646e9f Little grammar corrections 2023-11-22 08:43:31 +05:30
reya b73d84fccb update storage provider 2023-11-22 09:05:10 +07:00
reya 1929ceb72d add toast message 2023-11-22 08:31:58 +07:00
reya a1d22c1daf make auto update is optional 2023-11-22 08:30:43 +07:00
reya cf7af1ba64 fix ci again again 2023-11-20 08:47:16 +07:00
reya 933ca758ee fix ci again 2023-11-20 08:27:37 +07:00
reya f537209b92 fix ci 2023-11-20 08:16:39 +07:00
reya 6777610b07 update dependencies 2023-11-20 08:02:09 +07:00
reya 88803cd3cd bump version 2023-11-19 19:51:00 +07:00
Ren AmamiyaandGitHub 6adf5933b0 Merge pull request #113 from luminous-devs/hotfix/themes
Add support dark mode for toaster
2023-11-19 19:49:59 +07:00
reya 9521a49fff support dark mode for toaster 2023-11-19 19:49:26 +07:00
Ren AmamiyaandGitHub 5789a105f5 Merge pull request #112 from luminous-devs/hotfix/settings
Fix settings screen
2023-11-19 15:15:11 +07:00
reya b7a18bea34 respect user settings 2023-11-19 14:50:59 +07:00
reya 7117ed05a9 update settings screen 2023-11-19 08:48:01 +07:00
reya c53bdb68e5 add change theme function 2023-11-18 21:17:37 +07:00
reya 6725dca807 fix all dms bugs 2023-11-17 16:03:12 +07:00
Ren AmamiyaandGitHub 077712cf43 Merge pull request #105 from luminous-devs/feat/v2.1.0
v2.1.0
2023-11-17 10:06:58 +07:00
reya 2794c78ee1 add new private key screen 2023-11-17 09:24:21 +07:00
reya 21574023db update dark mode 2023-11-17 08:16:25 +07:00
reya 954b729dc9 wip: settings screen 2023-11-16 17:48:29 +07:00
reya 6dc4e1cde6 bump version 2023-11-16 08:38:23 +07:00
reya efbdf26706 add window state plugin 2023-11-16 08:25:08 +07:00
reya b41ec353c6 improve relay connection 2023-11-16 07:59:29 +07:00
reya 875225591a wip: settings screen 2023-11-15 14:20:45 +07:00
reya 04c1223f2e update article screen 2023-11-15 13:32:23 +07:00
reya 773e49afa2 wip 2023-11-15 13:13:11 +07:00
reya 025d7a623b polish 2023-11-15 10:27:47 +07:00
reya b6caab15e1 update tauri controls 2023-11-15 09:26:59 +07:00
reya 1d3d0a17dc fix updater 2023-11-15 08:32:40 +07:00
reya 1cbe781698 update error screen 2023-11-14 16:14:40 +07:00
reya dc5b4f8ac1 refactor publish event 2023-11-14 15:15:13 +07:00
reya fee4ad7b98 smal fixes and update article layout 2023-11-13 15:44:58 +07:00
reya d5647d7452 redesign loading screen 2023-11-13 08:16:36 +07:00
reya 0a5076f06c polish 2023-11-12 15:43:14 +07:00
reya a3632571ff polish 2023-11-12 08:41:47 +07:00
reya 5c48ebe103 improve spacing 2023-11-11 16:12:50 +07:00
reya 1c3119577f update widget list 2023-11-11 09:14:23 +07:00
reya 0710996a0d wip: update widget list 2023-11-10 16:05:20 +07:00
reya 0cdf199cb5 wip: rework widget 2023-11-09 18:02:25 +07:00
reya cb9006abb2 add topic widget 2023-11-09 09:12:42 +07:00
reya 108ecafab7 update widgets 2023-11-08 16:17:47 +07:00
reya 6b030f2902 polish 2023-11-08 08:21:52 +07:00
reya ce864c8990 wip 2023-11-07 16:23:01 +07:00
reya ee3e8eb105 wip 2023-11-07 09:35:13 +07:00
reya 701712e7b8 polish 2023-11-05 15:19:51 +07:00
reya dad388c6ab new parser 2023-11-04 14:18:29 +07:00
Ren AmamiyaandGitHub 912c740c51 Merge pull request #103 from luminous-devs/feat/personal-dashboard
Add personal dashboard
2023-11-03 15:41:28 +07:00
reya da0b48c5df update personal dashboard screen 2023-11-03 15:38:58 +07:00
reya 64b4745993 update personal screen 2023-11-03 08:45:25 +07:00
reya 8aa2ef39c5 update nip-05 and user profile component styles 2023-11-02 13:47:44 +07:00
reya a945f04959 update dependencies 2023-11-02 13:09:52 +07:00
reya c93989237a fix app crash issue 2023-11-01 16:59:13 +07:00
reya 95cf3f60f3 readd updater pubkey 2023-11-01 16:42:20 +07:00
164 changed files with 6428 additions and 5853 deletions
+3 -3
View File
@@ -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
+51 -52
View File
@@ -2,7 +2,7 @@
"name": "lume", "name": "lume",
"description": "the communication app", "description": "the communication app",
"private": true, "private": true,
"version": "2.0.1", "version": "2.1.2",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
@@ -19,10 +19,9 @@
}, },
"dependencies": { "dependencies": {
"@evilmartians/harmony": "^1.1.0", "@evilmartians/harmony": "^1.1.0",
"@getalby/sdk": "^2.5.0", "@getalby/sdk": "^2.6.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",
@@ -30,24 +29,24 @@
"@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-hover-card": "^1.0.7", "@radix-ui/react-hover-card": "^1.0.7",
"@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-switch": "^1.0.3",
"@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/query-sync-storage-persister": "^5.4.3", "@tanstack/react-query": "^5.8.4",
"@tanstack/react-query": "^5.0.5", "@tauri-apps/api": "2.0.0-alpha.11",
"@tanstack/react-query-persist-client": "^5.4.3", "@tauri-apps/cli": "2.0.0-alpha.17",
"@tauri-apps/api": "^2.0.0-alpha.9", "@tauri-apps/plugin-autostart": "2.0.0-alpha.3",
"@tauri-apps/cli": "^2.0.0-alpha.16", "@tauri-apps/plugin-clipboard-manager": "2.0.0-alpha.3",
"@tauri-apps/plugin-clipboard-manager": "^2.0.0-alpha.2", "@tauri-apps/plugin-dialog": "2.0.0-alpha.3",
"@tauri-apps/plugin-dialog": "^2.0.0-alpha.2", "@tauri-apps/plugin-fs": "2.0.0-alpha.3",
"@tauri-apps/plugin-fs": "^2.0.0-alpha.2", "@tauri-apps/plugin-http": "2.0.0-alpha.3",
"@tauri-apps/plugin-http": "^2.0.0-alpha.2", "@tauri-apps/plugin-notification": "2.0.0-alpha.3",
"@tauri-apps/plugin-notification": "^2.0.0-alpha.2", "@tauri-apps/plugin-os": "2.0.0-alpha.4",
"@tauri-apps/plugin-os": "^2.0.0-alpha.3", "@tauri-apps/plugin-process": "2.0.0-alpha.3",
"@tauri-apps/plugin-process": "^2.0.0-alpha.2", "@tauri-apps/plugin-shell": "2.0.0-alpha.3",
"@tauri-apps/plugin-shell": "^2.0.0-alpha.2", "@tauri-apps/plugin-sql": "2.0.0-alpha.3",
"@tauri-apps/plugin-sql": "^2.0.0-alpha.2", "@tauri-apps/plugin-updater": "2.0.0-alpha.3",
"@tauri-apps/plugin-updater": "^2.0.0-alpha.2", "@tauri-apps/plugin-upload": "2.0.0-alpha.3",
"@tauri-apps/plugin-upload": "^2.0.0-alpha.2",
"@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 +59,65 @@
"@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.2", "framer-motion": "^10.16.5",
"framer-motion": "^10.16.4",
"html-to-text": "^9.0.5", "html-to-text": "^9.0.5",
"immer": "^10.0.3", "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.3",
"markdown-to-jsx": "^7.3.2", "markdown-to-jsx": "^7.3.2",
"media-chrome": "^1.4.5", "media-chrome": "^1.5.3",
"million": "^2.6.4",
"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-router-dom": "^6.17.0", "react-router-dom": "^6.19.0",
"reactflow": "^11.9.4", "react-string-replace": "^1.1.1",
"sonner": "^1.0.3", "reactflow": "^11.10.1",
"sonner": "^1.2.2",
"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.6", "virtua": "^0.16.5",
"zustand": "^4.4.4" "zustand": "^4.4.6"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/typography": "^0.5.10", "@tailwindcss/typography": "^0.5.10",
"@trivago/prettier-plugin-sort-imports": "^4.2.1", "@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.9", "@types/node": "^20.9.4",
"@types/react": "^18.2.33", "@types/react": "^18.2.38",
"@types/react-dom": "^18.2.14", "@types/react-dom": "^18.2.17",
"@types/youtube-player": "^5.5.9", "@types/youtube-player": "^5.5.11",
"@typescript-eslint/eslint-plugin": "^6.9.0", "@typescript-eslint/eslint-plugin": "^6.12.0",
"@typescript-eslint/parser": "^6.9.0", "@typescript-eslint/parser": "^6.12.0",
"@vitejs/plugin-react-swc": "^3.4.0", "@vitejs/plugin-react-swc": "^3.5.0",
"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.52.0", "eslint": "^8.54.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": "^15.0.2", "lint-staged": "^15.1.0",
"postcss": "^8.4.31", "postcss": "^8.4.31",
"prettier": "^3.0.3", "prettier": "^3.1.0",
"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.5", "tailwindcss": "^3.3.5",
"typescript": "^5.2.2", "typescript": "^5.3.2",
"vite": "^4.5.0", "vite": "^4.5.0",
"vite-tsconfig-paths": "^4.2.1" "vite-tsconfig-paths": "^4.2.1"
} }
+1042 -1211
View File
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+689 -545
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "lume" name = "lume"
version = "2.0.1" version = "2.1.2"
description = "the communication app" description = "the communication app"
authors = ["Ren Amamiya"] authors = ["Ren Amamiya"]
license = "GPL-3.0" license = "GPL-3.0"
@@ -32,6 +32,8 @@ tauri-plugin-single-instance = { git = "https://github.com/tauri-apps/plugins-wo
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-theme = { git = "https://github.com/wyhaya/tauri-plugin-theme" }
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",
] } ] }
+15 -21
View File
@@ -5,9 +5,9 @@
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 tauri_plugin_theme::ThemePlugin;
use webpage::{Webpage, WebpageOptions}; use webpage::{Webpage, WebpageOptions};
#[derive(Clone, serde::Serialize)] #[derive(Clone, serde::Serialize)]
@@ -106,6 +106,7 @@ fn secure_remove(key: String) -> Result<(), ()> {
} }
fn main() { fn main() {
let mut ctx = tauri::generate_context!();
tauri::Builder::default() tauri::Builder::default()
.setup(|app| { .setup(|app| {
#[cfg(desktop)] #[cfg(desktop)]
@@ -114,16 +115,7 @@ fn main() {
.plugin(tauri_plugin_updater::Builder::new().build())?; .plugin(tauri_plugin_updater::Builder::new().build())?;
Ok(()) Ok(())
}) })
.plugin(tauri_plugin_clipboard_manager::init()) .plugin(ThemePlugin::init(ctx.config_mut()))
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_upload::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin( .plugin(
tauri_plugin_sql::Builder::default() tauri_plugin_sql::Builder::default()
.add_migrations( .add_migrations(
@@ -145,24 +137,26 @@ fn main() {
) )
.build(), .build(),
) )
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_upload::init())
.plugin(tauri_plugin_window_state::Builder::default().build())
.plugin(tauri_plugin_autostart::init( .plugin(tauri_plugin_autostart::init(
MacosLauncher::LaunchAgent, MacosLauncher::LaunchAgent,
Some(vec!["--flag1", "--flag2"]), Some(vec![]),
)) ))
.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,
secure_load, secure_load,
secure_remove secure_remove
]) ])
.run(tauri::generate_context!()) .run(ctx)
.expect("error while running tauri application"); .expect("error while running tauri application");
} }
+16 -10
View File
@@ -9,7 +9,7 @@
}, },
"package": { "package": {
"productName": "Lume", "productName": "Lume",
"version": "2.0.1" "version": "2.1.2"
}, },
"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",
+4
View File
@@ -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 {
+51 -8
View File
@@ -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';
@@ -6,13 +7,13 @@ import { OnboardingScreen } from '@app/auth/onboarding';
import { ChatsScreen } from '@app/chats'; import { ChatsScreen } from '@app/chats';
import { ErrorScreen } from '@app/error'; import { ErrorScreen } from '@app/error';
import { ExploreScreen } from '@app/explore'; import { ExploreScreen } from '@app/explore';
import { NewScreen } from '@app/new';
import { useStorage } from '@libs/storage/provider'; import { useStorage } from '@libs/storage/provider';
import { LoaderIcon } from '@shared/icons'; import { LoaderIcon } from '@shared/icons';
import { AppLayout } from '@shared/layouts/app'; import { AppLayout } from '@shared/layouts/app';
import { AuthLayout } from '@shared/layouts/auth'; import { AuthLayout } from '@shared/layouts/auth';
import { NewLayout } from '@shared/layouts/new';
import { NoteLayout } from '@shared/layouts/note'; import { NoteLayout } from '@shared/layouts/note';
import { SettingsLayout } from '@shared/layouts/settings'; import { SettingsLayout } from '@shared/layouts/settings';
@@ -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 };
}, },
}, },
{ {
@@ -114,7 +115,7 @@ export default function App() {
}, },
{ {
path: '/new', path: '/new',
element: <NewScreen />, element: <NewLayout />,
errorElement: <ErrorScreen />, errorElement: <ErrorScreen />,
children: [ children: [
{ {
@@ -138,6 +139,13 @@ export default function App() {
return { Component: NewFileScreen }; return { Component: NewFileScreen };
}, },
}, },
{
path: 'privkey',
async lazy() {
const { NewPrivkeyScreen } = await import('@app/new/privkey');
return { Component: NewPrivkeyScreen };
},
},
], ],
}, },
{ {
@@ -231,15 +239,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 ? (
@@ -12,7 +12,7 @@ export function FollowList() {
const { status, data } = useQuery({ const { status, data } = useQuery({
queryKey: ['follows'], queryKey: ['follows'],
queryFn: 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 = [];
+2
View File
@@ -45,6 +45,8 @@ export function CreateAccountScreen() {
const onSubmit = async (data: { name: string; about: string }) => { const onSubmit = async (data: { name: string; about: string }) => {
try { try {
if (!ndk.signer) return navigate('/new/privkey');
setLoading(true); setLoading(true);
const profile = { const profile = {
+1 -1
View File
@@ -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"
-1
View File
@@ -47,7 +47,6 @@ export function OnboardEnrichScreen() {
setLoading(true); setLoading(true);
const tags = arrayToNIP02(follows); const tags = arrayToNIP02(follows);
const event = new NDKEvent(ndk); const event = new NDKEvent(ndk);
event.content = ''; event.content = '';
event.kind = NDKKind.Contacts; event.kind = NDKKind.Contacts;
+21 -59
View File
@@ -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 { WidgetKinds } from '@stores/constants'; import { TOPICS, WIDGET_KIND } from '@stores/constants';
import { useOnboarding } from '@stores/onboarding'; import { useOnboarding } from '@stores/onboarding';
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>
+1 -1
View File
@@ -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&apos;re almost ready to use Lume. You&apos;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">
+33 -10
View File
@@ -1,5 +1,5 @@
import { NDKEvent, NDKSubscription } from '@nostr-dev-kit/ndk'; import { NDKEvent, NDKSubscription } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useCallback, useEffect, useRef } from 'react'; import { useCallback, useEffect, useRef } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { VList, VListHandle } from 'virtua'; import { VList, VListHandle } from 'virtua';
@@ -16,8 +16,6 @@ import { User } from '@shared/user';
import { useNostr } from '@utils/hooks/useNostr'; import { useNostr } from '@utils/hooks/useNostr';
export function ChatScreen() { export function ChatScreen() {
const listRef = useRef<VListHandle>(null);
const { db } = useStorage(); const { db } = useStorage();
const { ndk } = useNDK(); const { ndk } = useNDK();
const { pubkey } = useParams(); const { pubkey } = useParams();
@@ -30,10 +28,39 @@ export function ChatScreen() {
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}); });
const queryClient = useQueryClient();
const listRef = useRef<VListHandle>(null);
const newMessage = useMutation({
mutationFn: async (event: NDKEvent) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: ['nip04-dm', pubkey] });
// Snapshot the previous value
const prevMessages = queryClient.getQueryData(['nip04-dm', pubkey]);
// Optimistically update to the new value
queryClient.setQueryData(['nip04-dm', pubkey], (prev: NDKEvent[]) => [
...prev,
event,
]);
// Return a context object with the snapshotted value
return { prevMessages };
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['nip04-dm', pubkey] });
},
});
const renderItem = useCallback( const renderItem = useCallback(
(message: NDKEvent) => { (message: NDKEvent) => {
return ( return (
<ChatMessage message={message} self={message.pubkey === db.account.pubkey} /> <ChatMessage
key={message.id}
message={message}
isSelf={message.pubkey === db.account.pubkey}
/>
); );
}, },
[data] [data]
@@ -57,7 +84,7 @@ export function ChatScreen() {
); );
sub.addListener('event', (event) => { sub.addListener('event', (event) => {
console.log(event); newMessage.mutate(event);
}); });
return () => { return () => {
@@ -96,11 +123,7 @@ export function ChatScreen() {
)} )}
</div> </div>
<div className="shrink-0 rounded-b-lg border-t border-neutral-300 bg-neutral-200 p-3 dark:border-neutral-700 dark:bg-neutral-800"> <div className="shrink-0 rounded-b-lg border-t border-neutral-300 bg-neutral-200 p-3 dark:border-neutral-700 dark:bg-neutral-800">
<ChatForm <ChatForm receiverPubkey={pubkey} />
receiverPubkey={pubkey}
userPubkey={db.account.pubkey}
userPrivkey={''}
/>
</div> </div>
</div> </div>
</div> </div>
+17 -25
View File
@@ -1,6 +1,6 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk'; import { NDKEvent, NDKKind, NDKUser } from '@nostr-dev-kit/ndk';
import { nip04 } from 'nostr-tools'; import { useState } from 'react';
import { useCallback, useState } from 'react'; import { toast } from 'sonner';
import { MediaUploader } from '@app/chats/components/mediaUploader'; import { MediaUploader } from '@app/chats/components/mediaUploader';
@@ -8,34 +8,26 @@ import { useNDK } from '@libs/ndk/provider';
import { EnterIcon } from '@shared/icons'; import { EnterIcon } from '@shared/icons';
export function ChatForm({ export function ChatForm({ receiverPubkey }: { receiverPubkey: string }) {
receiverPubkey,
userPrivkey,
}: {
receiverPubkey: string;
userPubkey: string;
userPrivkey: string;
}) {
const { ndk } = useNDK(); const { ndk } = useNDK();
const [value, setValue] = useState(''); const [value, setValue] = useState('');
const encryptMessage = useCallback(async () => {
return await nip04.encrypt(userPrivkey, receiverPubkey, value);
}, [receiverPubkey, value]);
const submit = async () => { const submit = async () => {
const message = await encryptMessage(); try {
const tags = [['p', receiverPubkey]]; const recipient = new NDKUser({ pubkey: receiverPubkey });
const message = await ndk.signer.encrypt(recipient, value);
const event = new NDKEvent(ndk); const event = new NDKEvent(ndk);
event.content = message; event.content = message;
event.kind = NDKKind.EncryptedDirectMessage; event.kind = NDKKind.EncryptedDirectMessage;
event.tags = tags; event.tag(recipient);
await event.publish(); const publish = await event.publish();
// reset state if (publish) setValue('');
setValue(''); } catch (e) {
toast.error(e);
}
}; };
const handleEnterPress = (e: { const handleEnterPress = (e: {
@@ -61,7 +53,7 @@ export function ChatForm({
autoComplete="off" autoComplete="off"
autoCorrect="off" autoCorrect="off"
autoCapitalize="off" autoCapitalize="off"
placeholder="Message" placeholder="Message..."
className="h-10 flex-1 resize-none bg-transparent px-3 text-neutral-900 placeholder:text-neutral-600 focus:outline-none dark:text-neutral-100 dark:placeholder:text-neutral-300" className="h-10 flex-1 resize-none bg-transparent px-3 text-neutral-900 placeholder:text-neutral-600 focus:outline-none dark:text-neutral-100 dark:placeholder:text-neutral-300"
/> />
<button <button
+4 -20
View File
@@ -3,38 +3,22 @@ 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'; export function ChatMessage({ message, isSelf }: { message: NDKEvent; isSelf: boolean }) {
import { parser } from '@utils/parser';
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
className={twMerge( className={twMerge(
'my-2 w-max max-w-[400px] rounded-t-xl px-3 py-3', 'my-2 w-max max-w-[400px] rounded-t-xl px-3 py-3',
self isSelf
? 'ml-auto rounded-l-xl bg-blue-500 text-white' ? 'ml-auto rounded-l-xl bg-blue-500 text-white'
: '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> <p className="select-text whitespace-pre-line break-all">{decryptedContent}</p>
<p className="select-text whitespace-pre-line">{richContent.parsed}</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>
); );
+1 -1
View File
@@ -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 -2
View File
@@ -1,15 +1,20 @@
import { NDKEvent } from '@nostr-dev-kit/ndk'; import { NDKEvent } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useCallback } from 'react'; import { useCallback, useEffect } from 'react';
import { Outlet } from 'react-router-dom'; import { Outlet, useNavigate } from 'react-router-dom';
import { ChatListItem } from '@app/chats/components/chatListItem'; import { ChatListItem } from '@app/chats/components/chatListItem';
import { useNDK } from '@libs/ndk/provider';
import { LoaderIcon } from '@shared/icons'; import { LoaderIcon } from '@shared/icons';
import { useNostr } from '@utils/hooks/useNostr'; import { useNostr } from '@utils/hooks/useNostr';
export function ChatsScreen() { export function ChatsScreen() {
const navigate = useNavigate();
const { ndk } = useNDK();
const { getAllNIP04Chats } = useNostr(); const { getAllNIP04Chats } = useNostr();
const { status, data } = useQuery({ const { status, data } = useQuery({
queryKey: ['nip04-chats'], queryKey: ['nip04-chats'],
@@ -29,6 +34,10 @@ export function ChatsScreen() {
[data] [data]
); );
useEffect(() => {
if (!ndk.signer) navigate('/new/privkey');
}, []);
return ( return (
<div className="grid h-full w-full grid-cols-3"> <div className="grid h-full w-full grid-cols-3">
<div className="col-span-1 h-full overflow-y-auto border-r border-neutral-200 scrollbar-none dark:border-neutral-800"> <div className="col-span-1 h-full overflow-y-auto border-r border-neutral-200 scrollbar-none dark:border-neutral-800">
+117 -64
View File
@@ -1,85 +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;
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();
appDir: null, };
});
useEffect(() => { const download = async () => {
async function getInformation() { try {
const { platform, version } = await import('@tauri-apps/plugin-os'); const downloadPath = await downloadDir();
const { appConfigDir } = await import('@tauri-apps/api/path'); const fileName = `nostr_keys_${new Date().toISOString()}.txt`;
const filePath = await save({
const platformName = await platform(); defaultPath: downloadPath + '/' + fileName,
const osVersion = await version();
const appDir = await appConfigDir();
setDebugInfo({
os: platformName + ' ' + osVersion,
appDir: appDir,
}); });
} const nsec = await db.secureLoad(db.account.pubkey);
getInformation(); if (filePath) {
}, []); if (nsec) {
await writeTextFile(
filePath,
`Nostr account, generated by Lume (lume.nu)\nPublic key: ${db.account.id}\nPrivate key: ${nsec}`
);
} else {
await writeTextFile(
filePath,
`Nostr account, generated by Lume (lume.nu)\nPublic key: ${db.account.id}`
);
}
} // else { user cancel action }
} 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&apos;t panic, your account is safe.
{error.statusText || error.message} <br />
</p> Here are what things you can do:
</div> </h3>
<div className="mt-4">
<p className="font-medium text-neutral-600 dark:text-neutral-400">
Current location: {location.pathname}
</p>
<p className="font-medium text-neutral-600 dark:text-neutral-400">
Platform: {debugInfo.os}
</p>
</div>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex w-full flex-col gap-3">
<a <div className="flex items-center justify-between rounded-xl bg-blue-700 px-3 py-4">
href="https://github.com/luminous-devs/lume/issues/new" <div className="text-xl font-semibold text-white">
target="_blank" 1. Try to close and re-open the app
rel="noreferrer" </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" <button
> type="button"
Click here to report the issue on GitHub onClick={() => restart()}
</a> className="h-9 w-28 rounded-lg bg-blue-800 px-3 font-medium text-white hover:bg-blue-900"
<button >
type="button" Restart
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" </button>
> </div>
Reload app <div className="flex items-center justify-between rounded-xl bg-blue-700 px-3 py-4">
</button> <div className="text-xl font-semibold text-white">
<button 2. Backup Nostr account
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" <button
> type="button"
Reset app onClick={() => download()}
</button> 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&apos;s Devs
</div>
<a
href="https://github.com/luminous-devs/lume/issues/new"
target="_blank"
rel="noreferrer"
className="inline-flex h-9 w-28 items-center justify-center rounded-lg bg-blue-800 px-3 font-medium text-white hover:bg-blue-900"
>
Report
</a>
</div>
<div className="inline-flex h-16 items-center justify-center overflow-y-auto rounded-lg border border-dashed border-red-300 bg-blue-800 px-5">
<p className="select-text break-all text-red-400">
{error.statusText || error.message}
</p>
</div>
</div>
</div>
<div className="rounded-xl bg-blue-700 px-3 py-4">
<div className="flex w-full flex-col gap-1.5">
<div className="text-xl font-semibold text-white">
4. Use another Nostr client
</div>
<div className="select-text text-lg font-medium text-blue-300">
<p>
While waiting for Lume&apos;s Devs to release the bug fixes, you always can use
other Nostr clients with your account:
</p>
<div className="mt-2 flex flex-col gap-1 text-white">
<a 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>
+4 -31
View File
@@ -3,14 +3,7 @@ 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';
@@ -28,31 +21,11 @@ export function UserLatestPosts({ pubkey }: { pubkey: string }) {
(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]
@@ -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 = '';
@@ -2,34 +2,30 @@ import { useQuery } from '@tanstack/react-query';
import { useCallback, useRef, useState } from 'react'; import { useCallback, useRef, useState } from 'react';
import { VList, VListHandle } from 'virtua'; 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 { useStorage } from '@libs/storage/provider';
import { LoaderIcon } from '@shared/icons'; import { LoaderIcon } from '@shared/icons';
import { import {
GlobalArticlesWidget, ArticleWidget,
GlobalFilesWidget, FileWidget,
GlobalHashtagWidget, GroupWidget,
LocalArticlesWidget, HashtagWidget,
LocalFeedsWidget,
LocalFilesWidget,
LocalThreadWidget,
LocalUserWidget,
NewsfeedWidget, NewsfeedWidget,
NotificationWidget, NotificationWidget,
ThreadWidget,
ToggleWidgetList,
TopicWidget,
TrendingAccountsWidget, TrendingAccountsWidget,
TrendingNotesWidget, TrendingNotesWidget,
XfeedsWidget, UserWidget,
XhashtagWidget, WidgetList,
} from '@shared/widgets'; } from '@shared/widgets';
import { WidgetKinds } from '@stores/constants'; import { WIDGET_KIND } from '@stores/constants';
import { Widget } from '@utils/types'; import { Widget } from '@utils/types';
export function SpaceScreen() { export function HomeScreen() {
const ref = useRef<VListHandle>(null); const ref = useRef<VListHandle>(null);
const [selectedIndex, setSelectedIndex] = useState(-1); const [selectedIndex, setSelectedIndex] = useState(-1);
@@ -43,13 +39,13 @@ export function SpaceScreen() {
id: '9998', id: '9998',
title: 'Notification', title: 'Notification',
content: '', content: '',
kind: WidgetKinds.local.notification, kind: WIDGET_KIND.notification,
}, },
{ {
id: '9999', id: '9999',
title: 'Newsfeed', title: 'Newsfeed',
content: '', content: '',
kind: WidgetKinds.local.network, kind: WIDGET_KIND.newsfeed,
}, },
]; ];
@@ -63,36 +59,30 @@ export function SpaceScreen() {
const renderItem = useCallback((widget: Widget) => { const renderItem = useCallback((widget: Widget) => {
switch (widget.kind) { switch (widget.kind) {
case WidgetKinds.local.feeds: case WIDGET_KIND.notification:
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.local.notification:
return <NotificationWidget key={widget.id} />; return <NotificationWidget key={widget.id} />;
case WidgetKinds.local.network: case WIDGET_KIND.newsfeed:
return <NewsfeedWidget key={widget.id} />; 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: default:
return null; return null;
} }
@@ -100,7 +90,7 @@ export function SpaceScreen() {
if (status === 'pending') { if (status === 'pending') {
return ( return (
<div className="flex h-full w-full items-center justify-center"> <div className="flex h-full w-full items-center justify-center bg-white dark:bg-black">
<LoaderIcon className="h-5 w-5 animate-spin" /> <LoaderIcon className="h-5 w-5 animate-spin" />
</div> </div>
); );
@@ -108,14 +98,15 @@ export function SpaceScreen() {
return ( return (
<VList <VList
className="h-full w-full flex-nowrap overflow-x-auto !overflow-y-hidden scrollbar-none focus:outline-none"
horizontal
ref={ref} ref={ref}
className="h-full w-full flex-nowrap overflow-x-auto !overflow-y-hidden scrollbar-none focus:outline-none"
initialItemSize={420} initialItemSize={420}
tabIndex={0} tabIndex={0}
horizontal
onKeyDown={(e) => { onKeyDown={(e) => {
if (!ref.current) return; if (!ref.current) return;
switch (e.code) { switch (e.code) {
case 'ArrowUp':
case 'ArrowLeft': { case 'ArrowLeft': {
e.preventDefault(); e.preventDefault();
const prevIndex = Math.max(selectedIndex - 1, 0); const prevIndex = Math.max(selectedIndex - 1, 0);
@@ -126,6 +117,7 @@ export function SpaceScreen() {
}); });
break; break;
} }
case 'ArrowDown':
case 'ArrowRight': { case 'ArrowRight': {
e.preventDefault(); e.preventDefault();
const nextIndex = Math.min(selectedIndex + 1, data.length - 1); const nextIndex = Math.min(selectedIndex + 1, data.length - 1);
@@ -136,6 +128,8 @@ export function SpaceScreen() {
}); });
break; break;
} }
default:
break;
} }
}} }}
> >
+11 -4
View File
@@ -1,10 +1,11 @@
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';
import { EditorContent, FloatingMenu, useEditor } from '@tiptap/react'; import { EditorContent, FloatingMenu, useEditor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit'; import StarterKit from '@tiptap/starter-kit';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import { Markdown } from 'tiptap-markdown'; import { Markdown } from 'tiptap-markdown';
@@ -31,6 +32,7 @@ export function NewArticleScreen() {
const [summary, setSummary] = useState({ open: false, content: '' }); const [summary, setSummary] = useState({ open: false, content: '' });
const [cover, setCover] = useState(''); const [cover, setCover] = useState('');
const navigate = useNavigate();
const ident = useMemo(() => String(Date.now()), []); const ident = useMemo(() => String(Date.now()), []);
const editor = useEditor({ const editor = useEditor({
extensions: [ extensions: [
@@ -65,13 +67,15 @@ export function NewArticleScreen() {
const submit = async () => { const submit = async () => {
try { try {
if (!ndk.signer) return navigate('/new/privkey');
setLoading(true); setLoading(true);
// get markdown content // get markdown content
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 +89,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 +242,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 -3
View File
@@ -1,5 +1,3 @@
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';
@@ -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"
+4
View File
@@ -2,6 +2,7 @@ import { NDKEvent } from '@nostr-dev-kit/ndk';
import { message, open } from '@tauri-apps/plugin-dialog'; import { message, open } from '@tauri-apps/plugin-dialog';
import { readBinaryFile } from '@tauri-apps/plugin-fs'; import { readBinaryFile } from '@tauri-apps/plugin-fs';
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useNDK } from '@libs/ndk/provider'; import { useNDK } from '@libs/ndk/provider';
@@ -10,6 +11,7 @@ import { LoaderIcon } from '@shared/icons';
export function NewFileScreen() { export function NewFileScreen() {
const { ndk } = useNDK(); const { ndk } = useNDK();
const navigate = useNavigate();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [isPublish, setIsPublish] = useState(false); const [isPublish, setIsPublish] = useState(false);
@@ -84,6 +86,8 @@ export function NewFileScreen() {
const submit = async () => { const submit = async () => {
try { try {
if (!ndk.signer) return navigate('/new/privkey');
setIsPublish(true); setIsPublish(true);
const event = new NDKEvent(ndk); const event = new NDKEvent(ndk);
-77
View File
@@ -1,77 +0,0 @@
import { Link, NavLink, Outlet } from 'react-router-dom';
import { twMerge } from 'tailwind-merge';
import { WindowTitlebar } from 'tauri-controls';
import { useStorage } from '@libs/storage/provider';
import { ArrowLeftIcon } from '@shared/icons';
export function NewScreen() {
const { db } = useStorage();
return (
<div className="flex h-screen w-screen flex-col bg-neutral-50 dark:bg-neutral-950">
{db.platform !== 'macos' ? (
<WindowTitlebar />
) : (
<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="container mx-auto grid grid-cols-8 px-4">
<div className="col-span-1">
<Link
to="/"
className="inline-flex h-10 w-10 items-center justify-center rounded-lg bg-neutral-100 hover:bg-neutral-200 dark:bg-neutral-900"
>
<ArrowLeftIcon className="h-5 w-5" />
</Link>
</div>
<div className="relative col-span-6 flex flex-col">
<div className="mb-8 flex h-10 shrink-0 items-center gap-3">
<div className="flex h-10 items-center gap-2 rounded-lg bg-neutral-100 px-0.5 dark:bg-neutral-800">
<NavLink
to="/new/"
className={({ isActive }) =>
twMerge(
'inline-flex h-9 w-20 items-center justify-center rounded-lg text-sm font-medium',
isActive ? 'bg-white shadow dark:bg-black' : 'bg-transparent'
)
}
>
Post
</NavLink>
<NavLink
to="/new/article"
className={({ isActive }) =>
twMerge(
'inline-flex h-9 w-20 items-center justify-center rounded-lg text-sm font-medium',
isActive ? 'bg-white shadow dark:bg-black' : 'bg-transparent'
)
}
>
Article
</NavLink>
<NavLink
to="/new/file"
className={({ isActive }) =>
twMerge(
'inline-flex h-9 w-28 items-center justify-center rounded-lg text-sm font-medium',
isActive ? 'bg-white shadow dark:bg-black' : 'bg-transparent'
)
}
>
File Sharing
</NavLink>
</div>
</div>
<div className="h-full min-h-0 w-full">
<Outlet />
</div>
</div>
<div className="col-span-1" />
</div>
</div>
</div>
);
}
+43 -44
View File
@@ -6,7 +6,7 @@ import { EditorContent, useEditor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit'; import StarterKit from '@tiptap/starter-kit';
import { convert } from 'html-to-text'; import { convert } from 'html-to-text';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { MediaUploader, MentionPopup } from '@app/new/components'; import { MediaUploader, MentionPopup } from '@app/new/components';
@@ -16,12 +16,18 @@ 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();
const navigate = useNavigate();
const editor = useEditor({ const editor = useEditor({
extensions: [ extensions: [
StarterKit.configure(), StarterKit.configure(),
@@ -49,13 +55,9 @@ export function NewPostScreen() {
const submit = async () => { const submit = async () => {
try { try {
setLoading(true); if (!ndk.signer) return navigate('/new/privkey');
const reply = { setLoading(true);
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();
@@ -66,47 +68,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 +129,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">
+86
View File
@@ -0,0 +1,86 @@
import { NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import { getPublicKey, nip19 } from 'nostr-tools';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
export function NewPrivkeyScreen() {
const { db } = useStorage();
const { ndk } = useNDK();
const [nsec, setNsec] = useState('');
const navigate = useNavigate();
const save = async (content: string) => {
return await db.secureSave(db.account.pubkey, content);
};
const submit = async (isSave?: boolean) => {
try {
if (!nsec.startsWith('nsec1'))
return toast.info('You must enter a private key starts with nsec');
const decoded = nip19.decode(nsec);
if (decoded.type !== 'nsec') return toast.info('You must enter a valid nsec');
const privkey = decoded.data;
const pubkey = getPublicKey(privkey);
if (pubkey !== db.account.pubkey)
return toast.info(
'Your nsec is not match your current public key, please make sure you enter right nsec'
);
const signer = new NDKPrivateKeySigner(privkey);
ndk.signer = signer;
if (isSave) await save(privkey);
navigate(-1);
} catch (e) {
toast.error(e);
}
};
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mb-16 flex flex-col gap-3">
<h1 className="text-center font-semibold text-neutral-900 dark:text-neutral-100">
You need to provide private key to sign nostr event.
</h1>
<input
name="privkey"
placeholder="nsec..."
type="password"
value={nsec}
onChange={(e) => setNsec(e.target.value)}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
className="h-11 w-full rounded-lg bg-neutral-100 px-3 py-2 placeholder:text-neutral-500 dark:bg-neutral-900 dark:placeholder:text-neutral-400"
/>
<div className="mt-2 flex flex-col gap-2">
<button
type="button"
onClick={() => submit()}
className="inline-flex h-9 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600"
>
Submit
</button>
<button
type="button"
onClick={() => submit(true)}
className="inline-flex h-9 w-full shrink-0 items-center justify-center rounded-lg bg-neutral-100 font-medium text-neutral-900 hover:bg-neutral-200 dark:bg-neutral-900 dark:text-neutral-100 dark:hover:bg-neutral-800"
>
Submit and Save
</button>
</div>
</div>
</div>
);
}
+87 -67
View File
@@ -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 { id } = useParams(); const { id } = useParams();
const { status, data } = useEvent(id);
const navigate = useNavigate();
const replyRef = useRef(null);
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,69 +50,72 @@ 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)} className="inline-flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-100 dark:bg-neutral-900"
className="inline-flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-100 dark:bg-neutral-900" >
> <ArrowLeftIcon className="h-5 w-5" />
<ArrowLeftIcon className="h-5 w-5" /> </button>
</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"> type="button"
<button onClick={share}
type="button" className="inline-flex h-12 w-12 items-center justify-center rounded-t-xl"
onClick={share} >
className="inline-flex h-12 w-12 items-center justify-center rounded-t-xl" {isCopy ? (
> <CheckCircleIcon className="h-5 w-5 text-teal-500" />
{isCopy ? (
<CheckCircleIcon className="h-5 w-5 text-teal-500" />
) : (
<ShareIcon className="h-5 w-5" />
)}
</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="relative col-span-6 flex flex-col overflow-y-auto">
<div className="mx-auto w-full max-w-2xl">
{status === 'pending' ? (
<div className="px-3 py-1.5">Loading...</div>
) : ( ) : (
<div className="flex h-min w-full flex-col px-3"> <ShareIcon className="h-5 w-5" />
<div className="mb-3 border-b border-neutral-100 pb-3 dark:border-neutral-900"> )}
<User pubkey={data.pubkey} time={data.created_at} variant="thread" /> </button>
<div className="mt-3"> </div>
<ArticleDetailNote event={data} /> <div className="col-span-7 overflow-y-auto px-3 xl:col-span-8">
</div> {status === 'pending' ? (
<div className="mt-3"> <div className="px-3 py-1.5">Loading...</div>
<NoteActions id={id} pubkey={data.pubkey} extraButtons={false} /> ) : (
</div> <div className="flex flex-col gap-4">
<div className="flex flex-col gap-2 border-b border-neutral-100 pb-4 dark:border-neutral-900">
{metadata.image && (
<img
src={metadata.image}
alt={metadata.title}
className="h-auto w-full rounded-lg object-cover"
/>
)}
<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
<div ref={replyRef} className="px-3"> options={{
<div className="mb-3 border-b border-neutral-100 pb-3 dark:border-neutral-900"> overrides: {
<NoteReplyForm id={id} /> a: {
</div> props: {
<ReplyList id={id} /> 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>
<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">
<NoteReplyForm rootEvent={data} />
</div> </div>
<ReplyList eventId={id} />
</div> </div>
<div className="col-span-1" />
</div> </div>
); );
} }
+26 -14
View File
@@ -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} />;
} }
@@ -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>
+10 -41
View File
@@ -6,27 +6,20 @@ 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({
queryKey: ['relay-event'], queryKey: ['relay-events', relayUrl],
queryFn: 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[];
}, },
@@ -37,31 +30,11 @@ export function RelayEventList({ relayUrl }: { relayUrl: string }) {
(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,7 +42,7 @@ 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 === 'pending' ? ( {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">
@@ -78,13 +51,9 @@ export function RelayEventList({ relayUrl }: { relayUrl: string }) {
</div> </div>
</div> </div>
) : ( ) : (
<VList className="h-full scrollbar-none"> data.map((item) => renderItem(item))
<div className="h-10" />
{data.map((item) => renderItem(item))}
<div className="h-16" />
</VList>
)} )}
</div> </VList>
</div> </div>
); );
} }
+1 -1
View File
@@ -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]) => (
+1 -1
View File
@@ -36,7 +36,7 @@ export function UserRelay() {
{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 -2
View File
@@ -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}`}
+76
View File
@@ -0,0 +1,76 @@
import { getVersion } from '@tauri-apps/api/app';
import { relaunch } from '@tauri-apps/plugin-process';
import { Update, check } from '@tauri-apps/plugin-updater';
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { toast } from 'sonner';
export function AboutScreen() {
const [version, setVersion] = useState('');
const [newUpdate, setNewUpdate] = useState<Update>(null);
const checkUpdate = async () => {
const update = await check();
if (!update) toast.info('There is no update available');
setNewUpdate(update);
};
const installUpdate = async () => {
await newUpdate.downloadAndInstall();
await relaunch();
};
useEffect(() => {
async function loadVersion() {
const appVersion = await getVersion();
setVersion(appVersion);
}
loadVersion();
}, []);
return (
<div className="mx-auto w-full max-w-lg">
<div className="flex items-center justify-center gap-2">
<img src="/icon.png" alt="Lume's logo" className="w-16 shrink-0" />
<div>
<h1 className="text-xl font-semibold">Lume</h1>
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
Version {version}
</p>
</div>
</div>
<div className="mx-auto mt-4 flex w-full max-w-xs flex-col gap-2">
{!newUpdate ? (
<button
type="button"
onClick={() => checkUpdate()}
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-blue-500 text-sm font-medium text-white hover:bg-blue-600"
>
Check for update
</button>
) : (
<button
type="button"
onClick={() => installUpdate()}
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-blue-500 text-sm font-medium text-white hover:bg-blue-600"
>
Install {newUpdate.version}
</button>
)}
<Link
to="https://lume.nu"
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-neutral-100 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
Website
</Link>
<Link
to="https://github.com/luminous-devs/lume/issues"
className="inline-flex h-9 w-full items-center justify-center rounded-lg bg-neutral-100 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
Report a issue
</Link>
</div>
</div>
);
}
-138
View File
@@ -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>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { useStorage } from '@libs/storage/provider';
export function AdvancedSettingScreen() {
const { db } = useStorage();
const clearCache = async () => {
await db.clearCache();
};
return (
<div className="mx-auto w-full max-w-lg">
<div className="flex flex-col gap-6">
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-8">
<div className="w-24 shrink-0 text-end text-sm font-semibold">Caches</div>
<div className="text-sm">Use for boost up NDK</div>
</div>
<button
type="button"
onClick={() => clearCache()}
className="h-8 w-max rounded-lg bg-blue-500 px-3 text-sm font-medium text-white hover:bg-blue-600"
>
Clear
</button>
</div>
</div>
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
import { nip19 } from 'nostr-tools';
import { useEffect, useState } from 'react';
import { useStorage } from '@libs/storage/provider';
import { EyeOffIcon } from '@shared/icons';
export function BackupSettingScreen() {
const { db } = useStorage();
const [privkey, setPrivkey] = useState(null);
const [showPassword, setShowPassword] = useState(false);
const removePrivkey = async () => {
await db.secureRemove(db.account.pubkey);
};
useEffect(() => {
async function loadPrivkey() {
const key = await db.secureLoad(db.account.pubkey);
if (key) setPrivkey(key);
}
loadPrivkey();
}, []);
return (
<div className="mx-auto w-full max-w-lg">
<div className="mb-2 text-sm font-semibold">Private key</div>
<div>
{!privkey ? (
<div className="inline-flex h-24 w-full items-center justify-center rounded-lg bg-neutral-100 dark:bg-neutral-900">
You&apos;ve stored private key on Lume
</div>
) : (
<>
<div className="relative">
<input
readOnly
type={showPassword ? 'text' : 'password'}
value={nip19.nsecEncode(privkey)}
className="relative h-11 w-full resize-none rounded-lg bg-neutral-200 py-1 pl-3 pr-11 text-neutral-900 !outline-none placeholder:text-neutral-600 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder:text-neutral-400"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-1.5 top-1/2 inline-flex h-8 w-8 -translate-y-1/2 transform items-center justify-center rounded-lg bg-neutral-50 dark:bg-neutral-950"
>
<EyeOffIcon className="h-4 w-4" />
</button>
</div>
<button
type="button"
onClick={() => removePrivkey()}
className="mt-2 inline-flex h-9 w-full items-center justify-center gap-2 rounded-lg bg-red-200 px-6 font-medium text-red-500 hover:bg-red-500 hover:text-white focus:outline-none dark:hover:text-white"
>
Remove private key
</button>
</>
)}
</div>
</div>
);
}
-12
View File
@@ -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>
);
}
-40
View File
@@ -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>
);
}
-28
View File
@@ -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>
);
}
+61
View File
@@ -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>
);
}
+53
View File
@@ -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>
);
}
-15
View File
@@ -1,15 +0,0 @@
export function VersionSetting() {
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&apos;re using latest version
</span>
</div>
<div className="inline-flex items-center gap-2">
<span className="font-medium text-neutral-300">2</span>
</div>
</div>
);
}
+54
View File
@@ -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>
);
}
+41
View File
@@ -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>
);
}
+306
View File
@@ -0,0 +1,306 @@
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 { useNavigate } from 'react-router-dom';
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 navigate = useNavigate();
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 {
if (!ndk.signer) return navigate('/new/privkey');
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>
);
}
+269 -12
View File
@@ -1,17 +1,274 @@
import { AutoStartSetting } from '@app/settings/components/autoStart'; import * as Switch from '@radix-ui/react-switch';
import { DataPath } from '@app/settings/components/dataPath'; import { invoke } from '@tauri-apps/api/primitives';
import { VersionSetting } from '@app/settings/components/version'; import { getCurrent } from '@tauri-apps/api/window';
import { disable, enable, isEnabled } from '@tauri-apps/plugin-autostart';
import { isPermissionGranted, requestPermission } from '@tauri-apps/plugin-notification';
import { useEffect, useState } from 'react';
import { twMerge } from 'tailwind-merge';
import { useStorage } from '@libs/storage/provider';
import { DarkIcon, LightIcon, SystemModeIcon } from '@shared/icons';
export function GeneralSettingScreen() {
const { db } = useStorage();
const [settings, setSettings] = useState({
autoupdate: false,
autolaunch: false,
outbox: false,
media: true,
hashtag: true,
notification: true,
appearance: 'system',
});
const changeTheme = async (theme: 'light' | 'dark' | 'auto') => {
await invoke('plugin:theme|set_theme', { theme });
// update state
setSettings((prev) => ({ ...prev, appearance: theme }));
};
const toggleAutolaunch = async () => {
if (!settings.autolaunch) {
await enable();
// update state
setSettings((prev) => ({ ...prev, autolaunch: true }));
} else {
await disable();
// update state
setSettings((prev) => ({ ...prev, autolaunch: false }));
}
};
const toggleOutbox = async () => {
await db.createSetting('outbox', String(+!settings.outbox));
// update state
setSettings((prev) => ({ ...prev, outbox: !settings.outbox }));
};
const toggleMedia = async () => {
await db.createSetting('media', String(+!settings.media));
db.settings.media = !settings.media;
// update state
setSettings((prev) => ({ ...prev, media: !settings.media }));
};
const toggleHashtag = async () => {
await db.createSetting('hashtag', String(+!settings.hashtag));
db.settings.hashtag = !settings.hashtag;
// update state
setSettings((prev) => ({ ...prev, hashtag: !settings.hashtag }));
};
const toggleAutoupdate = async () => {
await db.createSetting('autoupdate', String(+!settings.autoupdate));
db.settings.autoupdate = !settings.autoupdate;
// update state
setSettings((prev) => ({ ...prev, autoupdate: !settings.autoupdate }));
};
const toggleNofitication = async () => {
if (settings.notification) return;
await requestPermission();
// update state
setSettings((prev) => ({ ...prev, notification: !settings.notification }));
};
useEffect(() => {
async function loadSettings() {
const theme = await getCurrent().theme();
setSettings((prev) => ({ ...prev, appearance: theme }));
const autostart = await isEnabled();
setSettings((prev) => ({ ...prev, autolaunch: autostart }));
const permissionGranted = await isPermissionGranted();
setSettings((prev) => ({ ...prev, notification: permissionGranted }));
const data = await db.getAllSettings();
if (!data) return;
data.forEach((item) => {
if (item.key === 'autoupdate')
setSettings((prev) => ({
...prev,
autoupdate: !!parseInt(item.value),
}));
if (item.key === 'outbox')
setSettings((prev) => ({
...prev,
outbox: !!parseInt(item.value),
}));
if (item.key === 'media')
setSettings((prev) => ({
...prev,
media: !!parseInt(item.value),
}));
if (item.key === 'hashtag')
setSettings((prev) => ({
...prev,
hashtag: !!parseInt(item.value),
}));
if (item.key === 'notification')
setSettings((prev) => ({
...prev,
notification: !!parseInt(item.value),
}));
});
}
loadSettings();
}, []);
export function GeneralSettingsScreen() {
return ( return (
<div className="h-full w-full px-3 pt-11"> <div className="mx-auto w-full max-w-lg">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-6">
<h1 className="text-xl font-semibold text-white">General</h1> <div className="flex w-full items-center justify-between">
<div className="w-full rounded-xl bg-neutral-400 dark:bg-neutral-600"> <div className="flex items-center gap-8">
<div className="flex h-full w-full flex-col divide-y divide-white/5"> <div className="w-24 shrink-0 text-end text-sm font-semibold">Updater</div>
<AutoStartSetting /> <div className="text-sm">Auto download new update at Login</div>
<DataPath /> </div>
<VersionSetting /> <Switch.Root
checked={settings.autoupdate}
onClick={() => toggleAutoupdate()}
className="relative h-7 w-12 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
>
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-8">
<div className="w-24 shrink-0 text-end text-sm font-semibold">Startup</div>
<div className="text-sm">Launch Lume at Login</div>
</div>
<Switch.Root
checked={settings.autolaunch}
onClick={() => toggleAutolaunch()}
className="relative h-7 w-12 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
>
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-8">
<div className="w-24 shrink-0 text-end text-sm font-semibold">Gossip</div>
<div className="text-sm">Use Outbox model</div>
</div>
<Switch.Root
checked={settings.outbox}
onClick={() => toggleOutbox()}
className="relative h-7 w-12 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
>
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-8">
<div className="w-24 shrink-0 text-end text-sm font-semibold">Media</div>
<div className="text-sm">Automatically load media</div>
</div>
<Switch.Root
checked={settings.media}
onClick={() => toggleMedia()}
className="relative h-7 w-12 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
>
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-8">
<div className="w-24 shrink-0 text-end text-sm font-semibold">Hashtag</div>
<div className="text-sm">Hide all hashtags in content</div>
</div>
<Switch.Root
checked={settings.hashtag}
onClick={() => toggleHashtag()}
className="relative h-7 w-12 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
>
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-8">
<div className="w-24 shrink-0 text-end text-sm font-semibold">
Notification
</div>
<div className="text-sm">Automatically send notification</div>
</div>
<Switch.Root
checked={settings.notification}
disabled={settings.notification}
onClick={() => toggleNofitication()}
className="relative h-7 w-12 cursor-default rounded-full bg-neutral-200 outline-none data-[state=checked]:bg-blue-500 dark:bg-neutral-800"
>
<Switch.Thumb className="block h-6 w-6 translate-x-0.5 rounded-full bg-white transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[19px]" />
</Switch.Root>
</div>
<div className="flex w-full items-start gap-8">
<div className="w-24 shrink-0 text-end text-sm font-semibold">Appearance</div>
<div className="flex flex-1 gap-6">
<button
type="button"
onClick={() => changeTheme('light')}
className="flex flex-col items-center justify-center gap-0.5"
>
<div
className={twMerge(
'inline-flex h-11 w-11 items-center justify-center rounded-lg',
settings.appearance === 'light'
? 'bg-blue-500 text-white'
: 'bg-neutral-100 dark:bg-neutral-900'
)}
>
<LightIcon className="h-5 w-5" />
</div>
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
Light
</p>
</button>
<button
type="button"
onClick={() => changeTheme('dark')}
className="flex flex-col items-center justify-center gap-0.5"
>
<div
className={twMerge(
'inline-flex h-11 w-11 items-center justify-center rounded-lg',
settings.appearance === 'dark'
? 'bg-blue-500 text-white'
: 'bg-neutral-100 dark:bg-neutral-900'
)}
>
<DarkIcon className="h-5 w-5" />
</div>
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
Dark
</p>
</button>
<button
type="button"
onClick={() => changeTheme('auto')}
className="flex flex-col items-center justify-center gap-0.5"
>
<div
className={twMerge(
'inline-flex h-11 w-11 items-center justify-center rounded-lg',
settings.appearance === 'auto'
? 'bg-blue-500 text-white'
: 'bg-neutral-100 dark:bg-neutral-900'
)}
>
<SystemModeIcon className="h-5 w-5" />
</div>
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
System
</p>
</button>
</div> </div>
</div> </div>
</div> </div>
+19
View File
@@ -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>
);
}
-118
View File
@@ -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>
);
}
-135
View File
@@ -1,135 +0,0 @@
import { useCallback } from 'react';
import {
ArticleIcon,
BellIcon,
FileIcon,
FollowsIcon,
GroupFeedsIcon,
HashtagIcon,
ThreadsIcon,
TrendingIcon,
} from '@shared/icons';
import { TitleBar } from '@shared/titleBar';
import { WidgetWrapper } from '@shared/widgets';
import { DefaultWidgets, WidgetKinds } from '@stores/constants';
import { useWidget } from '@utils/hooks/useWidget';
import { Widget, WidgetGroup, WidgetGroupItem } from '@utils/types';
export function WidgetList({ params }: { params: Widget }) {
const { addWidget, removeWidget } = useWidget();
const open = (item: WidgetGroupItem) => {
addWidget.mutate({ kind: item.kind, title: item.title, content: '' });
removeWidget.mutate(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">{row.title}</h3>
<div className="flex flex-col divide-y divide-neutral-200 overflow-hidden rounded-xl bg-neutral-100 dark:divide-neutral-800 dark:bg-neutral-900">
{row.data.map((item, index) => (
<button
onClick={() => open(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-lg">
<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-lg 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-600 dark:text-neutral-400">
{item.description}
</p>
</div>
</button>
))}
</div>
</div>
);
}, []);
return (
<WidgetWrapper>
<TitleBar id={params.id} title="Add widget" />
<div className="flex-1 overflow-y-auto pb-10 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-50 text-sm font-medium text-neutral-900 dark:bg-neutral-950 dark:text-neutral-100"
>
Build your own widget{' '}
<div className="-rotate-3 transform-gpu rounded-md border border-neutral-200 bg-neutral-100 px-1.5 py-1 dark:border-neutral-800 dark:bg-neutral-900">
<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>
</WidgetWrapper>
);
}
-418
View File
@@ -1,418 +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({
queryKey: ['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>
);
}
+32 -22
View File
@@ -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, useNavigate } 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';
@@ -21,12 +21,16 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
const { user } = useProfile(pubkey); const { user } = useProfile(pubkey);
const [followed, setFollowed] = useState(false); const [followed, setFollowed] = useState(false);
const navigate = useNavigate();
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 +44,14 @@ 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 }); if (!ndk.signer) return navigate('/new/privkey');
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 +89,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
alt={pubkey} src={user?.picture || user?.image}
className="h-14 w-14 rounded-lg ring-2 ring-neutral-100 dark:ring-neutral-900" alt={pubkey}
/> 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 +116,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 +159,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>
+15 -11
View File
@@ -1,4 +1,5 @@
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';
@@ -6,15 +7,22 @@ import { compactNumber } from '@utils/number';
export function UserStats({ pubkey }: { pubkey: string }) { export function UserStats({ pubkey }: { pubkey: string }) {
const { status, data } = useQuery({ const { status, data } = useQuery({
queryKey: ['user-metadata', pubkey], queryKey: ['user-stats', pubkey],
queryFn: async ({ signal }: { signal: AbortSignal }) => {
const res = await fetch(`https://api.nostr.band/v0/stats/profile/${pubkey}`, {
signal,
});
...async () => {
const res = await fetch(`https://api.nostr.band/v0/stats/profile/${pubkey}`);
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 === 'pending') { if (status === 'pending') {
@@ -33,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
@@ -41,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
@@ -49,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
@@ -59,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
+6 -34
View File
@@ -7,14 +7,7 @@ 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();
@@ -23,9 +16,9 @@ export function UserScreen() {
queryKey: ['user-feed', pubkey], queryKey: ['user-feed', pubkey],
queryFn: async () => { 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;
@@ -38,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]
@@ -70,7 +43,6 @@ 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">
+1
View File
@@ -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,
+14 -5
View File
@@ -4,6 +4,7 @@ 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 NDKCacheAdapterTauri from '@libs/ndk/cache';
import { useStorage } from '@libs/storage/provider'; import { useStorage } from '@libs/storage/provider';
@@ -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 NDKCacheAdapterTauri(db); 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();
+28 -8
View File
@@ -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
+35 -4
View File
@@ -20,11 +20,18 @@ export class LumeStorage {
public db: Database; public db: Database;
public account: Account | null; public account: Account | null;
public platform: Platform | null; public platform: Platform | null;
public settings: {
autoupdate: boolean;
outbox: boolean;
media: boolean;
hashtag: boolean;
};
constructor(sqlite: Database, platform: Platform) { constructor(sqlite: Database, platform: Platform) {
this.db = sqlite; this.db = sqlite;
this.account = null; this.account = null;
this.platform = platform; this.platform = platform;
this.settings = { autoupdate: false, outbox: false, media: true, hashtag: true };
} }
public async secureSave(key: string, value: string) { public async secureSave(key: string, value: string) {
@@ -406,7 +413,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);
} }
@@ -429,10 +436,28 @@ export class LumeStorage {
} }
public async createSetting(key: string, value: string) { public async createSetting(key: string, value: string) {
return await this.db.execute( const currentSetting = await this.getSettingValue(key);
'INSERT OR IGNORE INTO settings (key, value) VALUES ($1, $2);',
[key, value] if (!currentSetting)
return await this.db.execute(
'INSERT OR IGNORE INTO settings (key, value) VALUES ($1, $2);',
[key, value]
);
const currentValue = !!parseInt(currentSetting);
return await this.db.execute('UPDATE settings SET value = $1 WHERE key = $2;', [
+!currentValue,
key,
]);
}
public async getAllSettings() {
const results: { key: string; value: string }[] = await this.db.select(
'SELECT * FROM settings ORDER BY id DESC;'
); );
if (results.length < 1) return null;
return results;
} }
public async getSettingValue(key: string) { public async getSettingValue(key: string) {
@@ -444,6 +469,12 @@ export class LumeStorage {
return results[0].value; return results[0].value;
} }
public async clearCache() {
await this.db.execute('DELETE FROM ndk_events;');
await this.db.execute('DELETE FROM ndk_eventtags;');
await this.db.execute('DELETE FROM ndk_users;');
}
public async accountLogout() { public async accountLogout() {
// update current account status // update current account status
await this.db.execute("UPDATE accounts SET is_active = '0' WHERE id = $1;", [ await this.db.execute("UPDATE accounts SET is_active = '0' WHERE id = $1;", [
+60 -16
View File
@@ -1,15 +1,17 @@
import { appConfigDir } from '@tauri-apps/api/path';
import { message } from '@tauri-apps/plugin-dialog'; import { message } from '@tauri-apps/plugin-dialog';
import { platform } from '@tauri-apps/plugin-os'; 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;
} }
@@ -18,7 +20,7 @@ const StorageContext = createContext<StorageContext>({
db: undefined, db: undefined,
}); });
const StorageProvider = ({ children }: PropsWithChildren<object>) => { const StorageInstance = () => {
const [db, setDB] = useState<LumeStorage>(undefined); const [db, setDB] = useState<LumeStorage>(undefined);
const [isNewVersion, setIsNewVersion] = useState(false); const [isNewVersion, setIsNewVersion] = useState(false);
@@ -26,22 +28,41 @@ const StorageProvider = ({ children }: PropsWithChildren<object>) => {
try { try {
const sqlite = await Database.load('sqlite:lume_v2.db'); const sqlite = await Database.load('sqlite:lume_v2.db');
const platformName = await platform(); const platformName = await platform();
const dir = await appConfigDir();
const lumeStorage = new LumeStorage(sqlite, platformName); const lumeStorage = new LumeStorage(sqlite, platformName);
if (!lumeStorage.account) await lumeStorage.getActiveAccount(); if (!lumeStorage.account) await lumeStorage.getActiveAccount();
// check update const settings = await lumeStorage.getAllSettings();
const update = await check(); let autoUpdater = false;
if (update) {
setIsNewVersion(true);
await update.downloadAndInstall(); if (settings) {
await relaunch(); settings.forEach((item) => {
if (item.key === 'outbox') lumeStorage.settings.outbox = !!parseInt(item.value);
if (item.key === 'media') lumeStorage.settings.media = !!parseInt(item.value);
if (item.key === 'hashtag')
lumeStorage.settings.hashtag = !!parseInt(item.value);
if (item.key === 'autoupdate') {
if (parseInt(item.value)) autoUpdater = true;
}
});
}
if (autoUpdater) {
// check update
const update = await check();
// install new version
if (update) {
setIsNewVersion(true);
await update.downloadAndInstall();
await relaunch();
}
} }
setDB(lumeStorage); setDB(lumeStorage);
console.info(dir);
} catch (e) { } catch (e) {
await message(`Cannot initialize database: ${e}`, { await message(`Cannot initialize database: ${e}`, {
title: 'Lume', title: 'Lume',
@@ -54,21 +75,44 @@ const StorageProvider = ({ children }: PropsWithChildren<object>) => {
if (!db) initLumeStorage(); if (!db) initLumeStorage();
}, []); }, []);
if (!db) { return { db, isNewVersion };
};
const StorageProvider = ({ children }: PropsWithChildren<object>) => {
const { db, isNewVersion } = StorageInstance();
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...' : 'Starting...'}
</p> </p>
</div> </div>
</div> </div>
); );
}
return <StorageContext.Provider value={{ db }}>{children}</StorageContext.Provider>; return <StorageContext.Provider value={{ db }}>{children}</StorageContext.Provider>;
}; };
+4 -10
View File
@@ -1,6 +1,4 @@
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { QueryClient } from '@tanstack/react-query';
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { Toaster } from 'sonner'; import { Toaster } from 'sonner';
@@ -17,20 +15,16 @@ const queryClient = new QueryClient({
}, },
}); });
const persister = createSyncStoragePersister({
storage: window.localStorage,
});
const container = document.getElementById('root'); const container = document.getElementById('root');
const root = createRoot(container); const root = createRoot(container);
root.render( root.render(
<PersistQueryClientProvider client={queryClient} persistOptions={{ persister }}> <QueryClientProvider client={queryClient}>
<Toaster position="top-center" closeButton theme="system" />
<StorageProvider> <StorageProvider>
<NDKProvider> <NDKProvider>
<Toaster position="top-center" closeButton />
<App /> <App />
</NDKProvider> </NDKProvider>
</StorageProvider> </StorageProvider>
</PersistQueryClientProvider> </QueryClientProvider>
); );
+4 -4
View File
@@ -24,8 +24,8 @@ export function ActiveAccount() {
} }
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}
@@ -38,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>
); );
} }
+3 -22
View File
@@ -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
+24
View File
@@ -0,0 +1,24 @@
import { SVGProps } from 'react';
export function AdvancedSettingsIcon(
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>
) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
{...props}
>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
d="M13.75 7h-10m10 0a3.25 3.25 0 116.5 0 3.25 3.25 0 11-6.5 0zm6.5 10h-8m0 0a3.25 3.25 0 11-6.5 0m6.5 0a3.25 3.25 0 10-6.5 0m0 0h-2"
></path>
</svg>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { SVGProps } from 'react';
export function DarkIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
{...props}
>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
d="M21.248 11.811a6.5 6.5 0 01-9.06-9.06 9.25 9.25 0 109.06 9.06z"
></path>
</svg>
);
}
+6
View File
@@ -78,3 +78,9 @@ export * from './heading2';
export * from './heading3'; export * from './heading3';
export * from './bold'; export * from './bold';
export * from './italic'; export * from './italic';
export * from './user';
export * from './advancedSettings';
export * from './info';
export * from './light';
export * from './dark';
export * from './system';
+32
View File
@@ -0,0 +1,32 @@
import { SVGProps } from 'react';
export function InfoIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
{...props}
>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
d="M10.75 11H12v5.25M21.25 12a9.25 9.25 0 11-18.5 0 9.25 9.25 0 0118.5 0z"
></path>
<rect
width="1.25"
height="1.25"
x="11.375"
y="7.375"
fill="currentColor"
stroke="currentColor"
strokeWidth="0.25"
rx="0.625"
></rect>
</svg>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { SVGProps } from 'react';
export function LightIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
{...props}
>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
d="M11.998 3.29V1.769M5.84 18.158l-1.077 1.078m7.235 2.997v-1.524m7.235-15.944l-1.077 1.077M20.707 12h1.523m-4.074 6.159l1.077 1.077M1.766 12h1.523m1.474-7.235L5.84 5.842m9.87 2.446a5.25 5.25 0 11-7.424 7.424 5.25 5.25 0 017.424-7.424z"
></path>
</svg>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { SVGProps } from 'react';
export function SystemModeIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
{...props}
>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
d="M3.75 12.25V12a8.25 8.25 0 1116.5 0v.25m-18.5 4h20.5m-15.5 4h10.5"
></path>
</svg>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { SVGProps } from 'react';
export function UserIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
{...props}
>
<path
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="1.5"
d="M5.857 18.916C7.171 16.996 9.332 15.75 12 15.75c2.668 0 4.83 1.247 6.143 3.166m-12.286 0A9.215 9.215 0 0012 21.25c2.358 0 4.51-.882 6.143-2.334m-12.286 0a9.25 9.25 0 1112.286 0M15.25 10a3.25 3.25 0 11-6.5 0 3.25 3.25 0 016.5 0z"
></path>
</svg>
);
}
-32
View File
@@ -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' }}
/>
);
});
+80
View File
@@ -0,0 +1,80 @@
import { Link, NavLink, Outlet, useLocation } from 'react-router-dom';
import { twMerge } from 'tailwind-merge';
import { WindowTitlebar } from 'tauri-controls';
import { useStorage } from '@libs/storage/provider';
import { ArrowLeftIcon } from '@shared/icons';
export function NewLayout() {
const { db } = useStorage();
const location = useLocation();
return (
<div className="flex h-screen w-screen flex-col bg-neutral-50 dark:bg-neutral-950">
{db.platform !== 'macos' ? (
<WindowTitlebar />
) : (
<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="container mx-auto grid grid-cols-8 px-4">
<div className="col-span-1">
<Link
to="/"
className="inline-flex h-10 w-10 items-center justify-center rounded-lg bg-neutral-100 hover:bg-neutral-200 dark:bg-neutral-900"
>
<ArrowLeftIcon className="h-5 w-5" />
</Link>
</div>
<div className="relative col-span-6 flex flex-col">
<div className="mb-8 flex h-10 shrink-0 items-center gap-3">
{location.pathname !== '/new/privkey' ? (
<div className="flex h-10 items-center gap-2 rounded-lg bg-neutral-100 px-0.5 dark:bg-neutral-800">
<NavLink
to="/new/"
className={({ isActive }) =>
twMerge(
'inline-flex h-9 w-20 items-center justify-center rounded-lg text-sm font-medium',
isActive ? 'bg-white shadow dark:bg-black' : 'bg-transparent'
)
}
>
Post
</NavLink>
<NavLink
to="/new/article"
className={({ isActive }) =>
twMerge(
'inline-flex h-9 w-20 items-center justify-center rounded-lg text-sm font-medium',
isActive ? 'bg-white shadow dark:bg-black' : 'bg-transparent'
)
}
>
Article
</NavLink>
<NavLink
to="/new/file"
className={({ isActive }) =>
twMerge(
'inline-flex h-9 w-28 items-center justify-center rounded-lg text-sm font-medium',
isActive ? 'bg-white shadow dark:bg-black' : 'bg-transparent'
)
}
>
File Sharing
</NavLink>
</div>
) : null}
</div>
<div className="h-full min-h-0 w-full">
<Outlet />
</div>
</div>
<div className="col-span-1" />
</div>
</div>
</div>
);
}
-1
View File
@@ -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 />
+81 -34
View File
@@ -1,68 +1,115 @@
import { Link, NavLink, Outlet, ScrollRestoration } from 'react-router-dom'; import { Link, 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 {
AdvancedSettingsIcon,
ArrowLeftIcon,
InfoIcon,
SecureIcon,
SettingsIcon,
UserIcon,
} 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" />
)}
<div className="flex h-full min-h-0 w-full flex-col gap-8 overflow-y-auto pb-10">
<div className="flex h-20 w-full items-center justify-between border-b border-neutral-200 px-2 pb-2 dark:border-neutral-900">
<div>
<Link <Link
to="/" to="/"
className="inline-flex h-7 w-7 items-center justify-center rounded hover:bg-white/10" className="inline-flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-100 dark:bg-neutral-900"
> >
<ArrowLeftIcon className="h-4 w-4 text-neutral-600 dark:text-neutral-400" /> <ArrowLeftIcon className="h-5 w-5" />
</Link> </Link>
<h3 className="text-[11px] font-bold uppercase tracking-widest text-neutral-600 dark:text-neutral-400">
Settings
</h3>
</div> </div>
<div className="flex flex-col pr-2"> <div className="flex items-center gap-0.5">
<NavLink <NavLink
to="/settings/" to="/settings"
end
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"> <UserIcon 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'
: ''
)
}
>
<AdvancedSettingsIcon 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'
: ''
)
}
>
<InfoIcon className="h-6 w-6" />
<p className="text-sm font-medium">About</p>
</NavLink> </NavLink>
</div> </div>
<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>
); );
+2 -2
View File
@@ -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;
+1 -31
View File
@@ -2,14 +2,7 @@ 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 { compactNumber } from '@utils/number'; import { compactNumber } from '@utils/number';
@@ -87,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
+16 -10
View File
@@ -22,7 +22,7 @@ export const NIP05 = memo(function NIP05({
}) { }) {
const { status, data } = useQuery({ const { status, data } = useQuery({
queryKey: ['nip05', nip05], queryKey: ['nip05', nip05],
queryFn: 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;
@@ -58,15 +60,19 @@ export const NIP05 = memo(function NIP05({
} }
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 ? ( <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-3 w-3 text-green-500" /> <VerifiedIcon className="h-4 w-4" />
) : ( Verified
<UnverifiedIcon className="h-3 w-3 text-red-500" /> </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>
); );
}); });
+49 -24
View File
@@ -1,53 +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 { FocusIcon } from '@shared/icons'; import { FocusIcon, ReplyIcon } 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 } from '@stores/constants'; import { WIDGET_KIND } from '@stores/constants';
import { useWidget } from '@utils/hooks/useWidget'; 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 { addWidget } = useWidget(); const { addWidget } = useWidget();
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={() =>
addWidget.mutate({ 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>
@@ -59,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>
); );
+4 -12
View File
@@ -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>
+14 -14
View File
@@ -1,6 +1,8 @@
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 { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { useNDK } from '@libs/ndk/provider'; import { useNDK } from '@libs/ndk/provider';
@@ -29,31 +31,29 @@ 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);
const { ndk } = useNDK();
const navigate = useNavigate();
const getReactionImage = (content: string) => { const getReactionImage = (content: string) => {
const reaction: { img: string } = REACTIONS.find((el) => el.content === content); const reaction: { img: string } = REACTIONS.find((el) => el.content === content);
return reaction.img; return reaction.img;
}; };
const react = async (content: string) => { const react = async (content: string) => {
setReaction(content); try {
if (!ndk.signer) return navigate('/new/privkey');
const event = new NDKEvent(ndk); setReaction(content);
event.content = content;
event.kind = NDKKind.Reaction;
event.tags = [
['e', id],
['p', pubkey],
];
const publishedRelays = await event.publish(); // react
if (publishedRelays) { await event.react(content);
setOpen(false); setOpen(false);
} catch (e) {
toast.error(e);
} }
}; };
-45
View File
@@ -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>
);
}
+14 -17
View File
@@ -1,7 +1,8 @@
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 { useNavigate } from 'react-router-dom';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
@@ -9,33 +10,29 @@ 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 { ndk } = useNDK();
const navigate = useNavigate();
const submit = async () => { const submit = async () => {
setIsLoading(true); try {
if (!ndk.signer) return navigate('/new/privkey');
const tags = [ setIsLoading(true);
['e', id, relayUrls[0], 'root'],
['p', pubkey],
];
const event = new NDKEvent(ndk); // repsot
event.content = ''; await event.repost(true);
event.kind = NDKKind.Repost;
event.tags = tags;
const publishedRelays = await event.publish(); // reset state
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');
} }
+35 -35
View File
@@ -1,24 +1,28 @@
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/primitives'; 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';
import CurrencyInput from 'react-currency-input-field'; import CurrencyInput from 'react-currency-input-field';
import { useNavigate } from 'react-router-dom';
import { useNDK } from '@libs/ndk/provider';
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 navigate = useNavigate();
const { data: event } = useEvent(id);
const { ndk } = useNDK();
const { user } = useProfile(event.pubkey);
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 +32,12 @@ 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 {
if (!ndk.signer) return navigate('/new/privkey');
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 +88,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 +109,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 +135,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 +145,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 +189,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&apos;re using nostr wallet connect You&apos;re using nostr wallet connect
</p> </p>
</span> </span>
@@ -218,9 +220,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 +230,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 />
+75
View File
@@ -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);
+16 -73
View File
@@ -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 === 'pending') {
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> </div>
</> <User
pubkey={data.pubkey}
time={data.created_at}
variant="childnote"
subtext={isRoot ? 'posted' : 'replied'}
/>
</div>
); );
} }

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