3
.eslintignore
Normal file
3
.eslintignore
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
/**/node_modules/*
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Lume</title>
|
<title>Lume</title>
|
||||||
</head>
|
</head>
|
||||||
<body class="cursor-default select-none overflow-hidden font-sans antialiased h-screen w-screen dark:bg-black dark:text-zinc-100">
|
<body class="relative cursor-default select-none overflow-hidden font-sans antialiased h-screen w-screen text-white">
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
64
package.json
64
package.json
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "lume",
|
"name": "lume",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.1",
|
"version": "1.2.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
@@ -17,16 +17,34 @@
|
|||||||
"**/*.{ts, tsx, css, md, html, json}": "prettier --cache --write"
|
"**/*.{ts, tsx, css, md, html, json}": "prettier --cache --write"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/react": "^1.7.15",
|
"@ctrl/magnet-link": "^3.1.2",
|
||||||
"@nostr-dev-kit/ndk": "^0.7.7",
|
"@headlessui/react": "^1.7.16",
|
||||||
|
"@nostr-dev-kit/ndk": "^0.8.11",
|
||||||
"@nostr-fetch/adapter-ndk": "^0.11.0",
|
"@nostr-fetch/adapter-ndk": "^0.11.0",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.0.4",
|
||||||
|
"@radix-ui/react-collapsible": "^1.0.3",
|
||||||
"@radix-ui/react-dialog": "^1.0.4",
|
"@radix-ui/react-dialog": "^1.0.4",
|
||||||
"@radix-ui/react-popover": "^1.0.6",
|
"@radix-ui/react-popover": "^1.0.6",
|
||||||
"@radix-ui/react-tooltip": "^1.0.6",
|
"@radix-ui/react-tooltip": "^1.0.6",
|
||||||
"@tanstack/react-query": "^4.32.0",
|
"@tanstack/react-query": "^4.32.6",
|
||||||
"@tanstack/react-query-devtools": "^4.32.0",
|
"@tanstack/react-query-devtools": "^4.32.6",
|
||||||
"@tanstack/react-virtual": "3.0.0-beta.54",
|
"@tanstack/react-virtual": "3.0.0-beta.54",
|
||||||
"@tauri-apps/api": "^1.4.0",
|
"@tauri-apps/api": "2.0.0-alpha.5",
|
||||||
|
"@tauri-apps/plugin-app": "github:tauri-apps/tauri-plugin-app#v2",
|
||||||
|
"@tauri-apps/plugin-autostart": "github:tauri-apps/tauri-plugin-autostart#v2",
|
||||||
|
"@tauri-apps/plugin-clipboard-manager": "github:tauri-apps/tauri-plugin-clipboard-manager#v2",
|
||||||
|
"@tauri-apps/plugin-dialog": "github:tauri-apps/tauri-plugin-dialog#v2",
|
||||||
|
"@tauri-apps/plugin-fs": "github:tauri-apps/tauri-plugin-fs#v2",
|
||||||
|
"@tauri-apps/plugin-http": "github:tauri-apps/tauri-plugin-http#v2",
|
||||||
|
"@tauri-apps/plugin-notification": "github:tauri-apps/tauri-plugin-notification#v2",
|
||||||
|
"@tauri-apps/plugin-os": "github:tauri-apps/tauri-plugin-os#v2",
|
||||||
|
"@tauri-apps/plugin-process": "github:tauri-apps/tauri-plugin-process#v2",
|
||||||
|
"@tauri-apps/plugin-shell": "github:tauri-apps/tauri-plugin-shell#v2",
|
||||||
|
"@tauri-apps/plugin-sql": "github:tauri-apps/tauri-plugin-sql#v2",
|
||||||
|
"@tauri-apps/plugin-store": "github:tauri-apps/tauri-plugin-store#v2",
|
||||||
|
"@tauri-apps/plugin-stronghold": "github:tauri-apps/tauri-plugin-stronghold#v2",
|
||||||
|
"@tauri-apps/plugin-upload": "github:tauri-apps/tauri-plugin-upload#v2",
|
||||||
|
"@tauri-apps/plugin-window": "2.0.0-alpha.0",
|
||||||
"@tiptap/extension-image": "^2.0.4",
|
"@tiptap/extension-image": "^2.0.4",
|
||||||
"@tiptap/extension-mention": "^2.0.4",
|
"@tiptap/extension-mention": "^2.0.4",
|
||||||
"@tiptap/extension-placeholder": "^2.0.4",
|
"@tiptap/extension-placeholder": "^2.0.4",
|
||||||
@@ -34,55 +52,52 @@
|
|||||||
"@tiptap/react": "^2.0.4",
|
"@tiptap/react": "^2.0.4",
|
||||||
"@tiptap/starter-kit": "^2.0.4",
|
"@tiptap/starter-kit": "^2.0.4",
|
||||||
"@tiptap/suggestion": "^2.0.4",
|
"@tiptap/suggestion": "^2.0.4",
|
||||||
|
"@void-cat/api": "^1.0.7",
|
||||||
"cheerio": "1.0.0-rc.12",
|
"cheerio": "1.0.0-rc.12",
|
||||||
"dayjs": "^1.11.9",
|
"dayjs": "^1.11.9",
|
||||||
"destr": "^1.2.2",
|
"destr": "^1.2.2",
|
||||||
"framer-motion": "^10.13.1",
|
|
||||||
"get-urls": "^11.0.0",
|
"get-urls": "^11.0.0",
|
||||||
"html-to-text": "^9.0.5",
|
"html-to-text": "^9.0.5",
|
||||||
"immer": "^10.0.2",
|
"immer": "^10.0.2",
|
||||||
"light-bolt11-decoder": "^3.0.0",
|
"light-bolt11-decoder": "^3.0.0",
|
||||||
|
"lru-cache": "^10.0.1",
|
||||||
"nostr-fetch": "^0.12.2",
|
"nostr-fetch": "^0.12.2",
|
||||||
"nostr-tools": "^1.13.1",
|
"nostr-tools": "^1.14.0",
|
||||||
"qrcode.react": "^3.1.0",
|
"qrcode.react": "^3.1.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-hook-form": "^7.45.2",
|
"react-hook-form": "^7.45.4",
|
||||||
"react-hotkeys-hook": "^4.4.1",
|
"react-hotkeys-hook": "^4.4.1",
|
||||||
"react-markdown": "^8.0.7",
|
"react-markdown": "^8.0.7",
|
||||||
"react-player": "^2.12.0",
|
"react-player": "^2.12.0",
|
||||||
"react-router-dom": "^6.14.2",
|
"react-router-dom": "^6.15.0",
|
||||||
"react-string-replace": "^1.1.1",
|
"react-string-replace": "^1.1.1",
|
||||||
"react-virtuoso": "^4.4.2",
|
"react-virtuoso": "^4.5.0",
|
||||||
"remark-gfm": "^3.0.1",
|
"remark-gfm": "^3.0.1",
|
||||||
"tailwind-merge": "^1.14.0",
|
|
||||||
"tauri-plugin-autostart-api": "github:tauri-apps/tauri-plugin-autostart#v1",
|
|
||||||
"tauri-plugin-sql-api": "github:tauri-apps/tauri-plugin-sql",
|
|
||||||
"tauri-plugin-stronghold-api": "github:tauri-apps/tauri-plugin-stronghold#v1",
|
|
||||||
"tauri-plugin-upload-api": "github:tauri-apps/tauri-plugin-upload#v1",
|
|
||||||
"tippy.js": "^6.3.7",
|
"tippy.js": "^6.3.7",
|
||||||
"zustand": "^4.3.9"
|
"zustand": "^4.4.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/typography": "^0.5.9",
|
"@tailwindcss/typography": "^0.5.9",
|
||||||
"@tauri-apps/cli": "^1.4.0",
|
"@tauri-apps/cli": "2.0.0-alpha.10",
|
||||||
"@trivago/prettier-plugin-sort-imports": "^4.2.0",
|
"@trivago/prettier-plugin-sort-imports": "^4.2.0",
|
||||||
"@types/html-to-text": "^9.0.1",
|
"@types/html-to-text": "^9.0.1",
|
||||||
"@types/node": "^18.17.1",
|
"@types/node": "^18.17.5",
|
||||||
"@types/react": "^18.2.17",
|
"@types/react": "^18.2.20",
|
||||||
"@types/react-dom": "^18.2.7",
|
"@types/react-dom": "^18.2.7",
|
||||||
"@types/youtube-player": "^5.5.7",
|
"@types/youtube-player": "^5.5.7",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
||||||
"@typescript-eslint/parser": "^5.62.0",
|
"@typescript-eslint/parser": "^5.62.0",
|
||||||
"@vitejs/plugin-react-swc": "^3.3.2",
|
"@vitejs/plugin-react-swc": "^3.3.2",
|
||||||
"autoprefixer": "^10.4.14",
|
"autoprefixer": "^10.4.14",
|
||||||
|
"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.45.0",
|
"eslint": "^8.47.0",
|
||||||
"eslint-config-prettier": "^8.8.0",
|
"eslint-config-prettier": "^8.10.0",
|
||||||
"eslint-plugin-jsx-a11y": "^6.7.1",
|
"eslint-plugin-jsx-a11y": "^6.7.1",
|
||||||
"eslint-plugin-react": "^7.33.0",
|
"eslint-plugin-react": "^7.33.1",
|
||||||
"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": "^13.2.3",
|
"lint-staged": "^13.2.3",
|
||||||
@@ -90,9 +105,10 @@
|
|||||||
"prettier": "^2.8.8",
|
"prettier": "^2.8.8",
|
||||||
"prettier-plugin-tailwindcss": "^0.3.0",
|
"prettier-plugin-tailwindcss": "^0.3.0",
|
||||||
"prop-types": "^15.8.1",
|
"prop-types": "^15.8.1",
|
||||||
|
"tailwind-merge": "^1.14.0",
|
||||||
"tailwindcss": "^3.3.3",
|
"tailwindcss": "^3.3.3",
|
||||||
"typescript": "^4.9.5",
|
"typescript": "^4.9.5",
|
||||||
"vite": "^4.4.7",
|
"vite": "^4.4.9",
|
||||||
"vite-plugin-top-level-await": "^1.3.1",
|
"vite-plugin-top-level-await": "^1.3.1",
|
||||||
"vite-tsconfig-paths": "^4.2.0"
|
"vite-tsconfig-paths": "^4.2.0"
|
||||||
}
|
}
|
||||||
|
|||||||
1810
pnpm-lock.yaml
generated
1810
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
BIN
public/clapping_hands.png
Normal file
BIN
public/clapping_hands.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 398 KiB |
BIN
public/clown_face.png
Normal file
BIN
public/clown_face.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/crying_face.png
Normal file
BIN
public/crying_face.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
BIN
public/face_with_open_mouth.png
Normal file
BIN
public/face_with_open_mouth.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
BIN
public/face_with_tongue.png
Normal file
BIN
public/face_with_tongue.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 986 KiB |
BIN
public/lume.png
Normal file
BIN
public/lume.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 55 KiB |
1463
src-tauri/Cargo.lock
generated
1463
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,46 +1,42 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lume"
|
name = "lume"
|
||||||
version = "1.1.1"
|
version = "1.2.0"
|
||||||
description = "nostr client"
|
description = "nostr client"
|
||||||
authors = ["Ren Amamiya"]
|
authors = ["Ren Amamiya"]
|
||||||
license = ""
|
license = ""
|
||||||
repository = ""
|
repository = ""
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.57"
|
rust-version = "1.71"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "1.2", features = [] }
|
tauri-build = { version = "2.0.0-alpha.6", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
tauri = { version = "1.2", features = [
|
tauri = { version = "2.0.0-alpha.10", features = [
|
||||||
"fs-remove-file",
|
"macos-private-api",
|
||||||
"fs-write-file",
|
|
||||||
"window-create",
|
|
||||||
"path-all",
|
|
||||||
"fs-read-dir",
|
|
||||||
"fs-read-file",
|
|
||||||
"clipboard-read-text",
|
|
||||||
"clipboard-write-text",
|
|
||||||
"dialog-open",
|
|
||||||
"http-all",
|
|
||||||
"http-multipart",
|
|
||||||
"notification-all",
|
|
||||||
"os-all",
|
|
||||||
"process-relaunch",
|
|
||||||
"shell-open",
|
|
||||||
"system-tray",
|
"system-tray",
|
||||||
"updater",
|
|
||||||
"window-close",
|
|
||||||
"window-start-dragging",
|
|
||||||
] }
|
] }
|
||||||
tauri-plugin-single-instance = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
|
tauri-plugin-single-instance = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
||||||
tauri-plugin-autostart = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
|
tauri-plugin-autostart = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
||||||
tauri-plugin-stronghold = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
|
tauri-plugin-stronghold = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
||||||
tauri-plugin-upload = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
|
tauri-plugin-upload = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
||||||
|
tauri-plugin-updater = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
||||||
|
tauri-plugin-dialog = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
||||||
|
tauri-plugin-http = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
||||||
|
tauri-plugin-fs = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
||||||
|
tauri-plugin-clipboard-manager = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
||||||
|
tauri-plugin-notification = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
||||||
|
tauri-plugin-app = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
||||||
|
tauri-plugin-process = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
||||||
|
tauri-plugin-os = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
||||||
|
tauri-plugin-window = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
||||||
|
tauri-plugin-store = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
||||||
|
tauri-plugin-shell = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
||||||
|
window-vibrancy = { git = "https://github.com/tauri-apps/window-vibrancy", branch = "dev" }
|
||||||
sqlx-cli = { version = "0.7.0", default-features = false, features = [
|
sqlx-cli = { version = "0.7.0", default-features = false, features = [
|
||||||
"sqlite",
|
"sqlite",
|
||||||
] }
|
] }
|
||||||
@@ -49,13 +45,9 @@ rand = "0.8.5"
|
|||||||
|
|
||||||
[dependencies.tauri-plugin-sql]
|
[dependencies.tauri-plugin-sql]
|
||||||
git = "https://github.com/tauri-apps/plugins-workspace"
|
git = "https://github.com/tauri-apps/plugins-workspace"
|
||||||
branch = "v1"
|
branch = "v2"
|
||||||
features = ["sqlite"]
|
features = ["sqlite"]
|
||||||
|
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
|
||||||
objc = "0.2.7"
|
|
||||||
cocoa = "0.24.1"
|
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# by default Tauri runs in production mode
|
# by default Tauri runs in production mode
|
||||||
# when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` is an URL
|
# when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` is an URL
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Add migration script here
|
||||||
|
ALTER TABLE accounts ADD network JSON;
|
||||||
10
src-tauri/migrations/20230808085847_add_relays_table.sql
Normal file
10
src-tauri/migrations/20230808085847_add_relays_table.sql
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
-- Add migration script here
|
||||||
|
CREATE TABLE
|
||||||
|
relays (
|
||||||
|
id INTEGER NOT NULL PRIMARY KEY,
|
||||||
|
account_id INTEGER NOT NULL,
|
||||||
|
relay TEXT NOT NULL,
|
||||||
|
purpose TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (account_id) REFERENCES accounts (id)
|
||||||
|
);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Add migration script here
|
||||||
|
ALTER TABLE blocks
|
||||||
|
RENAME TO widgets;
|
||||||
@@ -3,19 +3,11 @@
|
|||||||
windows_subsystem = "windows"
|
windows_subsystem = "windows"
|
||||||
)]
|
)]
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
#[macro_use]
|
|
||||||
extern crate objc;
|
|
||||||
|
|
||||||
// use rand::distributions::{Alphanumeric, DistString};
|
// use rand::distributions::{Alphanumeric, DistString};
|
||||||
use tauri::{Manager, WindowEvent};
|
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 window_vibrancy::{apply_mica, apply_vibrancy, NSVisualEffectMaterial};
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
use window_ext::WindowExt;
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
mod window_ext;
|
|
||||||
|
|
||||||
#[derive(Clone, serde::Serialize)]
|
#[derive(Clone, serde::Serialize)]
|
||||||
struct Payload {
|
struct Payload {
|
||||||
@@ -23,31 +15,18 @@ struct Payload {
|
|||||||
cwd: String,
|
cwd: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn close_splashscreen(window: tauri::Window) {
|
||||||
|
// Close splashscreen
|
||||||
|
if let Some(splashscreen) = window.get_window("splashscreen") {
|
||||||
|
splashscreen.close().unwrap();
|
||||||
|
}
|
||||||
|
// Show main window
|
||||||
|
window.get_window("main").unwrap().show().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.setup(|app| {
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
let main_window = app.get_window("main").unwrap();
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
main_window.position_traffic_lights(13.0, 17.0); // set inset for traffic lights (macos)
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
.on_window_event(|e| {
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
let apply_offset = || {
|
|
||||||
let win = e.window();
|
|
||||||
// keep inset for traffic lights when window resize (macos)
|
|
||||||
win.position_traffic_lights(13.0, 17.0);
|
|
||||||
};
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
match e.event() {
|
|
||||||
WindowEvent::Resized(..) => apply_offset(),
|
|
||||||
WindowEvent::ThemeChanged(..) => apply_offset(),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.plugin(
|
.plugin(
|
||||||
tauri_plugin_sql::Builder::default()
|
tauri_plugin_sql::Builder::default()
|
||||||
.add_migrations(
|
.add_migrations(
|
||||||
@@ -119,6 +98,24 @@ fn main() {
|
|||||||
sql: include_str!("../migrations/20230725010250_update_default_relays.sql"),
|
sql: include_str!("../migrations/20230725010250_update_default_relays.sql"),
|
||||||
kind: MigrationKind::Up,
|
kind: MigrationKind::Up,
|
||||||
},
|
},
|
||||||
|
Migration {
|
||||||
|
version: 20230804083544,
|
||||||
|
description: "add network to accounts",
|
||||||
|
sql: include_str!("../migrations/20230804083544_add_network_to_account.sql"),
|
||||||
|
kind: MigrationKind::Up,
|
||||||
|
},
|
||||||
|
Migration {
|
||||||
|
version: 20230808085847,
|
||||||
|
description: "add relays",
|
||||||
|
sql: include_str!("../migrations/20230808085847_add_relays_table.sql"),
|
||||||
|
kind: MigrationKind::Up,
|
||||||
|
},
|
||||||
|
Migration {
|
||||||
|
version: 20230811074423,
|
||||||
|
description: "rename blocks to widgets",
|
||||||
|
sql: include_str!("../migrations/20230811074423_rename_blocks_to_widgets.sql"),
|
||||||
|
kind: MigrationKind::Up,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.build(),
|
.build(),
|
||||||
@@ -156,7 +153,33 @@ fn main() {
|
|||||||
.emit_all("single-instance", Payload { args: argv, cwd })
|
.emit_all("single-instance", Payload { args: argv, cwd })
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}))
|
}))
|
||||||
|
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||||
.plugin(tauri_plugin_upload::init())
|
.plugin(tauri_plugin_upload::init())
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.plugin(tauri_plugin_http::init())
|
||||||
|
.plugin(tauri_plugin_fs::init())
|
||||||
|
.plugin(tauri_plugin_clipboard_manager::init())
|
||||||
|
.plugin(tauri_plugin_notification::init())
|
||||||
|
.plugin(tauri_plugin_app::init())
|
||||||
|
.plugin(tauri_plugin_process::init())
|
||||||
|
.plugin(tauri_plugin_os::init())
|
||||||
|
.plugin(tauri_plugin_window::init())
|
||||||
|
.plugin(tauri_plugin_store::Builder::default().build())
|
||||||
|
.plugin(tauri_plugin_shell::init())
|
||||||
|
.setup(|app| {
|
||||||
|
let window = app.get_window("main").unwrap();
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
apply_vibrancy(&window, NSVisualEffectMaterial::HudWindow, None, None)
|
||||||
|
.expect("Unsupported platform! 'apply_vibrancy' is only supported on macOS");
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
apply_mica(&window, None, None)
|
||||||
|
.expect("Unsupported platform! 'apply_blur' is only supported on Windows");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.invoke_handler(tauri::generate_handler![close_splashscreen])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
use tauri::{Runtime, Window};
|
|
||||||
|
|
||||||
pub trait WindowExt {
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
fn set_transparent_titlebar(&self, transparent: bool);
|
|
||||||
fn position_traffic_lights(&self, x: f64, y: f64);
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<R: Runtime> WindowExt for Window<R> {
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
fn set_transparent_titlebar(&self, transparent: bool) {
|
|
||||||
use cocoa::appkit::{NSWindow, NSWindowTitleVisibility};
|
|
||||||
|
|
||||||
let window = self.ns_window().unwrap() as cocoa::base::id;
|
|
||||||
|
|
||||||
unsafe {
|
|
||||||
window.setTitleVisibility_(NSWindowTitleVisibility::NSWindowTitleHidden);
|
|
||||||
|
|
||||||
if transparent {
|
|
||||||
window.setTitlebarAppearsTransparent_(cocoa::base::YES);
|
|
||||||
} else {
|
|
||||||
window.setTitlebarAppearsTransparent_(cocoa::base::NO);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
fn position_traffic_lights(&self, x: f64, y: f64) {
|
|
||||||
use cocoa::appkit::{NSView, NSWindow, NSWindowButton};
|
|
||||||
use cocoa::foundation::NSRect;
|
|
||||||
|
|
||||||
let window = self.ns_window().unwrap() as cocoa::base::id;
|
|
||||||
|
|
||||||
unsafe {
|
|
||||||
let close = window.standardWindowButton_(NSWindowButton::NSWindowCloseButton);
|
|
||||||
let miniaturize = window.standardWindowButton_(NSWindowButton::NSWindowMiniaturizeButton);
|
|
||||||
let zoom = window.standardWindowButton_(NSWindowButton::NSWindowZoomButton);
|
|
||||||
|
|
||||||
let title_bar_container_view = close.superview().superview();
|
|
||||||
|
|
||||||
let close_rect: NSRect = msg_send![close, frame];
|
|
||||||
let button_height = close_rect.size.height;
|
|
||||||
|
|
||||||
let title_bar_frame_height = button_height + y;
|
|
||||||
let mut title_bar_rect = NSView::frame(title_bar_container_view);
|
|
||||||
title_bar_rect.size.height = title_bar_frame_height;
|
|
||||||
title_bar_rect.origin.y = NSView::frame(window).size.height - title_bar_frame_height;
|
|
||||||
let _: () = msg_send![title_bar_container_view, setFrame: title_bar_rect];
|
|
||||||
|
|
||||||
let window_buttons = vec![close, miniaturize, zoom];
|
|
||||||
let space_between = NSView::frame(miniaturize).origin.x - NSView::frame(close).origin.x;
|
|
||||||
|
|
||||||
for (i, button) in window_buttons.into_iter().enumerate() {
|
|
||||||
let mut rect: NSRect = NSView::frame(button);
|
|
||||||
rect.origin.x = x + (i as f64 * space_between);
|
|
||||||
button.setFrameOrigin(rect.origin);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,148 +1,125 @@
|
|||||||
{
|
{
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm dev",
|
"beforeBuildCommand": "pnpm build",
|
||||||
"beforeBuildCommand": "pnpm build",
|
"beforeDevCommand": "pnpm dev",
|
||||||
"devPath": "http://localhost:3000",
|
"devPath": "http://localhost:3000",
|
||||||
"distDir": "../dist",
|
"distDir": "../dist",
|
||||||
"withGlobalTauri": true
|
"withGlobalTauri": true
|
||||||
},
|
},
|
||||||
"package": {
|
"package": {
|
||||||
"productName": "Lume",
|
"productName": "Lume",
|
||||||
"version": "1.1.1"
|
"version": "1.2.0"
|
||||||
},
|
},
|
||||||
"tauri": {
|
"plugins": {
|
||||||
"allowlist": {
|
"fs": {
|
||||||
"all": false,
|
"scope": [
|
||||||
"app": {
|
"$APPDATA/*",
|
||||||
"all": false
|
"$DATA/*",
|
||||||
},
|
"$LOCALDATA/*",
|
||||||
"os": {
|
"$DESKTOP/*",
|
||||||
"all": true
|
"$DOCUMENT/*",
|
||||||
},
|
"$DOWNLOAD/*",
|
||||||
"http": {
|
"$HOME/*",
|
||||||
"all": true,
|
"$PICTURE/*",
|
||||||
"request": true,
|
"$PUBLIC/*",
|
||||||
"scope": ["http://**", "https://**"]
|
"$VIDEO/*"
|
||||||
},
|
]
|
||||||
"fs": {
|
},
|
||||||
"all": false,
|
"http": {
|
||||||
"readFile": true,
|
"scope": [
|
||||||
"readDir": true,
|
"http://**/",
|
||||||
"writeFile": true,
|
"https://**/"
|
||||||
"removeFile": true,
|
]
|
||||||
"scope": [
|
},
|
||||||
"$APPDATA/*",
|
"shell": {
|
||||||
"$DATA/*",
|
"open": true
|
||||||
"$LOCALDATA/*",
|
},
|
||||||
"$DESKTOP/*",
|
"updater": {
|
||||||
"$DOCUMENT/*",
|
"endpoints": [
|
||||||
"$DOWNLOAD/*",
|
"https://lus.reya3772.workers.dev/v1/{{target}}/{{arch}}/{{current_version}}",
|
||||||
"$HOME/*",
|
"https://lus.reya3772.workers.dev/{{target}}/{{current_version}}"
|
||||||
"$PICTURE/*",
|
]
|
||||||
"$PUBLIC/*",
|
}
|
||||||
"$VIDEO/*"
|
},
|
||||||
]
|
"tauri": {
|
||||||
},
|
"bundle": {
|
||||||
"path": {
|
"active": true,
|
||||||
"all": true
|
"appimage": {
|
||||||
},
|
"bundleMediaFramework": true
|
||||||
"shell": {
|
},
|
||||||
"all": false,
|
"category": "SocialNetworking",
|
||||||
"open": true
|
"copyright": "",
|
||||||
},
|
"deb": {
|
||||||
"clipboard": {
|
"depends": []
|
||||||
"all": false,
|
},
|
||||||
"writeText": true,
|
"externalBin": [],
|
||||||
"readText": true
|
"icon": [
|
||||||
},
|
"icons/32x32.png",
|
||||||
"dialog": {
|
"icons/128x128.png",
|
||||||
"all": false,
|
"icons/128x128@2x.png",
|
||||||
"open": true
|
"icons/icon.icns",
|
||||||
},
|
"icons/icon.ico"
|
||||||
"notification": {
|
],
|
||||||
"all": true
|
"identifier": "com.lume.nu",
|
||||||
},
|
"longDescription": "",
|
||||||
"window": {
|
"macOS": {
|
||||||
"startDragging": true,
|
"entitlements": null,
|
||||||
"close": true,
|
"exceptionDomain": "",
|
||||||
"create": true
|
"frameworks": [],
|
||||||
},
|
"providerShortName": null,
|
||||||
"process": {
|
"signingIdentity": null
|
||||||
"all": false,
|
},
|
||||||
"exit": false,
|
"resources": [],
|
||||||
"relaunch": true,
|
"shortDescription": "",
|
||||||
"relaunchDangerousAllowSymlinkMacos": false
|
"targets": "all",
|
||||||
}
|
"updater": {
|
||||||
},
|
"active": true,
|
||||||
"bundle": {
|
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEU4RjAzODFBREQ4MkM3RTEKUldUaHg0TGRHamp3NkI5bnhoOEVjanlHWFNzQ2Q3NDhubFFLUmJpSHJ1L2FqNnB3alF1Y2R3U3gK",
|
||||||
"active": true,
|
"windows": {
|
||||||
"category": "SocialNetworking",
|
"installMode": "passive"
|
||||||
"copyright": "",
|
}
|
||||||
"appimage": {
|
},
|
||||||
"bundleMediaFramework": true
|
"windows": {
|
||||||
},
|
"certificateThumbprint": null,
|
||||||
"deb": {
|
"digestAlgorithm": "sha256",
|
||||||
"depends": []
|
"timestampUrl": ""
|
||||||
},
|
}
|
||||||
"externalBin": [],
|
},
|
||||||
"icon": [
|
"security": {
|
||||||
"icons/32x32.png",
|
"csp": "upgrade-insecure-requests"
|
||||||
"icons/128x128.png",
|
},
|
||||||
"icons/128x128@2x.png",
|
"systemTray": {
|
||||||
"icons/icon.icns",
|
"iconAsTemplate": true,
|
||||||
"icons/icon.ico"
|
"iconPath": "icons/icon.png"
|
||||||
],
|
},
|
||||||
"identifier": "com.lume.nu",
|
"windows": [
|
||||||
"longDescription": "",
|
{
|
||||||
"macOS": {
|
"width": 1080,
|
||||||
"entitlements": null,
|
"height": 800,
|
||||||
"exceptionDomain": "",
|
"minWidth": 1080,
|
||||||
"frameworks": [],
|
"minHeight": 800,
|
||||||
"providerShortName": null,
|
"resizable": true,
|
||||||
"signingIdentity": null
|
"theme": "Dark",
|
||||||
},
|
"title": "Lume",
|
||||||
"resources": [],
|
"titleBarStyle": "Overlay",
|
||||||
"shortDescription": "",
|
"transparent": true,
|
||||||
"targets": "all",
|
"center": true,
|
||||||
"windows": {
|
"fullscreen": false,
|
||||||
"certificateThumbprint": null,
|
"hiddenTitle": true,
|
||||||
"digestAlgorithm": "sha256",
|
"visible": false
|
||||||
"timestampUrl": ""
|
},
|
||||||
}
|
{
|
||||||
},
|
"width": 400,
|
||||||
"security": {
|
"height": 500,
|
||||||
"csp": "upgrade-insecure-requests"
|
"decorations": true,
|
||||||
},
|
"hiddenTitle": true,
|
||||||
"updater": {
|
"center": true,
|
||||||
"active": true,
|
"resizable": false,
|
||||||
"dialog": true,
|
"titleBarStyle": "Overlay",
|
||||||
"endpoints": [
|
"label": "splashscreen",
|
||||||
"https://lus.reya3772.workers.dev/v1/{{target}}/{{arch}}/{{current_version}}",
|
"url": "splashscreen"
|
||||||
"https://lus.reya3772.workers.dev/{{target}}/{{current_version}}"
|
}
|
||||||
],
|
],
|
||||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEU4RjAzODFBREQ4MkM3RTEKUldUaHg0TGRHamp3NkI5bnhoOEVjanlHWFNzQ2Q3NDhubFFLUmJpSHJ1L2FqNnB3alF1Y2R3U3gK",
|
"macOSPrivateApi": true
|
||||||
"windows": {
|
}
|
||||||
"installMode": "passive"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"systemTray": {
|
|
||||||
"iconPath": "icons/icon.png",
|
|
||||||
"iconAsTemplate": true
|
|
||||||
},
|
|
||||||
"windows": [
|
|
||||||
{
|
|
||||||
"title": "Lume",
|
|
||||||
"theme": "Dark",
|
|
||||||
"titleBarStyle": "Overlay",
|
|
||||||
"hiddenTitle": true,
|
|
||||||
"transparent": false,
|
|
||||||
"fullscreen": false,
|
|
||||||
"resizable": true,
|
|
||||||
"width": 1080,
|
|
||||||
"height": 800,
|
|
||||||
"minWidth": 1080,
|
|
||||||
"minHeight": 720
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
268
src/app.tsx
268
src/app.tsx
@@ -1,107 +1,237 @@
|
|||||||
import { RouterProvider, createBrowserRouter } from 'react-router-dom';
|
import { RouterProvider, createBrowserRouter, redirect } from 'react-router-dom';
|
||||||
|
|
||||||
import { AuthCreateScreen } from '@app/auth/create';
|
import { AuthCreateScreen } from '@app/auth/create';
|
||||||
import { CreateStep1Screen } from '@app/auth/create/step-1';
|
|
||||||
import { CreateStep2Screen } from '@app/auth/create/step-2';
|
|
||||||
import { CreateStep3Screen } from '@app/auth/create/step-3';
|
|
||||||
import { CreateStep4Screen } from '@app/auth/create/step-4';
|
|
||||||
import { CreateStep5Screen } from '@app/auth/create/step-5';
|
|
||||||
import { AuthImportScreen } from '@app/auth/import';
|
import { AuthImportScreen } from '@app/auth/import';
|
||||||
import { ImportStep1Screen } from '@app/auth/import/step-1';
|
|
||||||
import { ImportStep2Screen } from '@app/auth/import/step-2';
|
|
||||||
import { ImportStep3Screen } from '@app/auth/import/step-3';
|
|
||||||
import { MigrateScreen } from '@app/auth/migrate';
|
|
||||||
import { OnboardingScreen } from '@app/auth/onboarding';
|
import { OnboardingScreen } from '@app/auth/onboarding';
|
||||||
import { ResetScreen } from '@app/auth/reset';
|
|
||||||
import { UnlockScreen } from '@app/auth/unlock';
|
|
||||||
import { WelcomeScreen } from '@app/auth/welcome';
|
|
||||||
import { ChannelScreen } from '@app/channel';
|
|
||||||
import { ChatScreen } from '@app/chats';
|
|
||||||
import { ErrorScreen } from '@app/error';
|
import { ErrorScreen } from '@app/error';
|
||||||
import { NoteScreen } from '@app/note';
|
|
||||||
import { Root } from '@app/root';
|
import { getActiveAccount } from '@libs/storage';
|
||||||
import { AccountSettingsScreen } from '@app/settings/account';
|
|
||||||
import { GeneralSettingsScreen } from '@app/settings/general';
|
|
||||||
import { ShortcutsSettingsScreen } from '@app/settings/shortcuts';
|
|
||||||
import { SpaceScreen } from '@app/space';
|
|
||||||
import { TrendingScreen } from '@app/trending';
|
|
||||||
import { UserScreen } from '@app/users';
|
|
||||||
|
|
||||||
import { AppLayout } from '@shared/appLayout';
|
import { AppLayout } from '@shared/appLayout';
|
||||||
import { AuthLayout } from '@shared/authLayout';
|
import { AuthLayout } from '@shared/authLayout';
|
||||||
import { Protected } from '@shared/protected';
|
import { LoaderIcon } from '@shared/icons';
|
||||||
import { SettingsLayout } from '@shared/settingsLayout';
|
import { SettingsLayout } from '@shared/settingsLayout';
|
||||||
|
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
|
const appLoader = async () => {
|
||||||
|
const account = await getActiveAccount();
|
||||||
|
const stronghold = sessionStorage.getItem('stronghold');
|
||||||
|
const privkey = JSON.parse(stronghold).state.privkey || null;
|
||||||
|
const onboarding = localStorage.getItem('onboarding');
|
||||||
|
const step = JSON.parse(onboarding).state.step || null;
|
||||||
|
|
||||||
|
if (step) {
|
||||||
|
return redirect(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
return redirect('/auth/welcome');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (account && account.privkey.length > 35) {
|
||||||
|
return redirect('/auth/migrate');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (account && !privkey) {
|
||||||
|
return redirect('/auth/unlock');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
element: (
|
element: <AppLayout />,
|
||||||
<Protected>
|
|
||||||
<Root />
|
|
||||||
</Protected>
|
|
||||||
),
|
|
||||||
errorElement: <ErrorScreen />,
|
errorElement: <ErrorScreen />,
|
||||||
|
loader: appLoader,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
async lazy() {
|
||||||
|
const { SpaceScreen } = await import('@app/space');
|
||||||
|
return { Component: SpaceScreen };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'trending',
|
||||||
|
async lazy() {
|
||||||
|
const { TrendingScreen } = await import('@app/trending');
|
||||||
|
return { Component: TrendingScreen };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'events/:id',
|
||||||
|
async lazy() {
|
||||||
|
const { EventScreen } = await import('@app/events');
|
||||||
|
return { Component: EventScreen };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'users/:pubkey',
|
||||||
|
async lazy() {
|
||||||
|
const { UserScreen } = await import('@app/users');
|
||||||
|
return { Component: UserScreen };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'chats/:pubkey',
|
||||||
|
async lazy() {
|
||||||
|
const { ChatScreen } = await import('@app/chats');
|
||||||
|
return { Component: ChatScreen };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/splashscreen',
|
||||||
|
errorElement: <ErrorScreen />,
|
||||||
|
async lazy() {
|
||||||
|
const { SplashScreen } = await import('@app/splash');
|
||||||
|
return { Component: SplashScreen };
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/auth',
|
path: '/auth',
|
||||||
element: <AuthLayout />,
|
element: <AuthLayout />,
|
||||||
children: [
|
children: [
|
||||||
{ path: 'welcome', element: <WelcomeScreen /> },
|
{
|
||||||
{ path: 'onboarding', element: <OnboardingScreen /> },
|
path: 'welcome',
|
||||||
|
async lazy() {
|
||||||
|
const { WelcomeScreen } = await import('@app/auth/welcome');
|
||||||
|
return { Component: WelcomeScreen };
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'import',
|
path: 'import',
|
||||||
element: <AuthImportScreen />,
|
element: <AuthImportScreen />,
|
||||||
children: [
|
children: [
|
||||||
{ path: '', element: <ImportStep1Screen /> },
|
{
|
||||||
{ path: 'step-2', element: <ImportStep2Screen /> },
|
path: '',
|
||||||
{ path: 'step-3', element: <ImportStep3Screen /> },
|
async lazy() {
|
||||||
|
const { ImportStep1Screen } = await import('@app/auth/import/step-1');
|
||||||
|
return { Component: ImportStep1Screen };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'step-2',
|
||||||
|
async lazy() {
|
||||||
|
const { ImportStep2Screen } = await import('@app/auth/import/step-2');
|
||||||
|
return { Component: ImportStep2Screen };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'step-3',
|
||||||
|
async lazy() {
|
||||||
|
const { ImportStep3Screen } = await import('@app/auth/import/step-3');
|
||||||
|
return { Component: ImportStep3Screen };
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'create',
|
path: 'create',
|
||||||
element: <AuthCreateScreen />,
|
element: <AuthCreateScreen />,
|
||||||
children: [
|
children: [
|
||||||
{ path: '', element: <CreateStep1Screen /> },
|
{
|
||||||
{ path: 'step-2', element: <CreateStep2Screen /> },
|
path: '',
|
||||||
{ path: 'step-3', element: <CreateStep3Screen /> },
|
async lazy() {
|
||||||
{ path: 'step-4', element: <CreateStep4Screen /> },
|
const { CreateStep1Screen } = await import('@app/auth/create/step-1');
|
||||||
{ path: 'step-5', element: <CreateStep5Screen /> },
|
return { Component: CreateStep1Screen };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'step-2',
|
||||||
|
async lazy() {
|
||||||
|
const { CreateStep2Screen } = await import('@app/auth/create/step-2');
|
||||||
|
return { Component: CreateStep2Screen };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'step-3',
|
||||||
|
async lazy() {
|
||||||
|
const { CreateStep3Screen } = await import('@app/auth/create/step-3');
|
||||||
|
return { Component: CreateStep3Screen };
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ path: 'unlock', element: <UnlockScreen /> },
|
{
|
||||||
{ path: 'migrate', element: <MigrateScreen /> },
|
path: 'onboarding',
|
||||||
{ path: 'reset', element: <ResetScreen /> },
|
element: <OnboardingScreen />,
|
||||||
],
|
children: [
|
||||||
},
|
{
|
||||||
{
|
path: '',
|
||||||
path: '/app',
|
async lazy() {
|
||||||
element: (
|
const { OnboardStep1Screen } = await import('@app/auth/onboarding/step-1');
|
||||||
<Protected>
|
return { Component: OnboardStep1Screen };
|
||||||
<AppLayout />
|
},
|
||||||
</Protected>
|
},
|
||||||
),
|
{
|
||||||
children: [
|
path: 'step-2',
|
||||||
{ path: 'space', element: <SpaceScreen /> },
|
async lazy() {
|
||||||
{ path: 'trending', element: <TrendingScreen /> },
|
const { OnboardStep2Screen } = await import('@app/auth/onboarding/step-2');
|
||||||
{ path: 'note/:id', element: <NoteScreen /> },
|
return { Component: OnboardStep2Screen };
|
||||||
{ path: 'users/:pubkey', element: <UserScreen /> },
|
},
|
||||||
{ path: 'chats/:pubkey', element: <ChatScreen /> },
|
},
|
||||||
{ path: 'channel/:id', element: <ChannelScreen /> },
|
{
|
||||||
|
path: 'step-3',
|
||||||
|
async lazy() {
|
||||||
|
const { OnboardStep3Screen } = await import('@app/auth/onboarding/step-3');
|
||||||
|
return { Component: OnboardStep3Screen };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'unlock',
|
||||||
|
async lazy() {
|
||||||
|
const { UnlockScreen } = await import('@app/auth/unlock');
|
||||||
|
return { Component: UnlockScreen };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'migrate',
|
||||||
|
async lazy() {
|
||||||
|
const { MigrateScreen } = await import('@app/auth/migrate');
|
||||||
|
return { Component: MigrateScreen };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'reset',
|
||||||
|
async lazy() {
|
||||||
|
const { ResetScreen } = await import('@app/auth/reset');
|
||||||
|
return { Component: ResetScreen };
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/settings',
|
path: '/settings',
|
||||||
element: (
|
element: <SettingsLayout />,
|
||||||
<Protected>
|
|
||||||
<SettingsLayout />
|
|
||||||
</Protected>
|
|
||||||
),
|
|
||||||
children: [
|
children: [
|
||||||
{ path: 'general', element: <GeneralSettingsScreen /> },
|
{
|
||||||
{ path: 'shortcuts', element: <ShortcutsSettingsScreen /> },
|
path: 'general',
|
||||||
{ path: 'account', element: <AccountSettingsScreen /> },
|
async lazy() {
|
||||||
|
const { GeneralSettingsScreen } = await import('@app/settings/general');
|
||||||
|
return { Component: GeneralSettingsScreen };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'shortcuts',
|
||||||
|
async lazy() {
|
||||||
|
const { ShortcutsSettingsScreen } = await import('@app/settings/shortcuts');
|
||||||
|
return { Component: ShortcutsSettingsScreen };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'account',
|
||||||
|
async lazy() {
|
||||||
|
const { AccountSettingsScreen } = await import('@app/settings/account');
|
||||||
|
return { Component: AccountSettingsScreen };
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -110,7 +240,11 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<RouterProvider
|
<RouterProvider
|
||||||
router={router}
|
router={router}
|
||||||
fallbackElement={<p>Loading..</p>}
|
fallbackElement={
|
||||||
|
<div className="flex h-full w-full items-center justify-center bg-black/90">
|
||||||
|
<LoaderIcon className="h-6 w-6 animate-spin text-white" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
future={{ v7_startTransition: true }}
|
future={{ v7_startTransition: true }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Image } from '@shared/image';
|
|||||||
import { DEFAULT_AVATAR } from '@stores/constants';
|
import { DEFAULT_AVATAR } from '@stores/constants';
|
||||||
|
|
||||||
import { useProfile } from '@utils/hooks/useProfile';
|
import { useProfile } from '@utils/hooks/useProfile';
|
||||||
import { shortenKey } from '@utils/shortenKey';
|
import { displayNpub } from '@utils/shortenKey';
|
||||||
|
|
||||||
export function User({ pubkey, fallback }: { pubkey: string; fallback?: string }) {
|
export function User({ pubkey, fallback }: { pubkey: string; fallback?: string }) {
|
||||||
const { status, user } = useProfile(pubkey, fallback);
|
const { status, user } = useProfile(pubkey, fallback);
|
||||||
@@ -11,10 +11,10 @@ export function User({ pubkey, fallback }: { pubkey: string; fallback?: string }
|
|||||||
if (status === 'loading') {
|
if (status === 'loading') {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="relative h-10 w-10 shrink-0 animate-pulse rounded-md bg-zinc-800" />
|
<div className="relative h-10 w-10 shrink-0 animate-pulse rounded-md bg-white/10" />
|
||||||
<div className="flex w-full flex-1 flex-col items-start gap-1 text-start">
|
<div className="flex w-full flex-1 flex-col items-start gap-1 text-start">
|
||||||
<span className="h-4 w-1/2 animate-pulse rounded bg-zinc-800" />
|
<span className="h-4 w-1/2 animate-pulse rounded bg-white/10" />
|
||||||
<span className="h-3 w-1/3 animate-pulse rounded bg-zinc-800" />
|
<span className="h-3 w-1/3 animate-pulse rounded bg-white/10" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -31,11 +31,11 @@ export function User({ pubkey, fallback }: { pubkey: string; fallback?: string }
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full flex-1 flex-col items-start text-start">
|
<div className="flex w-full flex-1 flex-col items-start text-start">
|
||||||
<span className="truncate font-medium leading-tight text-zinc-100">
|
<span className="truncate font-medium leading-tight text-white">
|
||||||
{user?.name || user?.displayName || user?.display_name}
|
{user?.name || user?.display_name || user?.nip05}
|
||||||
</span>
|
</span>
|
||||||
<span className="max-w-[15rem] truncate text-base leading-tight text-zinc-400">
|
<span className="max-w-[15rem] truncate text-base leading-tight text-white/50">
|
||||||
{user?.nip05?.toLowerCase() || shortenKey(pubkey)}
|
{user?.nip05?.toLowerCase() || displayNpub(pubkey, 16)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
39
src/app/auth/components/userRelay.tsx
Normal file
39
src/app/auth/components/userRelay.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { Image } from '@shared/image';
|
||||||
|
|
||||||
|
import { DEFAULT_AVATAR } from '@stores/constants';
|
||||||
|
|
||||||
|
import { useProfile } from '@utils/hooks/useProfile';
|
||||||
|
import { displayNpub } from '@utils/shortenKey';
|
||||||
|
|
||||||
|
export function UserRelay({ pubkey }: { pubkey: string }) {
|
||||||
|
const { status, user } = useProfile(pubkey);
|
||||||
|
|
||||||
|
if (status === 'loading') {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative h-10 w-10 shrink-0 animate-pulse rounded-md bg-white/10" />
|
||||||
|
<div className="flex w-full flex-1 flex-col items-start gap-1 text-start">
|
||||||
|
<span className="h-4 w-1/2 animate-pulse rounded bg-white/10" />
|
||||||
|
<span className="h-3 w-1/3 animate-pulse rounded bg-white/10" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="inline-flex items-center gap-2 text-white/50">
|
||||||
|
<span className="text-sm">Use by</span>
|
||||||
|
<div className="inline-flex items-center gap-1">
|
||||||
|
<Image
|
||||||
|
src={user?.picture || user?.image}
|
||||||
|
fallback={DEFAULT_AVATAR}
|
||||||
|
alt={pubkey}
|
||||||
|
className="h-5 w-5 shrink-0 rounded object-cover"
|
||||||
|
/>
|
||||||
|
<span className="truncate text-sm font-medium leading-none text-white">
|
||||||
|
{user?.name || user?.display_name || user?.nip05 || displayNpub(pubkey, 16)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,19 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { useOnboarding } from '@stores/onboarding';
|
||||||
|
import { useStronghold } from '@stores/stronghold';
|
||||||
|
|
||||||
export function AuthCreateScreen() {
|
export function AuthCreateScreen() {
|
||||||
|
const [step, tmpPrivkey] = useOnboarding((state) => [state.step, state.tempPrivkey]);
|
||||||
|
const setPrivkey = useStronghold((state) => state.setPrivkey);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (step) {
|
||||||
|
setPrivkey(tmpPrivkey);
|
||||||
|
}
|
||||||
|
}, [tmpPrivkey]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { BaseDirectory, writeTextFile } from '@tauri-apps/api/fs';
|
import { BaseDirectory, writeTextFile } from '@tauri-apps/plugin-fs';
|
||||||
import { generatePrivateKey, getPublicKey, nip19 } from 'nostr-tools';
|
import { generatePrivateKey, getPublicKey, nip19 } from 'nostr-tools';
|
||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import { createAccount } from '@libs/storage';
|
import { createAccount } from '@libs/storage';
|
||||||
|
|
||||||
import { Button } from '@shared/button';
|
import { Button } from '@shared/button';
|
||||||
import { EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
|
import { EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
|
||||||
|
import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
|
||||||
|
|
||||||
import { useOnboarding } from '@stores/onboarding';
|
import { useOnboarding } from '@stores/onboarding';
|
||||||
import { useStronghold } from '@stores/stronghold';
|
import { useStronghold } from '@stores/stronghold';
|
||||||
@@ -16,7 +17,9 @@ export function CreateStep1Screen() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const setPrivkey = useStronghold((state) => state.setPrivkey);
|
const setPrivkey = useStronghold((state) => state.setPrivkey);
|
||||||
|
const setTempPrivkey = useOnboarding((state) => state.setTempPrivkey);
|
||||||
const setPubkey = useOnboarding((state) => state.setPubkey);
|
const setPubkey = useOnboarding((state) => state.setPubkey);
|
||||||
|
const setStep = useOnboarding((state) => state.setStep);
|
||||||
|
|
||||||
const [privkeyInput, setPrivkeyInput] = useState('password');
|
const [privkeyInput, setPrivkeyInput] = useState('password');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -37,13 +40,9 @@ export function CreateStep1Screen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const download = async () => {
|
const download = async () => {
|
||||||
await writeTextFile(
|
await writeTextFile('lume-keys.txt', `Public key: ${npub}\nPrivate key: ${nsec}`, {
|
||||||
'lume-keys.txt',
|
dir: BaseDirectory.Download,
|
||||||
`Public key: ${pubkey}\nPrivate key: ${privkey}`,
|
});
|
||||||
{
|
|
||||||
dir: BaseDirectory.Download,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
setDownloaded(true);
|
setDownloaded(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -64,8 +63,9 @@ export function CreateStep1Screen() {
|
|||||||
const submit = () => {
|
const submit = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
setPubkey(pubkey);
|
|
||||||
setPrivkey(privkey);
|
setPrivkey(privkey);
|
||||||
|
setTempPrivkey(privkey); // only use if user close app and reopen it
|
||||||
|
setPubkey(pubkey);
|
||||||
|
|
||||||
account.mutate({
|
account.mutate({
|
||||||
npub,
|
npub,
|
||||||
@@ -78,50 +78,47 @@ export function CreateStep1Screen() {
|
|||||||
setTimeout(() => navigate('/auth/create/step-2', { replace: true }), 1200);
|
setTimeout(() => navigate('/auth/create/step-2', { replace: true }), 1200);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// save current step, if user close app and reopen it
|
||||||
|
setStep('/auth/create/step-1');
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-md">
|
<div className="mx-auto w-full max-w-md">
|
||||||
<div className="mb-8 text-center">
|
<div className="mb-8 text-center">
|
||||||
<h1 className="text-xl font-semibold text-zinc-100">Save your access key!</h1>
|
<h1 className="text-xl font-semibold text-white">Save your access key!</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="text-base font-semibold text-zinc-400">Public Key</span>
|
<span className="text-base font-semibold text-white/50">Public Key</span>
|
||||||
<input
|
<input
|
||||||
readOnly
|
readOnly
|
||||||
value={npub}
|
value={npub}
|
||||||
className="relative w-full rounded-lg bg-zinc-800 py-3 pl-3.5 pr-11 text-zinc-100 !outline-none placeholder:text-zinc-400"
|
className="relative h-11 w-full rounded-lg bg-white/10 px-3.5 py-1 text-white !outline-none placeholder:text-white/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="text-base font-semibold text-zinc-400">Private Key</span>
|
<span className="text-base font-semibold text-white/50">Private Key</span>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
readOnly
|
readOnly
|
||||||
type={privkeyInput}
|
type={privkeyInput}
|
||||||
value={nsec}
|
value={nsec}
|
||||||
className="relative w-full rounded-lg bg-zinc-800 py-3 pl-3.5 pr-11 text-zinc-100 !outline-none placeholder:text-zinc-400"
|
className="relative h-11 w-full rounded-lg bg-white/10 py-1 pl-3.5 pr-11 text-white !outline-none placeholder:text-white/50"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => showPrivateKey()}
|
onClick={() => showPrivateKey()}
|
||||||
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-zinc-700"
|
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-white/10"
|
||||||
>
|
>
|
||||||
{privkeyInput === 'password' ? (
|
{privkeyInput === 'password' ? (
|
||||||
<EyeOffIcon
|
<EyeOffIcon className="h-4 w-4 text-white/50 group-hover:text-white" />
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
className="text-zinc-500 group-hover:text-zinc-100"
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<EyeOnIcon
|
<EyeOnIcon className="h-4 w-4 text-white/50 group-hover:text-white" />
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
className="text-zinc-500 group-hover:text-zinc-100"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-sm text-zinc-500">
|
<div className="mt-2 text-sm text-white/50">
|
||||||
<p>
|
<p>
|
||||||
Your private key is your password. If you lose this key, you will lose
|
Your private key is your password. If you lose this key, you will lose
|
||||||
access to your account! Copy it and keep it in a safe place. There is no way
|
access to your account! Copy it and keep it in a safe place. There is no way
|
||||||
@@ -130,15 +127,29 @@ export function CreateStep1Screen() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Button preset="large" onClick={() => submit()}>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => submit()}
|
||||||
|
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
|
||||||
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
|
<>
|
||||||
|
<span className="w-5" />
|
||||||
|
<span>Creating...</span>
|
||||||
|
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
'I have saved my key, continue →'
|
<>
|
||||||
|
<span className="w-5" />
|
||||||
|
<span>I have saved my key, continue</span>
|
||||||
|
<ArrowRightCircleIcon className="h-5 w-5" />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</button>
|
||||||
{downloaded ? (
|
{downloaded ? (
|
||||||
<span className="text-sm text-zinc-400">Saved in download folder</span>
|
<span className="inline-flex h-11 w-full items-center justify-center text-sm text-white/50">
|
||||||
|
Saved in Download folder
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<Button preset="large-alt" onClick={() => download()}>
|
<Button preset="large-alt" onClick={() => download()}>
|
||||||
Download
|
Download
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Resolver, useForm } from 'react-hook-form';
|
import { Resolver, useForm } from 'react-hook-form';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import { EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
|
import { EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
|
||||||
|
import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
|
||||||
|
|
||||||
import { useOnboarding } from '@stores/onboarding';
|
import { useOnboarding } from '@stores/onboarding';
|
||||||
import { useStronghold } from '@stores/stronghold';
|
import { useStronghold } from '@stores/stronghold';
|
||||||
@@ -29,13 +30,13 @@ const resolver: Resolver<FormValues> = async (values) => {
|
|||||||
|
|
||||||
export function CreateStep2Screen() {
|
export function CreateStep2Screen() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const setStep = useOnboarding((state) => state.setStep);
|
||||||
|
const pubkey = useOnboarding((state) => state.pubkey);
|
||||||
|
const privkey = useStronghold((state) => state.privkey);
|
||||||
|
|
||||||
const [passwordInput, setPasswordInput] = useState('password');
|
const [passwordInput, setPasswordInput] = useState('password');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const privkey = useStronghold((state) => state.privkey);
|
|
||||||
const pubkey = useOnboarding((state) => state.pubkey);
|
|
||||||
|
|
||||||
const { save } = useSecureStorage();
|
const { save } = useSecureStorage();
|
||||||
|
|
||||||
// toggle private key
|
// toggle private key
|
||||||
@@ -71,10 +72,15 @@ export function CreateStep2Screen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// save current step, if user close app and reopen it
|
||||||
|
setStep('/auth/create/step-2');
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-md">
|
<div className="mx-auto w-full max-w-md">
|
||||||
<div className="mb-8 text-center">
|
<div className="mb-8 text-center">
|
||||||
<h1 className="text-xl font-semibold text-zinc-100">
|
<h1 className="text-xl font-semibold text-white">
|
||||||
Set password to secure your key
|
Set password to secure your key
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
@@ -85,29 +91,21 @@ export function CreateStep2Screen() {
|
|||||||
<input
|
<input
|
||||||
{...register('password', { required: true })}
|
{...register('password', { required: true })}
|
||||||
type={passwordInput}
|
type={passwordInput}
|
||||||
className="relative w-full rounded-lg bg-zinc-800 py-3 pl-3.5 pr-11 text-zinc-100 !outline-none placeholder:text-zinc-400"
|
className="relative h-11 w-full rounded-lg bg-white/10 px-3.5 py-1 text-center text-white !outline-none placeholder:text-white/50"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => showPassword()}
|
onClick={() => showPassword()}
|
||||||
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-zinc-700"
|
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-white/10"
|
||||||
>
|
>
|
||||||
{passwordInput === 'password' ? (
|
{passwordInput === 'password' ? (
|
||||||
<EyeOffIcon
|
<EyeOffIcon className="h-4 w-4 text-white/50 group-hover:text-white" />
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
className="text-zinc-500 group-hover:text-zinc-100"
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<EyeOnIcon
|
<EyeOnIcon className="h-4 w-4 text-white/50 group-hover:text-white" />
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
className="text-zinc-500 group-hover:text-zinc-100"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-zinc-500">
|
<div className="text-sm text-white/50">
|
||||||
<p>
|
<p>
|
||||||
Password is use to secure your key store in local machine, when you move
|
Password is use to secure your key store in local machine, when you move
|
||||||
to other clients, you just need to copy your private key as nsec or
|
to other clients, you just need to copy your private key as nsec or
|
||||||
@@ -122,12 +120,20 @@ export function CreateStep2Screen() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!isDirty || !isValid}
|
disabled={!isDirty || !isValid}
|
||||||
className="inline-flex h-11 w-full items-center justify-center rounded-md bg-fuchsia-500 font-medium text-zinc-100 hover:bg-fuchsia-600 disabled:pointer-events-none disabled:opacity-50"
|
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
|
<>
|
||||||
|
<span className="w-5" />
|
||||||
|
<span>Creating...</span>
|
||||||
|
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
'Continue →'
|
<>
|
||||||
|
<span className="w-5" />
|
||||||
|
<span>Continue</span>
|
||||||
|
<ArrowRightCircleIcon className="h-5 w-5" />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,68 +1,79 @@
|
|||||||
import { useState } from 'react';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import { AvatarUploader } from '@shared/avatarUploader';
|
import { AvatarUploader } from '@shared/avatarUploader';
|
||||||
import { BannerUploader } from '@shared/bannerUploader';
|
import { BannerUploader } from '@shared/bannerUploader';
|
||||||
import { LoaderIcon } from '@shared/icons';
|
import { LoaderIcon } from '@shared/icons';
|
||||||
|
import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
|
||||||
import { Image } from '@shared/image';
|
import { Image } from '@shared/image';
|
||||||
|
|
||||||
import { DEFAULT_AVATAR } from '@stores/constants';
|
import { DEFAULT_AVATAR } from '@stores/constants';
|
||||||
import { useOnboarding } from '@stores/onboarding';
|
import { useOnboarding } from '@stores/onboarding';
|
||||||
|
|
||||||
|
import { useNostr } from '@utils/hooks/useNostr';
|
||||||
|
|
||||||
export function CreateStep3Screen() {
|
export function CreateStep3Screen() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const createProfile = useOnboarding((state) => state.createProfile);
|
const setStep = useOnboarding((state) => state.setStep);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
const [picture, setPicture] = useState(DEFAULT_AVATAR);
|
const [picture, setPicture] = useState(DEFAULT_AVATAR);
|
||||||
const [banner, setBanner] = useState('');
|
const [banner, setBanner] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
|
const { publish } = useNostr();
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { isDirty, isValid },
|
formState: { isDirty, isValid },
|
||||||
} = useForm();
|
} = useForm();
|
||||||
|
|
||||||
const onSubmit = (data: { name: string; about: string }) => {
|
const onSubmit = async (data: { name: string; about: string; website: string }) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const profile = {
|
const profile = {
|
||||||
...data,
|
...data,
|
||||||
username: data.name,
|
|
||||||
name: data.name,
|
name: data.name,
|
||||||
display_name: data.name,
|
display_name: data.name,
|
||||||
bio: data.about,
|
bio: data.about,
|
||||||
|
website: data.website,
|
||||||
};
|
};
|
||||||
createProfile(profile);
|
|
||||||
// redirect to next step
|
const event = await publish({
|
||||||
setTimeout(() => navigate('/auth/create/step-4', { replace: true }), 1200);
|
content: JSON.stringify(profile),
|
||||||
} catch {
|
kind: 0,
|
||||||
console.log('error');
|
tags: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
queryClient.invalidateQueries(['currentAccount']);
|
||||||
|
|
||||||
|
if (event) {
|
||||||
|
setTimeout(() => navigate('/auth/onboarding', { replace: true }), 1000);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('error: ', e);
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// save current step, if user close app and reopen it
|
||||||
|
setStep('/auth/create/step-3');
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-md">
|
<div className="mx-auto w-full max-w-md">
|
||||||
<div className="mb-8 text-center">
|
<div className="mb-8 text-center">
|
||||||
<h1 className="text-xl font-semibold text-zinc-100">Create your profile</h1>
|
<h1 className="text-xl font-semibold text-white">Create your profile</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full overflow-hidden rounded-xl border-t border-zinc-800/50 bg-zinc-900">
|
<div className="w-full overflow-hidden rounded-xl bg-white/10">
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col">
|
<form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col">
|
||||||
<input
|
<input type={'hidden'} {...register('picture')} value={picture} />
|
||||||
type={'hidden'}
|
<input type={'hidden'} {...register('banner')} value={banner} />
|
||||||
{...register('picture')}
|
|
||||||
value={picture}
|
|
||||||
className="shadow-input relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-100 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type={'hidden'}
|
|
||||||
{...register('banner')}
|
|
||||||
value={banner}
|
|
||||||
className="shadow-input relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-100 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
|
||||||
/>
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="relative h-44 w-full bg-zinc-800">
|
<div className="relative h-44 w-full bg-white/10">
|
||||||
<Image
|
<Image
|
||||||
src={banner}
|
src={banner}
|
||||||
fallback="https://void.cat/d/QY1myro5tkHVs2nY7dy74b.jpg"
|
fallback="https://void.cat/d/QY1myro5tkHVs2nY7dy74b.jpg"
|
||||||
@@ -79,7 +90,7 @@ export function CreateStep3Screen() {
|
|||||||
src={picture}
|
src={picture}
|
||||||
fallback={DEFAULT_AVATAR}
|
fallback={DEFAULT_AVATAR}
|
||||||
alt="user's avatar"
|
alt="user's avatar"
|
||||||
className="h-14 w-14 rounded-lg object-cover ring-2 ring-zinc-900"
|
className="h-14 w-14 rounded-lg object-cover ring-2 ring-white/10"
|
||||||
/>
|
/>
|
||||||
<div className="absolute left-1/2 top-1/2 z-10 h-full w-full -translate-x-1/2 -translate-y-1/2 transform">
|
<div className="absolute left-1/2 top-1/2 z-10 h-full w-full -translate-x-1/2 -translate-y-1/2 transform">
|
||||||
<AvatarUploader setPicture={setPicture} />
|
<AvatarUploader setPicture={setPicture} />
|
||||||
@@ -91,7 +102,7 @@ export function CreateStep3Screen() {
|
|||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label
|
<label
|
||||||
htmlFor="name"
|
htmlFor="name"
|
||||||
className="text-sm font-semibold uppercase tracking-wider text-zinc-400"
|
className="text-sm font-semibold uppercase tracking-wider text-white/50"
|
||||||
>
|
>
|
||||||
Name *
|
Name *
|
||||||
</label>
|
</label>
|
||||||
@@ -102,26 +113,26 @@ export function CreateStep3Screen() {
|
|||||||
minLength: 4,
|
minLength: 4,
|
||||||
})}
|
})}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
className="relative h-10 w-full rounded-lg bg-zinc-800 px-3 py-2 text-zinc-100 !outline-none placeholder:text-zinc-500"
|
className="relative h-11 w-full rounded-lg bg-white/10 px-3 py-1 text-white !outline-none placeholder:text-white/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label
|
<label
|
||||||
htmlFor="about"
|
htmlFor="about"
|
||||||
className="text-sm font-semibold uppercase tracking-wider text-zinc-400"
|
className="text-sm font-semibold uppercase tracking-wider text-white/50"
|
||||||
>
|
>
|
||||||
Bio
|
Bio
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
{...register('about')}
|
{...register('about')}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
className="relative h-20 w-full resize-none rounded-lg bg-zinc-800 px-3 py-2 text-zinc-100 !outline-none placeholder:text-zinc-500"
|
className="relative h-20 w-full resize-none rounded-lg bg-white/10 px-3 py-1 text-white !outline-none placeholder:text-white/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label
|
<label
|
||||||
htmlFor="website"
|
htmlFor="website"
|
||||||
className="text-sm font-semibold uppercase tracking-wider text-zinc-400"
|
className="text-sm font-semibold uppercase tracking-wider text-white/50"
|
||||||
>
|
>
|
||||||
Website
|
Website
|
||||||
</label>
|
</label>
|
||||||
@@ -131,18 +142,26 @@ export function CreateStep3Screen() {
|
|||||||
required: false,
|
required: false,
|
||||||
})}
|
})}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
className="relative h-10 w-full rounded-lg bg-zinc-800 px-3 py-2 text-zinc-100 !outline-none placeholder:text-zinc-500"
|
className="relative h-11 w-full rounded-lg bg-white/10 px-3 py-1 text-white !outline-none placeholder:text-white/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!isDirty || !isValid}
|
disabled={!isDirty || !isValid}
|
||||||
className="inline-flex h-11 w-full items-center justify-center rounded-md bg-fuchsia-500 font-medium text-zinc-100 hover:bg-fuchsia-600"
|
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
|
<>
|
||||||
|
<span className="w-5" />
|
||||||
|
<span>Creating...</span>
|
||||||
|
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
'Continue →'
|
<>
|
||||||
|
<span className="w-5" />
|
||||||
|
<span>Continue</span>
|
||||||
|
<ArrowRightCircleIcon className="h-5 w-5" />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
import { Body, fetch } from '@tauri-apps/api/http';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { Button } from '@shared/button';
|
|
||||||
import { LoaderIcon } from '@shared/icons';
|
|
||||||
|
|
||||||
import { useOnboarding } from '@stores/onboarding';
|
|
||||||
|
|
||||||
import { useAccount } from '@utils/hooks/useAccount';
|
|
||||||
import { usePublish } from '@utils/hooks/usePublish';
|
|
||||||
|
|
||||||
export function CreateStep4Screen() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const profile = useOnboarding((state) => state.profile);
|
|
||||||
|
|
||||||
const { publish } = usePublish();
|
|
||||||
const { account } = useAccount();
|
|
||||||
|
|
||||||
const [username, setUsername] = useState('');
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const createNIP05 = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const response = await fetch('https://lume.nu/api/user-create', {
|
|
||||||
method: 'POST',
|
|
||||||
timeout: 30,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json; charset=utf-8',
|
|
||||||
},
|
|
||||||
body: Body.json({
|
|
||||||
username: username,
|
|
||||||
pubkey: account.pubkey,
|
|
||||||
lightningAddress: '',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = { ...profile, nip05: `${username}@lume.nu` };
|
|
||||||
publish({ content: JSON.stringify(data), kind: 0, tags: [] });
|
|
||||||
|
|
||||||
// redirect to step 4
|
|
||||||
navigate('/auth/create/step-5', { replace: true });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
setLoading(false);
|
|
||||||
console.error('Error:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto w-full max-w-md">
|
|
||||||
<div className="mb-8 text-center">
|
|
||||||
<h1 className="text-xl font-semibold text-zinc-100">Create your Lume ID</h1>
|
|
||||||
</div>
|
|
||||||
<div className="flex w-full flex-col items-center justify-center gap-4">
|
|
||||||
<div className="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-zinc-800">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={username}
|
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
|
||||||
autoCapitalize="false"
|
|
||||||
autoCorrect="none"
|
|
||||||
spellCheck="false"
|
|
||||||
placeholder="satoshi"
|
|
||||||
className="relative w-full bg-transparent py-3 pl-3.5 text-zinc-100 !outline-none placeholder:text-zinc-500"
|
|
||||||
/>
|
|
||||||
<span className="pr-3.5 font-semibold text-fuchsia-500">@lume.nu</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
preset="large"
|
|
||||||
onClick={() => createNIP05()}
|
|
||||||
disabled={username.length === 0}
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
|
|
||||||
) : (
|
|
||||||
'Continue →'
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { User } from '@app/auth/components/user';
|
|
||||||
|
|
||||||
import { updateAccount } from '@libs/storage';
|
|
||||||
|
|
||||||
import { CheckCircleIcon, LoaderIcon } from '@shared/icons';
|
|
||||||
|
|
||||||
import { useAccount } from '@utils/hooks/useAccount';
|
|
||||||
import { usePublish } from '@utils/hooks/usePublish';
|
|
||||||
import { arrayToNIP02 } from '@utils/transform';
|
|
||||||
|
|
||||||
const INITIAL_LIST = [
|
|
||||||
{
|
|
||||||
pubkey: '82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pubkey: 'a341f45ff9758f570a21b000c17d4e53a3a497c8397f26c0e6d61e5acffc7a98',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pubkey: '04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pubkey: 'c4eabae1be3cf657bc1855ee05e69de9f059cb7a059227168b80b89761cbc4e0',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pubkey: '6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pubkey: 'e88a691e98d9987c964521dff60025f60700378a4879180dcbbb4a5027850411',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pubkey: '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pubkey: 'c49d52a573366792b9a6e4851587c28042fb24fa5625c6d67b8c95c8751aca15',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pubkey: 'e33fe65f1fde44c6dc17eeb38fdad0fceaf1cae8722084332ed1e32496291d42',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pubkey: '84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pubkey: '703e26b4f8bc0fa57f99d815dbb75b086012acc24fc557befa310f5aa08d1898',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pubkey: 'bf2376e17ba4ec269d10fcc996a4746b451152be9031fa48e74553dde5526bce',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pubkey: '4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pubkey: 'c9b19ffcd43e6a5f23b3d27106ce19e4ad2df89ba1031dd4617f1b591e108965',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pubkey: 'c7dccba4fe4426a7b1ea239a5637ba40fab9862c8c86b3330fe65e9f667435f6',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pubkey: '6e1534f56fc9e937e06237c8ba4b5662bcacc4e1a3cfab9c16d89390bec4fca3',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pubkey: '50d94fc2d8580c682b071a542f8b1e31a200b0508bab95a33bef0855df281d63',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pubkey: '3d2e51508699f98f0f2bdbe7a45b673c687fe6420f466dc296d90b908d51d594',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pubkey: '6e3f51664e19e082df5217fd4492bb96907405a0b27028671dd7f297b688608c',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pubkey: '2edbcea694d164629854a52583458fd6d965b161e3c48b57d3aff01940558884',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pubkey: '3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pubkey: 'eab0e756d32b80bcd464f3d844b8040303075a13eabc3599a762c9ac7ab91f4f',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pubkey: 'be1d89794bf92de5dd64c1e60f6a2c70c140abac9932418fee30c5c637fe9479',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pubkey: 'a5e93aef8e820cbc7ab7b6205f854b87aed4b48c5f6b30fbbeba5c99e40dcf3f',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pubkey: '1989034e56b8f606c724f45a12ce84a11841621aaf7182a1f6564380b9c4276b',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pubkey: 'c48b5cced5ada74db078df6b00fa53fc1139d73bf0ed16de325d52220211dbd5',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pubkey: '460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pubkey: '7f3b464b9ff3623630485060cbda3a7790131c5339a7803bde8feb79a5e1b06a',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pubkey: 'b99dbca0184a32ce55904cb267b22e434823c97f418f36daf5d2dff0dd7b5c27',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pubkey: 'e9e4276490374a0daf7759fd5f475deff6ffb9b0fc5fa98c902b5f4b2fe3bba2',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pubkey: 'ea2e3c814d08a378f8a5b8faecb2884d05855975c5ca4b5c25e2d6f936286f14',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pubkey: 'ff04a0e6cd80c141b0b55825fed127d4532a6eecdb7e743a38a3c28bf9f44609',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function CreateStep5Screen() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [follows, setFollows] = useState([]);
|
|
||||||
|
|
||||||
const { publish } = usePublish();
|
|
||||||
const { account } = useAccount();
|
|
||||||
const { status, data } = useQuery(['trending-profiles'], async () => {
|
|
||||||
const res = await fetch('https://api.nostr.band/v0/trending/profiles');
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error('Error');
|
|
||||||
}
|
|
||||||
return res.json();
|
|
||||||
});
|
|
||||||
|
|
||||||
// toggle follow state
|
|
||||||
const toggleFollow = (pubkey: string) => {
|
|
||||||
const arr = follows.includes(pubkey)
|
|
||||||
? follows.filter((i) => i !== pubkey)
|
|
||||||
: [...follows, pubkey];
|
|
||||||
setFollows(arr);
|
|
||||||
};
|
|
||||||
|
|
||||||
const update = useMutation({
|
|
||||||
mutationFn: (follows: string[]) => {
|
|
||||||
return updateAccount('follows', follows, account.pubkey);
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['currentAccount'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// save follows to database then broadcast
|
|
||||||
const submit = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const tags = arrayToNIP02([...follows, account.pubkey]);
|
|
||||||
publish({ content: '', kind: 3, tags: tags });
|
|
||||||
|
|
||||||
// update
|
|
||||||
update.mutate([...follows, account.pubkey]);
|
|
||||||
|
|
||||||
// redirect to next step
|
|
||||||
setTimeout(() => navigate('/auth/onboarding', { replace: true }), 1200);
|
|
||||||
} catch {
|
|
||||||
console.log('error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const list = data ? data.profiles.concat(INITIAL_LIST) : INITIAL_LIST;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto w-full max-w-md">
|
|
||||||
<div className="mb-8 text-center">
|
|
||||||
<h1 className="text-xl font-semibold text-zinc-100">
|
|
||||||
Personalized your newsfeed
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="w-full overflow-hidden rounded-xl border-t border-zinc-800/50 bg-zinc-900">
|
|
||||||
<div className="inline-flex h-10 w-full items-center gap-1 border-b border-zinc-800 px-4 text-base font-medium text-zinc-400">
|
|
||||||
Follow at least
|
|
||||||
<span className="font-semibold text-fuchsia-500">
|
|
||||||
{follows.length}/10
|
|
||||||
</span>{' '}
|
|
||||||
plebs
|
|
||||||
</div>
|
|
||||||
{status === 'loading' ? (
|
|
||||||
<div className="inline-flex h-11 w-full items-center justify-center px-4 py-2">
|
|
||||||
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="scrollbar-hide flex h-96 flex-col overflow-y-auto py-2">
|
|
||||||
{list.map((item: { pubkey: string; profile: { content: string } }) => (
|
|
||||||
<button
|
|
||||||
key={item.pubkey}
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggleFollow(item.pubkey)}
|
|
||||||
className="inline-flex transform items-center justify-between bg-zinc-900 px-4 py-2 hover:bg-zinc-800 active:translate-y-1"
|
|
||||||
>
|
|
||||||
<User pubkey={item.pubkey} fallback={item.profile?.content} />
|
|
||||||
{follows.includes(item.pubkey) && (
|
|
||||||
<div>
|
|
||||||
<CheckCircleIcon className="h-4 w-4 text-green-400" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{follows.length >= 10 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => submit()}
|
|
||||||
className="inline-flex h-11 w-full items-center justify-center rounded-md bg-fuchsia-500 font-medium text-zinc-100 hover:bg-fuchsia-600"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
|
|
||||||
) : (
|
|
||||||
'Finish →'
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,19 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { useOnboarding } from '@stores/onboarding';
|
||||||
|
import { useStronghold } from '@stores/stronghold';
|
||||||
|
|
||||||
export function AuthImportScreen() {
|
export function AuthImportScreen() {
|
||||||
|
const [step, tmpPrivkey] = useOnboarding((state) => [state.step, state.tempPrivkey]);
|
||||||
|
const setPrivkey = useStronghold((state) => state.setPrivkey);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (step) {
|
||||||
|
setPrivkey(tmpPrivkey);
|
||||||
|
}
|
||||||
|
}, [tmpPrivkey]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { getPublicKey, nip19 } from 'nostr-tools';
|
import { getPublicKey, nip19 } from 'nostr-tools';
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Resolver, useForm } from 'react-hook-form';
|
import { Resolver, useForm } from 'react-hook-form';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import { createAccount } from '@libs/storage';
|
import { createAccount } from '@libs/storage';
|
||||||
|
|
||||||
import { LoaderIcon } from '@shared/icons';
|
import { LoaderIcon } from '@shared/icons';
|
||||||
|
import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
|
||||||
|
|
||||||
import { useOnboarding } from '@stores/onboarding';
|
import { useOnboarding } from '@stores/onboarding';
|
||||||
import { useStronghold } from '@stores/stronghold';
|
import { useStronghold } from '@stores/stronghold';
|
||||||
@@ -33,7 +34,9 @@ export function ImportStep1Screen() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const setPrivkey = useStronghold((state) => state.setPrivkey);
|
const setPrivkey = useStronghold((state) => state.setPrivkey);
|
||||||
|
const setTempPubkey = useOnboarding((state) => state.setTempPrivkey);
|
||||||
const setPubkey = useOnboarding((state) => state.setPubkey);
|
const setPubkey = useOnboarding((state) => state.setPubkey);
|
||||||
|
const setStep = useOnboarding((state) => state.setStep);
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
@@ -71,10 +74,9 @@ export function ImportStep1Screen() {
|
|||||||
const pubkey = getPublicKey(privkey);
|
const pubkey = getPublicKey(privkey);
|
||||||
const npub = nip19.npubEncode(pubkey);
|
const npub = nip19.npubEncode(pubkey);
|
||||||
|
|
||||||
// use for onboarding process only
|
|
||||||
setPubkey(pubkey);
|
|
||||||
// add stronghold state
|
|
||||||
setPrivkey(privkey);
|
setPrivkey(privkey);
|
||||||
|
setTempPubkey(privkey); // only use if user close app and reopen it
|
||||||
|
setPubkey(pubkey);
|
||||||
|
|
||||||
// add account to local database
|
// add account to local database
|
||||||
account.mutate({
|
account.mutate({
|
||||||
@@ -90,25 +92,30 @@ export function ImportStep1Screen() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError('privkey', {
|
setError('privkey', {
|
||||||
type: 'custom',
|
type: 'custom',
|
||||||
message: 'Private Key is invalid, please check again',
|
message: 'Private key is invalid, please check again',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// save current step, if user close app and reopen it
|
||||||
|
setStep('/auth/import/step-1');
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-md">
|
<div className="mx-auto w-full max-w-md">
|
||||||
<div className="mb-8 text-center">
|
<div className="mb-8 text-center">
|
||||||
<h1 className="text-xl font-semibold text-zinc-100">Import your key</h1>
|
<h1 className="text-xl font-semibold text-white">Import your key</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-3">
|
<form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col gap-3">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="text-base font-semibold text-zinc-400">Private key</span>
|
<span className="text-base font-semibold text-white/50">Private key</span>
|
||||||
<input
|
<input
|
||||||
{...register('privkey', { required: true, minLength: 32 })}
|
{...register('privkey', { required: true, minLength: 32 })}
|
||||||
type={'password'}
|
type={'password'}
|
||||||
placeholder="nsec or hexstring"
|
placeholder="nsec or hexstring"
|
||||||
className="relative w-full rounded-lg bg-zinc-800 px-3 py-3 text-zinc-100 !outline-none placeholder:text-zinc-500"
|
className="relative h-11 w-full rounded-lg bg-white/10 px-3 py-1 text-white !outline-none placeholder:text-white/50"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-red-400">
|
<span className="text-sm text-red-400">
|
||||||
{errors.privkey && <p>{errors.privkey.message}</p>}
|
{errors.privkey && <p>{errors.privkey.message}</p>}
|
||||||
@@ -118,12 +125,20 @@ export function ImportStep1Screen() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!isDirty || !isValid}
|
disabled={!isDirty || !isValid}
|
||||||
className="inline-flex h-11 w-full items-center justify-center rounded-md bg-fuchsia-500 font-medium text-zinc-100 hover:bg-fuchsia-600"
|
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
|
<>
|
||||||
|
<span className="w-5" />
|
||||||
|
<span>Creating...</span>
|
||||||
|
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
'Continue →'
|
<>
|
||||||
|
<span className="w-5" />
|
||||||
|
<span>Continue</span>
|
||||||
|
<ArrowRightCircleIcon className="h-5 w-5" />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Resolver, useForm } from 'react-hook-form';
|
import { Resolver, useForm } from 'react-hook-form';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import { EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
|
import { EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
|
||||||
|
import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
|
||||||
|
|
||||||
import { useOnboarding } from '@stores/onboarding';
|
import { useOnboarding } from '@stores/onboarding';
|
||||||
import { useStronghold } from '@stores/stronghold';
|
import { useStronghold } from '@stores/stronghold';
|
||||||
@@ -29,13 +30,13 @@ const resolver: Resolver<FormValues> = async (values) => {
|
|||||||
|
|
||||||
export function ImportStep2Screen() {
|
export function ImportStep2Screen() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const setStep = useOnboarding((state) => state.setStep);
|
||||||
|
const pubkey = useOnboarding((state) => state.pubkey);
|
||||||
|
const privkey = useStronghold((state) => state.privkey);
|
||||||
|
|
||||||
const [passwordInput, setPasswordInput] = useState('password');
|
const [passwordInput, setPasswordInput] = useState('password');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const privkey = useStronghold((state) => state.privkey);
|
|
||||||
const pubkey = useOnboarding((state) => state.pubkey);
|
|
||||||
|
|
||||||
const { save } = useSecureStorage();
|
const { save } = useSecureStorage();
|
||||||
|
|
||||||
// toggle private key
|
// toggle private key
|
||||||
@@ -71,10 +72,15 @@ export function ImportStep2Screen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// save current step, if user close app and reopen it
|
||||||
|
setStep('/auth/import/step-2');
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-md">
|
<div className="mx-auto w-full max-w-md">
|
||||||
<div className="mb-8 text-center">
|
<div className="mb-8 text-center">
|
||||||
<h1 className="text-xl font-semibold text-zinc-100">
|
<h1 className="text-xl font-semibold text-white">
|
||||||
Set password to secure your key
|
Set password to secure your key
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
@@ -85,35 +91,25 @@ export function ImportStep2Screen() {
|
|||||||
<input
|
<input
|
||||||
{...register('password', { required: true })}
|
{...register('password', { required: true })}
|
||||||
type={passwordInput}
|
type={passwordInput}
|
||||||
className="relative w-full rounded-lg bg-zinc-800 py-3 pl-3.5 pr-11 text-zinc-100 !outline-none placeholder:text-zinc-400"
|
className="relative h-11 w-full rounded-lg bg-white/10 px-3.5 py-1 text-center text-white !outline-none placeholder:text-white/50"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => showPassword()}
|
onClick={() => showPassword()}
|
||||||
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-zinc-700"
|
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-white/10"
|
||||||
>
|
>
|
||||||
{passwordInput === 'password' ? (
|
{passwordInput === 'password' ? (
|
||||||
<EyeOffIcon
|
<EyeOffIcon className="h-4 w-4 text-white/50 group-hover:text-white" />
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
className="text-zinc-500 group-hover:text-zinc-100"
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<EyeOnIcon
|
<EyeOnIcon className="h-4 w-4 text-white/50 group-hover:text-white" />
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
className="text-zinc-500 group-hover:text-zinc-100"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-zinc-500">
|
<p className="text-sm text-white/50">
|
||||||
<p>
|
Password is use to unlock app and secure your key store in local machine.
|
||||||
Password is use to unlock app and secure your key store in local machine.
|
When you move to other clients, you just need to copy your private key as
|
||||||
When you move to other clients, you just need to copy your private key as
|
nsec or hexstring
|
||||||
nsec or hexstring
|
</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-red-400">
|
<span className="text-sm text-red-400">
|
||||||
{errors.password && <p>{errors.password.message}</p>}
|
{errors.password && <p>{errors.password.message}</p>}
|
||||||
</span>
|
</span>
|
||||||
@@ -122,12 +118,20 @@ export function ImportStep2Screen() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!isDirty || !isValid}
|
disabled={!isDirty || !isValid}
|
||||||
className="inline-flex h-11 w-full items-center justify-center rounded-md bg-fuchsia-500 font-medium text-zinc-100 hover:bg-fuchsia-600 disabled:pointer-events-none disabled:opacity-50"
|
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
|
<>
|
||||||
|
<span className="w-5" />
|
||||||
|
<span>Creating...</span>
|
||||||
|
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
'Continue →'
|
<>
|
||||||
|
<span className="w-5" />
|
||||||
|
<span>Continue</span>
|
||||||
|
<ArrowRightCircleIcon className="h-5 w-5" />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,85 +1,92 @@
|
|||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import { User } from '@app/auth/components/user';
|
import { User } from '@app/auth/components/user';
|
||||||
|
|
||||||
import { useNDK } from '@libs/ndk/provider';
|
import { updateLastLogin } from '@libs/storage';
|
||||||
import { updateAccount } from '@libs/storage';
|
|
||||||
|
|
||||||
import { Button } from '@shared/button';
|
import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
|
||||||
import { LoaderIcon } from '@shared/icons';
|
|
||||||
|
import { useOnboarding } from '@stores/onboarding';
|
||||||
|
|
||||||
import { useAccount } from '@utils/hooks/useAccount';
|
import { useAccount } from '@utils/hooks/useAccount';
|
||||||
import { setToArray } from '@utils/transform';
|
import { useNostr } from '@utils/hooks/useNostr';
|
||||||
|
|
||||||
export function ImportStep3Screen() {
|
export function ImportStep3Screen() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const setStep = useOnboarding((state) => state.setStep);
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const { ndk } = useNDK();
|
|
||||||
const { status, account } = useAccount();
|
const { status, account } = useAccount();
|
||||||
|
const { fetchNotes, fetchChats } = useNostr();
|
||||||
const update = useMutation({
|
|
||||||
mutationFn: (follows: string[]) => {
|
|
||||||
return updateAccount('follows', follows, account.pubkey);
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['currentAccount'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
try {
|
try {
|
||||||
// show loading indicator
|
// show loading indicator
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const user = ndk.getUser({ hexpubkey: account.pubkey });
|
const now = Math.floor(Date.now() / 1000);
|
||||||
const follows = await user.follows();
|
await fetchNotes();
|
||||||
|
await fetchChats();
|
||||||
|
await updateLastLogin(now);
|
||||||
|
|
||||||
// follows as list
|
queryClient.invalidateQueries(['currentAccount']);
|
||||||
const followsList = setToArray(follows);
|
|
||||||
|
|
||||||
// update
|
navigate('/auth/onboarding/step-2', { replace: true });
|
||||||
update.mutate([...followsList, account.pubkey]);
|
} catch (e) {
|
||||||
|
console.log('error: ', e);
|
||||||
// redirect to next step
|
setLoading(false);
|
||||||
setTimeout(() => navigate('/auth/onboarding', { replace: true }), 1200);
|
|
||||||
} catch {
|
|
||||||
console.log('error');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// save current step, if user close app and reopen it
|
||||||
|
setStep('/auth/import/step-3');
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-md">
|
<div className="mx-auto w-full max-w-md">
|
||||||
<div className="mb-8 text-center">
|
<div className="mb-8 text-center">
|
||||||
<h1 className="text-xl font-semibold">
|
<h1 className="text-xl font-semibold">
|
||||||
{loading ? 'Creating...' : 'Continue with'}
|
{loading ? 'Prefetching data...' : 'Continue with'}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full rounded-xl border-t border-zinc-800/50 bg-zinc-900 p-4">
|
<div className="w-full rounded-xl bg-white/10 p-4">
|
||||||
{status === 'loading' ? (
|
{status === 'loading' ? (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="h-11 w-11 animate-pulse rounded-lg bg-zinc-800" />
|
<div className="h-11 w-11 animate-pulse rounded-lg bg-white/10" />
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-1 h-4 w-16 animate-pulse rounded bg-zinc-800" />
|
<div className="mb-1 h-4 w-16 animate-pulse rounded bg-white/10" />
|
||||||
<div className="h-3 w-36 animate-pulse rounded bg-zinc-800" />
|
<div className="h-3 w-36 animate-pulse rounded bg-white/10" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<User pubkey={account.pubkey} />
|
<User pubkey={account.pubkey} />
|
||||||
<Button preset="large" onClick={() => submit()}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
|
||||||
|
onClick={() => submit()}
|
||||||
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
|
<>
|
||||||
|
<span className="w-5" />
|
||||||
|
<span>It might take a bit, please patient...</span>
|
||||||
|
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
'Continue →'
|
<>
|
||||||
|
<span className="w-5" />
|
||||||
|
<span>Continue</span>
|
||||||
|
<ArrowRightCircleIcon className="h-5 w-5" />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export function MigrateScreen() {
|
|||||||
<div className="flex h-full w-full items-center justify-center">
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
<div className="mx-auto w-full max-w-md">
|
<div className="mx-auto w-full max-w-md">
|
||||||
<div className="mb-8 text-center">
|
<div className="mb-8 text-center">
|
||||||
<h1 className="text-xl font-semibold text-zinc-100">
|
<h1 className="text-xl font-semibold text-white">
|
||||||
Upgrade security for your account
|
Upgrade security for your account
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
@@ -100,15 +100,15 @@ export function MigrateScreen() {
|
|||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<p className="text-sm text-zinc-400">
|
<p className="text-sm text-white/50">
|
||||||
You're using old Lume version which store your private key as
|
You're using old Lume version which store your private key as
|
||||||
plaintext in database, this is huge security risk.
|
plaintext in database, this is huge security risk.
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-sm text-zinc-400">
|
<p className="mt-2 text-sm text-white/50">
|
||||||
To secure your private key, please set a password and Lume will put your
|
To secure your private key, please set a password and Lume will put your
|
||||||
private key in secure storage.
|
private key in secure storage.
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-sm text-zinc-400">
|
<p className="mt-2 text-sm text-white/50">
|
||||||
It is not possible to start the app without applying this step, it is
|
It is not possible to start the app without applying this step, it is
|
||||||
easy and fast!
|
easy and fast!
|
||||||
</p>
|
</p>
|
||||||
@@ -124,7 +124,7 @@ export function MigrateScreen() {
|
|||||||
{...register('password', { required: true })}
|
{...register('password', { required: true })}
|
||||||
type={passwordInput}
|
type={passwordInput}
|
||||||
placeholder="min. 4 characters"
|
placeholder="min. 4 characters"
|
||||||
className="relative w-full rounded-lg bg-zinc-800 py-3 pl-3.5 pr-11 text-zinc-100 !outline-none placeholder:text-zinc-400"
|
className="relative w-full rounded-lg bg-zinc-800 py-3 pl-3.5 pr-11 text-white !outline-none placeholder:text-white/50"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -135,13 +135,13 @@ export function MigrateScreen() {
|
|||||||
<EyeOffIcon
|
<EyeOffIcon
|
||||||
width={20}
|
width={20}
|
||||||
height={20}
|
height={20}
|
||||||
className="text-zinc-500 group-hover:text-zinc-100"
|
className="text-white/50 group-hover:text-white"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<EyeOnIcon
|
<EyeOnIcon
|
||||||
width={20}
|
width={20}
|
||||||
height={20}
|
height={20}
|
||||||
className="text-zinc-500 group-hover:text-zinc-100"
|
className="text-white/50 group-hover:text-white"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -154,10 +154,10 @@ export function MigrateScreen() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!isDirty || !isValid}
|
disabled={!isDirty || !isValid}
|
||||||
className="mt-3 inline-flex h-11 w-full items-center justify-center rounded-md bg-fuchsia-500 font-medium text-zinc-100 hover:bg-fuchsia-600 disabled:pointer-events-none disabled:opacity-50"
|
className="mt-3 inline-flex h-11 w-full items-center justify-center rounded-md bg-fuchsia-500 font-medium text-white hover:bg-fuchsia-600 disabled:pointer-events-none disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
|
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-white" />
|
||||||
) : (
|
) : (
|
||||||
'Continue →'
|
'Continue →'
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { LoaderIcon } from '@shared/icons';
|
|
||||||
import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
|
|
||||||
import { User } from '@shared/user';
|
|
||||||
|
|
||||||
import { useAccount } from '@utils/hooks/useAccount';
|
|
||||||
import { usePublish } from '@utils/hooks/usePublish';
|
|
||||||
|
|
||||||
export function OnboardingScreen() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const { publish } = usePublish();
|
|
||||||
const { status, account } = useAccount();
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const submit = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
// publish event
|
|
||||||
publish({
|
|
||||||
content: 'Running Lume, join with me #nostr #lume : https://lume.nu',
|
|
||||||
kind: 1,
|
|
||||||
tags: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
// redirect to home
|
|
||||||
setTimeout(() => navigate('/', { replace: true }), 1200);
|
|
||||||
} catch (error) {
|
|
||||||
console.log(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
|
||||||
<div className="mx-auto w-full max-w-md">
|
|
||||||
<div className="mb-4 text-center">
|
|
||||||
<h1 className="mb-2 text-xl font-semibold text-zinc-100">
|
|
||||||
👋 Hello, welcome you to Lume
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-zinc-300">
|
|
||||||
You're a part of Nostr community now
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-zinc-300">
|
|
||||||
If Lume gets your attention, please help us spread it and don't forget
|
|
||||||
invite your friend join with you, we can have fun togother
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="w-full rounded-xl border-t border-zinc-800/50 bg-zinc-900">
|
|
||||||
<div className="h-min w-full px-5 py-3">
|
|
||||||
{status === 'success' && (
|
|
||||||
<User pubkey={account.pubkey} time={Math.floor(Date.now() / 1000)} />
|
|
||||||
)}
|
|
||||||
<div className="-mt-6 select-text whitespace-pre-line break-words pl-[49px] text-base text-zinc-100">
|
|
||||||
<p>Running Lume, join with me #nostr #lume</p>
|
|
||||||
<a
|
|
||||||
href="https://lume.nu"
|
|
||||||
className="font-normal text-fuchsia-500 no-underline hover:text-fuchsia-600"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
https://lume.nu
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 flex w-full flex-col gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => submit()}
|
|
||||||
className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium text-zinc-100 hover:bg-fuchsia-600"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<>
|
|
||||||
<span className="w-5" />
|
|
||||||
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
|
|
||||||
<span className="w-5" />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span className="w-5" />
|
|
||||||
<span>Spread</span>
|
|
||||||
<ArrowRightCircleIcon className="h-5 w-5" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<Link
|
|
||||||
to="/"
|
|
||||||
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-lg bg-zinc-800 px-6 font-medium text-zinc-300 hover:bg-zinc-900"
|
|
||||||
>
|
|
||||||
Skip
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
22
src/app/auth/onboarding/index.tsx
Normal file
22
src/app/auth/onboarding/index.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Outlet } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { useOnboarding } from '@stores/onboarding';
|
||||||
|
import { useStronghold } from '@stores/stronghold';
|
||||||
|
|
||||||
|
export function OnboardingScreen() {
|
||||||
|
const [step, tmpPrivkey] = useOnboarding((state) => [state.step, state.tempPrivkey]);
|
||||||
|
const setPrivkey = useStronghold((state) => state.setPrivkey);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (step) {
|
||||||
|
setPrivkey(tmpPrivkey);
|
||||||
|
}
|
||||||
|
}, [tmpPrivkey]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
136
src/app/auth/onboarding/step-1.tsx
Normal file
136
src/app/auth/onboarding/step-1.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { User } from '@app/auth/components/user';
|
||||||
|
|
||||||
|
import { updateAccount } from '@libs/storage';
|
||||||
|
|
||||||
|
import { ArrowRightCircleIcon, CheckCircleIcon, LoaderIcon } from '@shared/icons';
|
||||||
|
|
||||||
|
import { useOnboarding } from '@stores/onboarding';
|
||||||
|
|
||||||
|
import { useAccount } from '@utils/hooks/useAccount';
|
||||||
|
import { useNostr } from '@utils/hooks/useNostr';
|
||||||
|
import { arrayToNIP02 } from '@utils/transform';
|
||||||
|
|
||||||
|
export function OnboardStep1Screen() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const setStep = useOnboarding((state) => state.setStep);
|
||||||
|
|
||||||
|
const { publish, fetchNotes } = useNostr();
|
||||||
|
const { account } = useAccount();
|
||||||
|
const { status, data } = useQuery(['trending-profiles'], async () => {
|
||||||
|
const res = await fetch('https://api.nostr.band/v0/trending/profiles');
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('Error');
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
});
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [follows, setFollows] = useState([]);
|
||||||
|
|
||||||
|
// toggle follow state
|
||||||
|
const toggleFollow = (pubkey: string) => {
|
||||||
|
const arr = follows.includes(pubkey)
|
||||||
|
? follows.filter((i) => i !== pubkey)
|
||||||
|
: [...follows, pubkey];
|
||||||
|
setFollows(arr);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const tags = arrayToNIP02([...follows, account.pubkey]);
|
||||||
|
const event = await publish({ content: '', kind: 3, tags: tags });
|
||||||
|
await updateAccount('follows', follows);
|
||||||
|
|
||||||
|
// prefetch notes with current follows
|
||||||
|
const notes = await fetchNotes(follows);
|
||||||
|
|
||||||
|
// redirect to next step
|
||||||
|
if (event && notes) {
|
||||||
|
setTimeout(() => {
|
||||||
|
queryClient.invalidateQueries(['currentAccount']);
|
||||||
|
navigate('/auth/onboarding/step-2', { replace: true });
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.log('error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// save current step, if user close app and reopen it
|
||||||
|
setStep('/auth/onboarding');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-md">
|
||||||
|
<div className="mb-8 text-center">
|
||||||
|
<h1 className="text-xl font-semibold text-white">
|
||||||
|
{loading ? 'Prefetching data...' : 'Enrich your network'}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-white/50">Choose account you want to follow</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="scrollbar-hide flex h-[500px] w-full flex-col overflow-y-auto rounded-xl bg-white/10">
|
||||||
|
{status === 'loading' ? (
|
||||||
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
|
<LoaderIcon className="h-4 w-4 animate-spin text-white" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
data?.profiles.map(
|
||||||
|
(item: { pubkey: string; profile: { content: string } }) => (
|
||||||
|
<button
|
||||||
|
key={item.pubkey}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleFollow(item.pubkey)}
|
||||||
|
className="inline-flex transform items-center justify-between bg-white/10 px-4 py-2 hover:bg-white/20"
|
||||||
|
>
|
||||||
|
<User pubkey={item.pubkey} fallback={item.profile?.content} />
|
||||||
|
{follows.includes(item.pubkey) && (
|
||||||
|
<div>
|
||||||
|
<CheckCircleIcon className="h-4 w-4 text-green-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={submit}
|
||||||
|
disabled={loading || follows.length === 0}
|
||||||
|
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<span className="w-5" />
|
||||||
|
<span>It might take a bit, please patient...</span>
|
||||||
|
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="w-5" />
|
||||||
|
<span>Follow {follows.length} accounts & Continue</span>
|
||||||
|
<ArrowRightCircleIcon className="h-5 w-5" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
to="/auth/onboarding/step-2"
|
||||||
|
className="inline-flex h-11 w-full items-center justify-center rounded-lg px-6 font-medium leading-none text-white hover:bg-white/10 focus:outline-none"
|
||||||
|
>
|
||||||
|
Skip, you can add later
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
src/app/auth/onboarding/step-2.tsx
Normal file
124
src/app/auth/onboarding/step-2.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { createWidget } from '@libs/storage';
|
||||||
|
|
||||||
|
import { ArrowRightCircleIcon, CheckCircleIcon, LoaderIcon } from '@shared/icons';
|
||||||
|
|
||||||
|
import { BLOCK_KINDS } from '@stores/constants';
|
||||||
|
import { useOnboarding } from '@stores/onboarding';
|
||||||
|
|
||||||
|
const data = [
|
||||||
|
{ hashtag: '#bitcoin' },
|
||||||
|
{ hashtag: '#nostr' },
|
||||||
|
{ hashtag: '#zap' },
|
||||||
|
{ hashtag: '#LFG' },
|
||||||
|
{ hashtag: '#zapchain' },
|
||||||
|
{ hashtag: '#plebchain' },
|
||||||
|
{ hashtag: '#nodes' },
|
||||||
|
{ hashtag: '#hodl' },
|
||||||
|
{ hashtag: '#stacksats' },
|
||||||
|
{ hashtag: '#nokyc' },
|
||||||
|
{ hashtag: '#anime' },
|
||||||
|
{ hashtag: '#waifu' },
|
||||||
|
{ hashtag: '#manga' },
|
||||||
|
{ hashtag: '#nostriches' },
|
||||||
|
{ hashtag: '#dev' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function OnboardStep2Screen() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const setStep = useOnboarding((state) => state.setStep);
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [tags, setTags] = useState(new Set<string>());
|
||||||
|
|
||||||
|
const toggleTag = (tag: string) => {
|
||||||
|
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 () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
for (const tag of tags) {
|
||||||
|
await createWidget(BLOCK_KINDS.hashtag, tag, tag.replace('#', ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => navigate('/auth/onboarding/step-3', { replace: true }), 1000);
|
||||||
|
} catch {
|
||||||
|
console.log('error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// save current step, if user close app and reopen it
|
||||||
|
setStep('/auth/onboarding/step-2');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-md">
|
||||||
|
<div className="mb-8 text-center">
|
||||||
|
<h1 className="text-xl font-semibold text-white">
|
||||||
|
Choose {tags.size}/3 your favorite tags
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-white/50">Customize your space which hashtag widget</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="scrollbar-hide flex h-[500px] w-full flex-col overflow-y-auto rounded-xl bg-white/10">
|
||||||
|
{data.map((item: { hashtag: string }) => (
|
||||||
|
<button
|
||||||
|
key={item.hashtag}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleTag(item.hashtag)}
|
||||||
|
className="inline-flex transform items-center justify-between bg-white/10 px-4 py-2 hover:bg-white/20"
|
||||||
|
>
|
||||||
|
<p className="text-white">{item.hashtag}</p>
|
||||||
|
{tags.has(item.hashtag) && (
|
||||||
|
<div>
|
||||||
|
<CheckCircleIcon className="h-4 w-4 text-green-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={submit}
|
||||||
|
disabled={loading || tags.size === 0 || tags.size > 3}
|
||||||
|
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<span className="w-5" />
|
||||||
|
<span>Creating...</span>
|
||||||
|
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="w-5" />
|
||||||
|
<span>Add {tags.size} tags & Continue</span>
|
||||||
|
<ArrowRightCircleIcon className="h-5 w-5" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
to="/auth/onboarding/step-3"
|
||||||
|
className="inline-flex h-11 w-full items-center justify-center rounded-lg px-6 font-medium leading-none text-white hover:bg-white/10 focus:outline-none"
|
||||||
|
>
|
||||||
|
Skip, you can add later
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
183
src/app/auth/onboarding/step-3.tsx
Normal file
183
src/app/auth/onboarding/step-3.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { UserRelay } from '@app/auth/components/userRelay';
|
||||||
|
|
||||||
|
import { useNDK } from '@libs/ndk/provider';
|
||||||
|
import { createRelay } from '@libs/storage';
|
||||||
|
|
||||||
|
import { ArrowRightCircleIcon, CheckCircleIcon, LoaderIcon } from '@shared/icons';
|
||||||
|
|
||||||
|
import { FULL_RELAYS } from '@stores/constants';
|
||||||
|
import { useOnboarding } from '@stores/onboarding';
|
||||||
|
|
||||||
|
import { useAccount } from '@utils/hooks/useAccount';
|
||||||
|
import { useNostr } from '@utils/hooks/useNostr';
|
||||||
|
|
||||||
|
export function OnboardStep3Screen() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [setStep, clearStep] = useOnboarding((state) => [state.setStep, state.clearStep]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [relays, setRelays] = useState(new Set<string>());
|
||||||
|
|
||||||
|
const { publish } = useNostr();
|
||||||
|
const { account } = useAccount();
|
||||||
|
const { ndk } = useNDK();
|
||||||
|
const { status, data } = useQuery(
|
||||||
|
['relays'],
|
||||||
|
async () => {
|
||||||
|
const tmp = new Map<string, string>();
|
||||||
|
const events = await ndk.fetchEvents({ kinds: [10002], authors: account.follows });
|
||||||
|
|
||||||
|
if (events) {
|
||||||
|
events.forEach((event) => {
|
||||||
|
event.tags.forEach((tag) => {
|
||||||
|
tmp.set(tag[1], event.pubkey);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return tmp;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: account ? true : false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleRelay = (relay: string) => {
|
||||||
|
if (relays.has(relay)) {
|
||||||
|
setRelays((prev) => {
|
||||||
|
prev.delete(relay);
|
||||||
|
return new Set(prev);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setRelays((prev) => new Set(prev.add(relay)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async (skip?: boolean) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
if (!skip) {
|
||||||
|
for (const relay of relays) {
|
||||||
|
await createRelay(relay);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = Array.from(relays).map((relay) => ['r', relay.replace(/\/+$/, '')]);
|
||||||
|
await publish({ content: '', kind: 10002, tags: tags });
|
||||||
|
} else {
|
||||||
|
for (const relay of FULL_RELAYS) {
|
||||||
|
await createRelay(relay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
clearStep();
|
||||||
|
navigate('/', { replace: true });
|
||||||
|
}, 1000);
|
||||||
|
} catch (e) {
|
||||||
|
setLoading(false);
|
||||||
|
console.log('error: ', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const relaysAsArray = Array.from(data?.keys() || []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// save current step, if user close app and reopen it
|
||||||
|
setStep('/auth/onboarding/step-3');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-md">
|
||||||
|
<div className="mb-8 text-center">
|
||||||
|
<h1 className="text-xl font-semibold text-white">Relay discovery</h1>
|
||||||
|
<p className="text-sm text-white/50">
|
||||||
|
You can add relay which is using by who you're following to easier reach
|
||||||
|
their content. Learn more about relay{' '}
|
||||||
|
<a
|
||||||
|
href="https://nostr.com/relays"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-fuchsia-500 underline"
|
||||||
|
>
|
||||||
|
here (nostr.com)
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="scrollbar-hide relative flex h-[500px] w-full flex-col divide-y divide-white/10 overflow-y-auto rounded-xl bg-white/10">
|
||||||
|
{status === 'loading' ? (
|
||||||
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
|
<LoaderIcon className="h-4 w-4 animate-spin text-white" />
|
||||||
|
</div>
|
||||||
|
) : relaysAsArray.length === 0 ? (
|
||||||
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
|
<p className="text-center text-white/50">
|
||||||
|
Can't found any relays, you can skip this step and use default relays
|
||||||
|
instead
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
relaysAsArray.map((item, index) => (
|
||||||
|
<button
|
||||||
|
key={item + index}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleRelay(item)}
|
||||||
|
className="inline-flex transform items-start justify-between bg-white/10 px-4 py-2 hover:bg-white/20"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-start gap-1">
|
||||||
|
<p className="max-w-[15rem] truncate">{item.replace(/\/+$/, '')}</p>
|
||||||
|
<UserRelay pubkey={data.get(item)} />
|
||||||
|
</div>
|
||||||
|
{relays.has(item) && (
|
||||||
|
<div className="pt-1.5">
|
||||||
|
<CheckCircleIcon className="h-4 w-4 text-green-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{relays.size > 5 && (
|
||||||
|
<div className="sticky bottom-0 left-0 inline-flex w-full items-center justify-center bg-white/10 px-4 py-2 backdrop-blur-2xl">
|
||||||
|
<p className="text-sm text-orange-400">
|
||||||
|
Using too much relay can cause high resource usage
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => submit()}
|
||||||
|
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<span className="w-5" />
|
||||||
|
<span>Creating...</span>
|
||||||
|
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="w-5" />
|
||||||
|
<span>Add {relays.size} relays & Continue</span>
|
||||||
|
<ArrowRightCircleIcon className="h-5 w-5" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => submit(true)}
|
||||||
|
className="inline-flex h-11 w-full items-center justify-center rounded-lg px-6 font-medium leading-none text-white hover:bg-white/10 focus:outline-none"
|
||||||
|
>
|
||||||
|
Skip, use default relays
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -102,74 +102,64 @@ export function ResetScreen() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
<div className="mx-auto w-full max-w-md">
|
<div className="mx-auto w-full max-w-md">
|
||||||
<div className="mb-8 text-center">
|
<div className="mb-6 text-center">
|
||||||
<h1 className="text-xl font-semibold text-zinc-100">Reset unlock password</h1>
|
<h1 className="text-2xl font-semibold text-white">Reset unlock password</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4">
|
<form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col gap-3">
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col gap-3">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex flex-col gap-1">
|
<label htmlFor="privkey" className="font-medium text-white/50">
|
||||||
<label htmlFor="privkey" className="font-medium text-zinc-200">
|
Private key
|
||||||
Private key
|
</label>
|
||||||
</label>
|
<div className="relative">
|
||||||
<div className="relative">
|
<input
|
||||||
<input
|
{...register('privkey', { required: true })}
|
||||||
{...register('privkey', { required: true })}
|
type="text"
|
||||||
type="text"
|
placeholder="nsec..."
|
||||||
placeholder="nsec..."
|
className="relative h-12 w-full rounded-lg bg-white/10 px-3.5 py-1 text-white !outline-none placeholder:text-white/10"
|
||||||
className="relative w-full rounded-lg bg-zinc-800 px-3.5 py-3 text-zinc-100 !outline-none placeholder:text-zinc-400"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
</div>
|
||||||
<label htmlFor="password" className="font-medium text-zinc-200">
|
<div className="flex flex-col gap-1">
|
||||||
Set a new password to protect your key
|
<label htmlFor="password" className="font-medium text-white/50">
|
||||||
</label>
|
Set a new password to protect your key
|
||||||
<div className="relative">
|
</label>
|
||||||
<input
|
<div className="relative">
|
||||||
{...register('password', { required: true })}
|
<input
|
||||||
type={passwordInput}
|
{...register('password', { required: true })}
|
||||||
placeholder="min. 4 characters"
|
type={passwordInput}
|
||||||
className="relative w-full rounded-lg bg-zinc-800 py-3 pl-3.5 pr-11 text-zinc-100 !outline-none placeholder:text-zinc-400"
|
placeholder="min. 4 characters"
|
||||||
/>
|
className="relative h-12 w-full rounded-lg bg-white/10 px-3.5 py-1 text-white !outline-none placeholder:text-white/10"
|
||||||
<button
|
/>
|
||||||
type="button"
|
|
||||||
onClick={() => showPassword()}
|
|
||||||
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-zinc-700"
|
|
||||||
>
|
|
||||||
{passwordInput === 'password' ? (
|
|
||||||
<EyeOffIcon
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
className="text-zinc-500 group-hover:text-zinc-100"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<EyeOnIcon
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
className="text-zinc-500 group-hover:text-zinc-100"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-red-400">
|
|
||||||
{errors.password && <p>{errors.password.message}</p>}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-center">
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="button"
|
||||||
disabled={!isDirty || !isValid}
|
onClick={() => showPassword()}
|
||||||
className="mt-3 inline-flex h-11 w-full items-center justify-center rounded-md bg-fuchsia-500 font-medium text-zinc-100 hover:bg-fuchsia-600 disabled:pointer-events-none disabled:opacity-50"
|
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-white/10"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{passwordInput === 'password' ? (
|
||||||
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
|
<EyeOffIcon className="h-5 w-5 text-white/50 group-hover:text-white" />
|
||||||
) : (
|
) : (
|
||||||
'Continue →'
|
<EyeOnIcon className="h-5 w-5 text-white/50 group-hover:text-white" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
<span className="text-sm text-red-400">
|
||||||
</div>
|
{errors.password && <p>{errors.password.message}</p>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!isDirty || !isValid}
|
||||||
|
className="inline-flex h-12 w-full items-center justify-center rounded-md bg-fuchsia-500 font-medium text-white hover:bg-fuchsia-600 disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<LoaderIcon className="h-4 w-4 animate-spin text-white" />
|
||||||
|
) : (
|
||||||
|
'Continue →'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -81,65 +81,53 @@ export function UnlockScreen() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
<div className="mx-auto w-full max-w-md">
|
<div className="mx-auto w-full max-w-md">
|
||||||
<div className="mb-8 text-center">
|
<div className="mb-6 text-center">
|
||||||
<h1 className="text-xl font-semibold text-zinc-100">
|
<h1 className="text-2xl font-semibold text-white">Enter password to unlock</h1>
|
||||||
Enter password to unlock
|
|
||||||
</h1>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4">
|
<form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col gap-3">
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-3">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="relative">
|
||||||
<div className="relative">
|
<input
|
||||||
<input
|
{...register('password', { required: true })}
|
||||||
{...register('password', { required: true })}
|
type={passwordInput}
|
||||||
type={passwordInput}
|
className="relative h-12 w-full rounded-lg bg-white/10 py-1 text-center text-white !outline-none placeholder:text-white/10"
|
||||||
className="relative w-full rounded-lg bg-zinc-800 py-3 text-center text-zinc-100 !outline-none placeholder:text-zinc-400"
|
/>
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => showPassword()}
|
|
||||||
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-zinc-700"
|
|
||||||
>
|
|
||||||
{passwordInput === 'password' ? (
|
|
||||||
<EyeOffIcon
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
className="text-zinc-500 group-hover:text-zinc-100"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<EyeOnIcon
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
className="text-zinc-500 group-hover:text-zinc-100"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-red-400">
|
|
||||||
{errors.password && <p>{errors.password.message}</p>}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-center justify-center">
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="button"
|
||||||
disabled={!isDirty || !isValid}
|
onClick={() => showPassword()}
|
||||||
className="inline-flex h-11 w-full items-center justify-center rounded-md bg-fuchsia-500 font-medium text-zinc-100 hover:bg-fuchsia-600"
|
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-white/10"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{passwordInput === 'password' ? (
|
||||||
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
|
<EyeOffIcon className="h-5 w-5 text-white/50 group-hover:text-white" />
|
||||||
) : (
|
) : (
|
||||||
'Continue →'
|
<EyeOnIcon className="h-5 w-5 text-white/50 group-hover:text-white" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<Link
|
|
||||||
to="/auth/reset"
|
|
||||||
className="inline-flex h-12 items-center justify-center text-center text-sm text-zinc-400"
|
|
||||||
>
|
|
||||||
Reset password
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
<span className="text-sm text-red-400">
|
||||||
</div>
|
{errors.password && <p>{errors.password.message}</p>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center justify-center">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!isDirty || !isValid}
|
||||||
|
className="inline-flex h-12 w-full items-center justify-center rounded-md bg-fuchsia-500 font-medium text-white hover:bg-fuchsia-600"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<LoaderIcon className="h-4 w-4 animate-spin text-white" />
|
||||||
|
) : (
|
||||||
|
'Continue →'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
to="/auth/reset"
|
||||||
|
className="inline-flex h-14 items-center justify-center text-center text-white/50"
|
||||||
|
>
|
||||||
|
Reset password
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,29 +1,41 @@
|
|||||||
|
import { LogicalSize, appWindow } from '@tauri-apps/plugin-window';
|
||||||
|
import { useEffect } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
|
import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
|
||||||
|
|
||||||
export function WelcomeScreen() {
|
export function WelcomeScreen() {
|
||||||
|
useEffect(() => {
|
||||||
|
async function setWindow() {
|
||||||
|
await appWindow.setSize(new LogicalSize(400, 500));
|
||||||
|
await appWindow.setResizable(false);
|
||||||
|
await appWindow.center();
|
||||||
|
}
|
||||||
|
|
||||||
|
setWindow();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
appWindow.setSize(new LogicalSize(1080, 800)).then(() => {
|
||||||
|
appWindow.setResizable(false);
|
||||||
|
appWindow.center();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid h-full w-full grid-cols-12 gap-4 px-4 py-4">
|
<div className="flex h-screen w-full flex-col justify-between bg-white/10">
|
||||||
<div className="col-span-5 flex flex-col rounded-xl border-t border-zinc-800/50 bg-zinc-900">
|
<div className="flex flex-col gap-10 pt-16">
|
||||||
<div className="flex h-full w-full flex-col justify-center gap-2 px-4 py-4">
|
<div className="flex flex-col gap-2 text-center">
|
||||||
<h1 className="text-4xl font-bold leading-none text-transparent text-zinc-700">
|
<h1 className="text-3xl font-medium text-white">Welcome to Lume</h1>
|
||||||
Preserve your <span className="text-fuchsia-300">freedom</span>
|
<h3 className="mx-auto w-2/3 text-white/50">
|
||||||
</h1>
|
Let's get you up and connecting with all peoples around the world on
|
||||||
<h2 className="text-4xl font-bold leading-none text-transparent text-zinc-700">
|
Nostr
|
||||||
Protect your <span className="text-red-300">future</span>
|
|
||||||
</h2>
|
|
||||||
<h3 className="text-4xl font-bold leading-none text-transparent text-zinc-700">
|
|
||||||
Stack <span className="text-orange-300">bitcoin</span>
|
|
||||||
</h3>
|
|
||||||
<h3 className="text-4xl font-bold leading-none text-transparent text-zinc-700">
|
|
||||||
Use <span className="text-purple-300">nostr</span>
|
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-auto flex w-full flex-col gap-2 px-4 py-4">
|
<div className="inline-flex w-full flex-col items-center gap-3 px-4 pb-10">
|
||||||
<Link
|
<Link
|
||||||
to="/auth/import"
|
to="/auth/import"
|
||||||
className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium text-zinc-100 hover:bg-fuchsia-600"
|
className="inline-flex h-11 w-2/3 items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
|
||||||
>
|
>
|
||||||
<span className="w-5" />
|
<span className="w-5" />
|
||||||
<span>Login with private key</span>
|
<span>Login with private key</span>
|
||||||
@@ -31,24 +43,15 @@ export function WelcomeScreen() {
|
|||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/auth/create"
|
to="/auth/create"
|
||||||
className="inline-flex h-12 w-full items-center justify-center gap-2 rounded-lg bg-zinc-800 px-6 font-medium text-zinc-200 hover:bg-zinc-700"
|
className="inline-flex h-11 w-2/3 items-center justify-center gap-2 rounded-lg bg-white/10 px-6 font-medium leading-none text-zinc-200 hover:bg-white/20 focus:outline-none"
|
||||||
>
|
>
|
||||||
Create new key
|
Create new key
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="flex flex-1 items-end justify-center pb-10">
|
||||||
className="col-span-5 rounded-xl bg-zinc-900 bg-cover bg-center"
|
<img src="/lume.png" alt="lume" className="h-auto w-1/3" />
|
||||||
style={{
|
</div>
|
||||||
backgroundImage: `url("https://void.cat/d/Ps1b36vu5pdkEA2w75usuB")`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="col-span-2 rounded-xl bg-zinc-900 bg-cover bg-center"
|
|
||||||
style={{
|
|
||||||
backgroundImage: `url("https://void.cat/d/5FdJcBP5ZXKAjYqV8hpcp3")`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export function ChannelBlackList({ blacklist }: { blacklist: any }) {
|
|||||||
<MuteIcon
|
<MuteIcon
|
||||||
width={16}
|
width={16}
|
||||||
height={16}
|
height={16}
|
||||||
className="text-zinc-400 group-hover:text-zinc-100"
|
className="text-white/50 group-hover:text-white"
|
||||||
/>
|
/>
|
||||||
</Popover.Button>
|
</Popover.Button>
|
||||||
<Transition
|
<Transition
|
||||||
@@ -37,7 +37,7 @@ export function ChannelBlackList({ blacklist }: { blacklist: any }) {
|
|||||||
<h3 className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text font-semibold leading-none text-transparent">
|
<h3 className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text font-semibold leading-none text-transparent">
|
||||||
Your muted list
|
Your muted list
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-base leading-tight text-zinc-400">
|
<p className="text-base leading-tight text-white/50">
|
||||||
Currently, unmute only affect locally, when you move to new client,
|
Currently, unmute only affect locally, when you move to new client,
|
||||||
muted list will loaded again
|
muted list will loaded again
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export function ChannelCreateModal() {
|
|||||||
// close modal
|
// close modal
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
// redirect to channel page
|
// redirect to channel page
|
||||||
navigate(`/app/channel/${event.id}`);
|
navigate(`/channel/${event.id}`);
|
||||||
}, 1000);
|
}, 1000);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('error: ', e);
|
console.log('error: ', e);
|
||||||
@@ -112,10 +112,10 @@ export function ChannelCreateModal() {
|
|||||||
className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5"
|
className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5"
|
||||||
>
|
>
|
||||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
|
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
|
||||||
<PlusIcon width={12} height={12} className="text-zinc-500" />
|
<PlusIcon width={12} height={12} className="text-white/50" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h5 className="font-medium text-zinc-400">Create channel</h5>
|
<h5 className="font-medium text-white/50">Create channel</h5>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<Transition appear show={isOpen} as={Fragment}>
|
<Transition appear show={isOpen} as={Fragment}>
|
||||||
@@ -147,7 +147,7 @@ export function ChannelCreateModal() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Dialog.Title
|
<Dialog.Title
|
||||||
as="h3"
|
as="h3"
|
||||||
className="text-lg font-semibold leading-none text-zinc-100"
|
className="text-lg font-semibold leading-none text-white"
|
||||||
>
|
>
|
||||||
Create channel
|
Create channel
|
||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
@@ -159,7 +159,7 @@ export function ChannelCreateModal() {
|
|||||||
<CancelIcon width={20} height={20} className="text-zinc-300" />
|
<CancelIcon width={20} height={20} className="text-zinc-300" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<Dialog.Description className="text-sm leading-tight text-zinc-400">
|
<Dialog.Description className="text-sm leading-tight text-white/50">
|
||||||
Channels are freedom square, everyone can speech freely, no one can
|
Channels are freedom square, everyone can speech freely, no one can
|
||||||
stop you or deceive what to speech
|
stop you or deceive what to speech
|
||||||
</Dialog.Description>
|
</Dialog.Description>
|
||||||
@@ -174,10 +174,10 @@ export function ChannelCreateModal() {
|
|||||||
type={'hidden'}
|
type={'hidden'}
|
||||||
{...register('picture')}
|
{...register('picture')}
|
||||||
value={image}
|
value={image}
|
||||||
className="shadow-input relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-100 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
className="shadow-input relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-black/5 !outline-none placeholder:text-white/50 dark:bg-zinc-800 dark:text-white dark:shadow-black/10 dark:placeholder:text-white/50"
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="text-sm font-medium uppercase tracking-wider text-zinc-400">
|
<span className="text-sm font-medium uppercase tracking-wider text-white/50">
|
||||||
Picture
|
Picture
|
||||||
</span>
|
</span>
|
||||||
<div className="relative inline-flex h-36 w-full items-center justify-center overflow-hidden rounded-lg border border-zinc-900 bg-zinc-950">
|
<div className="relative inline-flex h-36 w-full items-center justify-center overflow-hidden rounded-lg border border-zinc-900 bg-zinc-950">
|
||||||
@@ -195,7 +195,7 @@ export function ChannelCreateModal() {
|
|||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label
|
<label
|
||||||
htmlFor="name"
|
htmlFor="name"
|
||||||
className="text-sm font-semibold uppercase tracking-wider text-zinc-400"
|
className="text-sm font-semibold uppercase tracking-wider text-white/50"
|
||||||
>
|
>
|
||||||
Channel name *
|
Channel name *
|
||||||
</label>
|
</label>
|
||||||
@@ -206,28 +206,28 @@ export function ChannelCreateModal() {
|
|||||||
minLength: 4,
|
minLength: 4,
|
||||||
})}
|
})}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
className="relative h-10 w-full rounded-lg bg-zinc-800 px-3 py-2 text-zinc-100 !outline-none placeholder:text-zinc-500"
|
className="relative h-10 w-full rounded-lg bg-zinc-800 px-3 py-2 text-white !outline-none placeholder:text-white/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label
|
<label
|
||||||
htmlFor="about"
|
htmlFor="about"
|
||||||
className="text-sm font-semibold uppercase tracking-wider text-zinc-400"
|
className="text-sm font-semibold uppercase tracking-wider text-white/50"
|
||||||
>
|
>
|
||||||
Description
|
Description
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
{...register('about')}
|
{...register('about')}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
className="relative h-20 w-full resize-none rounded-lg bg-zinc-800 px-3 py-2 text-zinc-100 !outline-none placeholder:text-zinc-500"
|
className="relative h-20 w-full resize-none rounded-lg bg-zinc-800 px-3 py-2 text-white !outline-none placeholder:text-white/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex h-20 items-center justify-between gap-1 rounded-lg bg-zinc-800 px-4 py-2">
|
<div className="flex h-20 items-center justify-between gap-1 rounded-lg bg-zinc-800 px-4 py-2">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="font-semibold leading-none text-zinc-100">
|
<span className="font-semibold leading-none text-white">
|
||||||
Encrypted
|
Encrypted
|
||||||
</span>
|
</span>
|
||||||
<p className="w-4/5 text-sm leading-none text-zinc-400">
|
<p className="w-4/5 text-sm leading-none text-white/50">
|
||||||
All messages are encrypted and only invited members can view and
|
All messages are encrypted and only invited members can view and
|
||||||
send message
|
send message
|
||||||
</p>
|
</p>
|
||||||
@@ -248,10 +248,10 @@ export function ChannelCreateModal() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!isDirty || !isValid}
|
disabled={!isDirty || !isValid}
|
||||||
className="inline-flex h-11 w-full transform items-center justify-center gap-1 rounded-md bg-fuchsia-500 font-medium text-zinc-100 hover:bg-fuchsia-600 focus:outline-none active:translate-y-1 disabled:pointer-events-none disabled:opacity-50"
|
className="inline-flex h-11 w-full transform items-center justify-center gap-1 rounded-md bg-fuchsia-500 font-medium text-white hover:bg-fuchsia-600 focus:outline-none active:translate-y-1 disabled:pointer-events-none disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
|
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-white" />
|
||||||
) : (
|
) : (
|
||||||
'Create channel →'
|
'Create channel →'
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,17 +7,17 @@ export function ChannelsListItem({ data }: { data: any }) {
|
|||||||
const channel = useChannelProfile(data.event_id);
|
const channel = useChannelProfile(data.event_id);
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
to={`/app/channel/${data.event_id}`}
|
to={`/channel/${data.event_id}`}
|
||||||
preventScrollReset={true}
|
preventScrollReset={true}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'inline-flex h-9 items-center gap-2.5 rounded-md px-2.5',
|
'inline-flex h-9 items-center gap-2.5 rounded-md px-2.5',
|
||||||
isActive ? 'bg-zinc-900/50 text-zinc-100' : ''
|
isActive ? 'bg-zinc-900/50 text-white' : ''
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
|
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
|
||||||
<span className="text-xs text-zinc-100">#</span>
|
<span className="text-xs text-white">#</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="inline-flex w-full items-center justify-between">
|
<div className="inline-flex w-full items-center justify-between">
|
||||||
<h5 className="truncate font-medium text-zinc-200">{channel?.name}</h5>
|
<h5 className="truncate font-medium text-zinc-200">{channel?.name}</h5>
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export function ChannelMessageForm({ channelID }: { channelID: string }) {
|
|||||||
<div className="flex w-full flex-col">
|
<div className="flex w-full flex-col">
|
||||||
<UserReply pubkey={replyTo.pubkey} />
|
<UserReply pubkey={replyTo.pubkey} />
|
||||||
<div className="-mt-5 pl-[38px]">
|
<div className="-mt-5 pl-[38px]">
|
||||||
<div className="text-base text-zinc-100">{replyTo.content}</div>
|
<div className="text-base text-white">{replyTo.content}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -82,7 +82,7 @@ export function ChannelMessageForm({ channelID }: { channelID: string }) {
|
|||||||
onClick={() => stopReply()}
|
onClick={() => stopReply()}
|
||||||
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-800"
|
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-800"
|
||||||
>
|
>
|
||||||
<CancelIcon width={12} height={12} className="text-zinc-100" />
|
<CancelIcon width={12} height={12} className="text-white" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,10 +95,10 @@ export function ChannelMessageForm({ channelID }: { channelID: string }) {
|
|||||||
placeholder="Message"
|
placeholder="Message"
|
||||||
className={`relative ${
|
className={`relative ${
|
||||||
replyTo.id ? 'h-36 pt-16' : 'h-24 pt-3'
|
replyTo.id ? 'h-36 pt-16' : 'h-24 pt-3'
|
||||||
} w-full resize-none rounded-md bg-zinc-800 px-5 !outline-none placeholder:text-zinc-500`}
|
} w-full resize-none rounded-md bg-zinc-800 px-5 !outline-none placeholder:text-white/50`}
|
||||||
/>
|
/>
|
||||||
<div className="absolute bottom-0 right-2 h-11">
|
<div className="absolute bottom-0 right-2 h-11">
|
||||||
<div className="flex h-full items-center justify-end gap-3 text-zinc-500">
|
<div className="flex h-full items-center justify-end gap-3 text-white/50">
|
||||||
<MediaUploader setState={setValue} />
|
<MediaUploader setState={setValue} />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export function MessageHideButton({ id }: { id: string }) {
|
|||||||
<CancelIcon width={20} height={20} className="text-zinc-300" />
|
<CancelIcon width={20} height={20} className="text-zinc-300" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<Dialog.Description className="leading-tight text-zinc-400">
|
<Dialog.Description className="leading-tight text-white/50">
|
||||||
This message will be hidden from your feed.
|
This message will be hidden from your feed.
|
||||||
</Dialog.Description>
|
</Dialog.Description>
|
||||||
</div>
|
</div>
|
||||||
@@ -109,14 +109,14 @@ export function MessageHideButton({ id }: { id: string }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={closeModal}
|
onClick={closeModal}
|
||||||
className="inline-flex h-9 items-center justify-center rounded-md px-2 text-base font-medium text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
|
className="inline-flex h-9 items-center justify-center rounded-md px-2 text-base font-medium text-white/50 hover:bg-zinc-800 hover:text-white"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => hideMessage()}
|
onClick={() => hideMessage()}
|
||||||
className="inline-flex h-9 items-center justify-center rounded-md bg-red-500 px-2 text-base font-medium text-zinc-100 hover:bg-red-600"
|
className="inline-flex h-9 items-center justify-center rounded-md bg-red-500 px-2 text-base font-medium text-white hover:bg-red-600"
|
||||||
>
|
>
|
||||||
Confirm
|
Confirm
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function ChannelMessageItem({ data }: { data: LumeEvent }) {
|
|||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<User pubkey={data.pubkey} time={data.created_at} isChat={true} />
|
<User pubkey={data.pubkey} time={data.created_at} isChat={true} />
|
||||||
<div className="-mt-[20px] pl-[49px]">
|
<div className="-mt-[20px] pl-[49px]">
|
||||||
<p className="select-text whitespace-pre-line break-words text-base text-zinc-100">
|
<p className="select-text whitespace-pre-line break-words text-base text-white">
|
||||||
{content.parsed}
|
{content.parsed}
|
||||||
</p>
|
</p>
|
||||||
{Array.isArray(content.images) && content.images.length ? (
|
{Array.isArray(content.images) && content.images.length ? (
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export function MessageMuteButton({ pubkey }: { pubkey: string }) {
|
|||||||
<CancelIcon width={20} height={20} className="text-zinc-300" />
|
<CancelIcon width={20} height={20} className="text-zinc-300" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<Dialog.Description className="leading-tight text-zinc-400">
|
<Dialog.Description className="leading-tight text-white/50">
|
||||||
You will no longer see messages from this user.
|
You will no longer see messages from this user.
|
||||||
</Dialog.Description>
|
</Dialog.Description>
|
||||||
</div>
|
</div>
|
||||||
@@ -109,14 +109,14 @@ export function MessageMuteButton({ pubkey }: { pubkey: string }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={closeModal}
|
onClick={closeModal}
|
||||||
className="inline-flex h-9 items-center justify-center rounded-md px-2 text-base font-medium text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
|
className="inline-flex h-9 items-center justify-center rounded-md px-2 text-base font-medium text-white/50 hover:bg-zinc-800 hover:text-white"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => muteUser()}
|
onClick={() => muteUser()}
|
||||||
className="inline-flex h-9 items-center justify-center rounded-md bg-red-500 px-2 text-base font-medium text-zinc-100 hover:bg-red-600"
|
className="inline-flex h-9 items-center justify-center rounded-md bg-red-500 px-2 text-base font-medium text-white hover:bg-red-600"
|
||||||
>
|
>
|
||||||
Confirm
|
Confirm
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export function UserReply({ pubkey }: { pubkey: string }) {
|
|||||||
{isError || isLoading ? (
|
{isError || isLoading ? (
|
||||||
<>
|
<>
|
||||||
<div className="relative h-9 w-9 shrink animate-pulse overflow-hidden rounded bg-zinc-800" />
|
<div className="relative h-9 w-9 shrink animate-pulse overflow-hidden rounded bg-zinc-800" />
|
||||||
<span className="h-2 w-10 animate-pulse rounded bg-zinc-800 text-base font-medium leading-none text-zinc-500" />
|
<span className="h-2 w-10 animate-pulse rounded bg-zinc-800 text-base font-medium leading-none text-white/50" />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -25,7 +25,7 @@ export function UserReply({ pubkey }: { pubkey: string }) {
|
|||||||
className="h-9 w-9 rounded object-cover"
|
className="h-9 w-9 rounded object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="max-w-[10rem] truncate text-sm font-medium leading-none text-zinc-500">
|
<span className="max-w-[10rem] truncate text-sm font-medium leading-none text-white/50">
|
||||||
Replying to {user?.name || shortenKey(pubkey)}
|
Replying to {user?.name || shortenKey(pubkey)}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export function ChannelMetadata({ id }: { id: string }) {
|
|||||||
const noteID = id ? nip19.noteEncode(id) : null;
|
const noteID = id ? nip19.noteEncode(id) : null;
|
||||||
|
|
||||||
const copyNoteID = async () => {
|
const copyNoteID = async () => {
|
||||||
const { writeText } = await import('@tauri-apps/api/clipboard');
|
const { writeText } = await import('@tauri-apps/plugin-clipboard-manager');
|
||||||
if (noteID) {
|
if (noteID) {
|
||||||
await writeText(noteID);
|
await writeText(noteID);
|
||||||
}
|
}
|
||||||
@@ -32,10 +32,10 @@ export function ChannelMetadata({ id }: { id: string }) {
|
|||||||
<div className="inline-flex items-center gap-1">
|
<div className="inline-flex items-center gap-1">
|
||||||
<h5 className="text-lg font-semibold leading-none">{metadata?.name}</h5>
|
<h5 className="text-lg font-semibold leading-none">{metadata?.name}</h5>
|
||||||
<button type="button" onClick={() => copyNoteID()}>
|
<button type="button" onClick={() => copyNoteID()}>
|
||||||
<CopyIcon width={14} height={14} className="text-zinc-400" />
|
<CopyIcon width={14} height={14} className="text-white/50" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="leading-tight text-zinc-400">
|
<p className="leading-tight text-white/50">
|
||||||
{metadata?.about || (noteID && `${noteID.substring(0, 24)}...`)}
|
{metadata?.about || (noteID && `${noteID.substring(0, 24)}...`)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -51,10 +51,10 @@ export function MutedItem({ data }: { data: any }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full flex-1 flex-col items-start gap-0.5 text-start">
|
<div className="flex w-full flex-1 flex-col items-start gap-0.5 text-start">
|
||||||
<span className="truncate text-base font-medium leading-none text-zinc-100">
|
<span className="truncate text-base font-medium leading-none text-white">
|
||||||
{user?.displayName || user?.name || 'Pleb'}
|
{user?.displayName || user?.name || 'Pleb'}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-base leading-none text-zinc-400">
|
<span className="text-base leading-none text-white/50">
|
||||||
{shortenKey(data.content)}
|
{shortenKey(data.content)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -64,7 +64,7 @@ export function MutedItem({ data }: { data: any }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => unmute()}
|
onClick={() => unmute()}
|
||||||
className="inline-flex h-6 w-min items-center justify-center rounded px-1.5 text-base font-medium leading-none text-zinc-400 hover:bg-zinc-800 hover:text-fuchsia-500"
|
className="inline-flex h-6 w-min items-center justify-center rounded px-1.5 text-base font-medium leading-none text-white/50 hover:bg-zinc-800 hover:text-fuchsia-500"
|
||||||
>
|
>
|
||||||
Unmute
|
Unmute
|
||||||
</button>
|
</button>
|
||||||
@@ -72,7 +72,7 @@ export function MutedItem({ data }: { data: any }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => mute()}
|
onClick={() => mute()}
|
||||||
className="inline-flex h-6 w-min items-center justify-center rounded px-1.5 text-base font-medium leading-none text-zinc-400 hover:bg-zinc-800 hover:text-fuchsia-500"
|
className="inline-flex h-6 w-min items-center justify-center rounded px-1.5 text-base font-medium leading-none text-white/50 hover:bg-zinc-800 hover:text-fuchsia-500"
|
||||||
>
|
>
|
||||||
Mute
|
Mute
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const Header = (
|
|||||||
<div className="w-full border-t border-zinc-800" />
|
<div className="w-full border-t border-zinc-800" />
|
||||||
</div>
|
</div>
|
||||||
<div className="relative flex justify-center">
|
<div className="relative flex justify-center">
|
||||||
<div className="inline-flex items-center gap-x-1.5 rounded-full bg-zinc-900 px-3 py-1.5 text-sm font-medium text-zinc-400 shadow-sm ring-1 ring-inset ring-zinc-800">
|
<div className="inline-flex items-center gap-x-1.5 rounded-full bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white/50 shadow-sm ring-1 ring-inset ring-zinc-800">
|
||||||
{getHourAgo(24, now).toLocaleDateString('en-US', {
|
{getHourAgo(24, now).toLocaleDateString('en-US', {
|
||||||
weekday: 'long',
|
weekday: 'long',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
@@ -40,7 +40,7 @@ const Empty = (
|
|||||||
<h3 className="text-base font-semibold leading-none text-white">
|
<h3 className="text-base font-semibold leading-none text-white">
|
||||||
Nothing to see here yet
|
Nothing to see here yet
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-base leading-none text-zinc-400">
|
<p className="text-base leading-none text-white/50">
|
||||||
Be the first to share a message in this channel.
|
Be the first to share a message in this channel.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -102,7 +102,7 @@ export function ChannelScreen() {
|
|||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
className="inline-flex h-11 w-full shrink-0 items-center justify-center border-b border-zinc-900"
|
className="inline-flex h-11 w-full shrink-0 items-center justify-center border-b border-zinc-900"
|
||||||
>
|
>
|
||||||
<h3 className="font-semibold text-zinc-100">Public Channel</h3>
|
<h3 className="font-semibold text-white">Public Channel</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-full w-full flex-1 p-3">
|
<div className="h-full w-full flex-1 p-3">
|
||||||
<div className="flex h-full flex-col justify-between overflow-hidden rounded-xl border-t border-zinc-800/50 bg-zinc-900">
|
<div className="flex h-full flex-col justify-between overflow-hidden rounded-xl border-t border-zinc-800/50 bg-zinc-900">
|
||||||
|
|||||||
@@ -6,48 +6,45 @@ import { Image } from '@shared/image';
|
|||||||
import { DEFAULT_AVATAR } from '@stores/constants';
|
import { DEFAULT_AVATAR } from '@stores/constants';
|
||||||
|
|
||||||
import { useProfile } from '@utils/hooks/useProfile';
|
import { useProfile } from '@utils/hooks/useProfile';
|
||||||
import { shortenKey } from '@utils/shortenKey';
|
import { displayNpub } from '@utils/shortenKey';
|
||||||
|
import { Chats } from '@utils/types';
|
||||||
|
|
||||||
export function ChatsListItem({ data }: { data: any }) {
|
export function ChatsListItem({ data }: { data: Chats }) {
|
||||||
const { status, user } = useProfile(data.sender_pubkey);
|
const { status, user } = useProfile(data.sender_pubkey);
|
||||||
|
|
||||||
if (status === 'loading') {
|
if (status === 'loading') {
|
||||||
return (
|
return (
|
||||||
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
|
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2">
|
||||||
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
|
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-white/10" />
|
||||||
<div className="h-2.5 w-2/3 animate-pulse rounded bg-zinc-800" />
|
<div className="h-2.5 w-2/3 animate-pulse rounded bg-white/10" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
to={`/app/chats/${data.sender_pubkey}`}
|
to={`/chats/${data.sender_pubkey}`}
|
||||||
preventScrollReset={true}
|
preventScrollReset={true}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'inline-flex h-9 items-center gap-2.5 rounded-md px-2.5',
|
'inline-flex h-9 items-center gap-2.5 rounded-md px-2',
|
||||||
isActive ? 'bg-zinc-900/50 text-zinc-100' : ''
|
isActive ? 'bg-white/10 text-white' : 'text-white/80'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
|
<Image
|
||||||
<Image
|
src={user?.picture || user?.image}
|
||||||
src={user?.picture || user?.image}
|
fallback={DEFAULT_AVATAR}
|
||||||
fallback={DEFAULT_AVATAR}
|
alt={data.sender_pubkey}
|
||||||
alt={data.sender_pubkey}
|
className="h-6 w-6 shrink-0 rounded object-cover"
|
||||||
className="h-6 w-6 rounded object-cover"
|
/>
|
||||||
/>
|
<div className="inline-flex w-full flex-1 items-center justify-between">
|
||||||
</div>
|
<h5 className="max-w-[10rem] truncate">
|
||||||
<div className="inline-flex w-full items-center justify-between">
|
{user?.nip05 ||
|
||||||
<div className="inline-flex items-baseline gap-1">
|
user?.name ||
|
||||||
<h5 className="max-w-[10rem] truncate font-medium text-zinc-200">
|
user?.display_name ||
|
||||||
{user?.nip05 ||
|
displayNpub(data.sender_pubkey, 16)}
|
||||||
user?.name ||
|
</h5>
|
||||||
user?.displayName ||
|
|
||||||
shortenKey(data.sender_pubkey)}
|
|
||||||
</h5>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{data.new_messages > 0 && (
|
{data.new_messages > 0 && (
|
||||||
<span className="inline-flex w-8 items-center justify-center rounded bg-fuchsia-400/10 px-1 py-1 text-xs font-medium text-fuchsia-500 ring-1 ring-inset ring-fuchsia-400/20">
|
<span className="inline-flex w-8 items-center justify-center rounded bg-fuchsia-400/10 px-1 py-1 text-xs font-medium text-fuchsia-500 ring-1 ring-inset ring-fuchsia-400/20">
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export function ChatsList() {
|
|||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
(item: Chats) => {
|
(item: Chats) => {
|
||||||
if (account.pubkey !== item.sender_pubkey) {
|
if (account?.pubkey !== item.sender_pubkey) {
|
||||||
return <ChatsListItem key={item.sender_pubkey} data={item} />;
|
return <ChatsListItem key={item.sender_pubkey} data={item} />;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -34,12 +34,12 @@ export function ChatsList() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
|
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
|
||||||
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
|
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-white/10" />
|
||||||
<div className="h-3 w-full animate-pulse rounded-sm bg-zinc-800" />
|
<div className="h-3 w-full animate-pulse rounded-sm bg-white/10" />
|
||||||
</div>
|
</div>
|
||||||
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
|
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
|
||||||
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
|
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-white/10" />
|
||||||
<div className="h-3 w-full animate-pulse rounded-sm bg-zinc-800" />
|
<div className="h-3 w-full animate-pulse rounded-sm bg-white/10" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -50,20 +50,20 @@ export function ChatsList() {
|
|||||||
{account ? (
|
{account ? (
|
||||||
<ChatsListSelfItem data={account} />
|
<ChatsListSelfItem data={account} />
|
||||||
) : (
|
) : (
|
||||||
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
|
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2">
|
||||||
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
|
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-white/10" />
|
||||||
<div className="h-3 w-full animate-pulse rounded-sm bg-zinc-800" />
|
<div className="h-3 w-full animate-pulse rounded-sm bg-white/10" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{chats.follows.map((item) => renderItem(item))}
|
{chats.follows.map((item) => renderItem(item))}
|
||||||
{chats.unknowns.length > 0 && <UnknownsModal data={chats.unknowns} />}
|
|
||||||
<NewMessageModal />
|
|
||||||
{isFetching && (
|
{isFetching && (
|
||||||
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
|
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2">
|
||||||
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
|
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-white/10" />
|
||||||
<div className="h-3 w-full animate-pulse rounded-sm bg-zinc-800" />
|
<div className="h-3 w-full animate-pulse rounded-sm bg-white/10" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{chats.unknowns.length > 0 && <UnknownsModal data={chats.unknowns} />}
|
||||||
|
<NewMessageModal />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useCallback, useState } from 'react';
|
|||||||
import { EnterIcon } from '@shared/icons';
|
import { EnterIcon } from '@shared/icons';
|
||||||
import { MediaUploader } from '@shared/mediaUploader';
|
import { MediaUploader } from '@shared/mediaUploader';
|
||||||
|
|
||||||
import { usePublish } from '@utils/hooks/usePublish';
|
import { useNostr } from '@utils/hooks/useNostr';
|
||||||
|
|
||||||
export function ChatMessageForm({
|
export function ChatMessageForm({
|
||||||
receiverPubkey,
|
receiverPubkey,
|
||||||
@@ -14,7 +14,7 @@ export function ChatMessageForm({
|
|||||||
userPubkey: string;
|
userPubkey: string;
|
||||||
userPrivkey: string;
|
userPrivkey: string;
|
||||||
}) {
|
}) {
|
||||||
const { publish } = usePublish();
|
const { publish } = useNostr();
|
||||||
const [value, setValue] = useState('');
|
const [value, setValue] = useState('');
|
||||||
|
|
||||||
const encryptMessage = useCallback(async () => {
|
const encryptMessage = useCallback(async () => {
|
||||||
@@ -51,10 +51,10 @@ export function ChatMessageForm({
|
|||||||
onKeyDown={handleEnterPress}
|
onKeyDown={handleEnterPress}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
placeholder="Message"
|
placeholder="Message"
|
||||||
className="relative h-11 w-full resize-none rounded-md bg-zinc-800 px-5 !outline-none placeholder:text-zinc-500"
|
className="relative h-11 w-full resize-none rounded-md bg-white/10 px-5 text-white !outline-none placeholder:text-white/50"
|
||||||
/>
|
/>
|
||||||
<div className="absolute right-2 top-0 h-11">
|
<div className="absolute right-2 top-0 h-11">
|
||||||
<div className="flex h-full items-center justify-end gap-3 text-zinc-500">
|
<div className="flex h-full items-center justify-end gap-3 text-white/50">
|
||||||
<MediaUploader setState={setValue} />
|
<MediaUploader setState={setValue} />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import { useDecryptMessage } from '@app/chats/hooks/useDecryptMessage';
|
import { useDecryptMessage } from '@app/chats/hooks/useDecryptMessage';
|
||||||
|
|
||||||
import { MentionNote } from '@shared/notes/mentions/note';
|
import { NoteContent } from '@shared/notes';
|
||||||
import { ImagePreview } from '@shared/notes/preview/image';
|
|
||||||
import { LinkPreview } from '@shared/notes/preview/link';
|
|
||||||
import { VideoPreview } from '@shared/notes/preview/video';
|
|
||||||
import { User } from '@shared/user';
|
import { User } from '@shared/user';
|
||||||
|
|
||||||
import { parser } from '@utils/parser';
|
import { parser } from '@utils/parser';
|
||||||
@@ -22,22 +19,15 @@ export function ChatMessageItem({
|
|||||||
if (decryptedContent) {
|
if (decryptedContent) {
|
||||||
data['content'] = decryptedContent;
|
data['content'] = decryptedContent;
|
||||||
}
|
}
|
||||||
// parse the note content
|
|
||||||
const content = parser(data);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-min min-h-min w-full select-text flex-col px-5 py-3 hover:bg-black/20">
|
<div className="flex h-min min-h-min w-full select-text flex-col px-5 py-3 hover:bg-white/10">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<User pubkey={data.sender_pubkey} time={data.created_at} isChat={true} />
|
<User pubkey={data.sender_pubkey} time={data.created_at} isChat={true} />
|
||||||
<div className="-mt-[20px] pl-[49px]">
|
<div className="-mt-[20px] pl-[49px]">
|
||||||
<p className="select-text whitespace-pre-line break-words text-base text-zinc-100">
|
<p className="select-text whitespace-pre-line break-words text-base text-white">
|
||||||
{content.parsed}
|
{data.content}
|
||||||
</p>
|
</p>
|
||||||
{content.images.length > 0 && <ImagePreview urls={content.images} />}
|
|
||||||
{content.videos.length > 0 && <VideoPreview urls={content.videos} />}
|
|
||||||
{content.links.length > 0 && <LinkPreview urls={content.links} />}
|
|
||||||
{content.notes.length > 0 &&
|
|
||||||
content.notes.map((note: string) => <MentionNote key={note} id={note} />)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Dialog, Transition } from '@headlessui/react';
|
import * as Dialog from '@radix-ui/react-dialog';
|
||||||
import { Fragment, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import { User } from '@app/auth/components/user';
|
import { User } from '@app/auth/components/user';
|
||||||
@@ -10,114 +10,77 @@ import { useAccount } from '@utils/hooks/useAccount';
|
|||||||
|
|
||||||
export function NewMessageModal() {
|
export function NewMessageModal() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
const { status, account } = useAccount();
|
const { status, account } = useAccount();
|
||||||
const follows = account ? JSON.parse(account.follows as string) : [];
|
|
||||||
|
|
||||||
const closeModal = () => {
|
|
||||||
setIsOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openModal = () => {
|
|
||||||
setIsOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openChat = (pubkey: string) => {
|
const openChat = (pubkey: string) => {
|
||||||
closeModal();
|
setOpen(false);
|
||||||
navigate(`/app/chats/${pubkey}`);
|
navigate(`/chats/${pubkey}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Dialog.Root open={open} onOpenChange={setOpen}>
|
||||||
<button
|
<Dialog.Trigger asChild>
|
||||||
type="button"
|
<button
|
||||||
onClick={() => openModal()}
|
type="button"
|
||||||
className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5"
|
className="inline-flex h-9 items-center gap-2.5 rounded-md px-2"
|
||||||
>
|
>
|
||||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
|
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-white/10">
|
||||||
<PlusIcon className="h-3 w-3 text-zinc-200" />
|
<PlusIcon className="h-3 w-3 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h5 className="font-medium text-zinc-400">New chat</h5>
|
<h5 className="text-white/50">New chat</h5>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<Transition appear show={isOpen} as={Fragment}>
|
</Dialog.Trigger>
|
||||||
<Dialog as="div" className="relative z-10" onClose={closeModal}>
|
<Dialog.Portal className="relative z-10">
|
||||||
<Transition.Child
|
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xl" />
|
||||||
as={Fragment}
|
<Dialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
|
||||||
enter="ease-out duration-300"
|
<div className="relative h-min w-full max-w-xl rounded-xl bg-white/10">
|
||||||
enterFrom="opacity-0"
|
<div className="h-min w-full shrink-0 border-b border-white/10 bg-white/5 px-5 py-5">
|
||||||
enterTo="opacity-100"
|
<div className="flex flex-col gap-1">
|
||||||
leave="ease-in duration-200"
|
<div className="flex items-center justify-between">
|
||||||
leaveFrom="opacity-100"
|
<Dialog.Title className="text-lg font-semibold leading-none text-white">
|
||||||
leaveTo="opacity-0"
|
New chat
|
||||||
>
|
</Dialog.Title>
|
||||||
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
|
<Dialog.Close className="inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-white/10">
|
||||||
</Transition.Child>
|
<CancelIcon className="h-4 w-4 text-white/50" />
|
||||||
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
|
</Dialog.Close>
|
||||||
<Transition.Child
|
</div>
|
||||||
as={Fragment}
|
<Dialog.Description className="text-sm leading-none text-white/50">
|
||||||
enter="ease-out duration-300"
|
All messages will be encrypted, but anyone can see who you chat
|
||||||
enterFrom="opacity-0 scale-95"
|
</Dialog.Description>
|
||||||
enterTo="opacity-100 scale-100"
|
</div>
|
||||||
leave="ease-in duration-200"
|
</div>
|
||||||
leaveFrom="opacity-100 scale-100"
|
<div className="flex h-[500px] flex-col overflow-y-auto overflow-x-hidden pb-2 pt-2">
|
||||||
leaveTo="opacity-0 scale-95"
|
{status === 'loading' ? (
|
||||||
>
|
<div className="inline-flex items-center justify-center px-4 py-3">
|
||||||
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col gap-2 rounded-lg border-t border-zinc-800/50 bg-zinc-900">
|
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
|
||||||
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-5">
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
) : (
|
||||||
<div className="flex items-center justify-between">
|
account?.follows?.map((follow) => (
|
||||||
<Dialog.Title
|
<div
|
||||||
as="h3"
|
key={follow}
|
||||||
className="text-lg font-semibold leading-none text-zinc-100"
|
className="group flex items-center justify-between px-4 py-2 hover:bg-white/10"
|
||||||
>
|
>
|
||||||
New chat
|
<User pubkey={follow} />
|
||||||
</Dialog.Title>
|
<div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={closeModal}
|
onClick={() => openChat(follow)}
|
||||||
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-800"
|
className="hidden w-max rounded bg-white/10 px-3 py-1 text-sm font-medium hover:bg-fuchsia-500 group-hover:inline-flex"
|
||||||
>
|
>
|
||||||
<CancelIcon className="h-4 w-4 text-zinc-300" />
|
Chat
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<Dialog.Description className="text-sm leading-tight text-zinc-400">
|
|
||||||
All messages will be encrypted, but anyone can see who you chat
|
|
||||||
</Dialog.Description>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))
|
||||||
<div className="flex h-[500px] flex-col overflow-y-auto overflow-x-hidden pb-5">
|
)}
|
||||||
{status === 'loading' ? (
|
</div>
|
||||||
<div className="inline-flex items-center justify-center px-4 py-3">
|
|
||||||
<LoaderIcon className="h-5 w-5 animate-spin text-black dark:text-zinc-100" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
follows.map((follow) => (
|
|
||||||
<div
|
|
||||||
key={follow}
|
|
||||||
className="group flex items-center justify-between px-4 py-3 hover:bg-zinc-800"
|
|
||||||
>
|
|
||||||
<User pubkey={follow} />
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => openChat(follow)}
|
|
||||||
className="hidden w-max rounded-md bg-zinc-700 px-3 py-1 text-sm font-medium hover:bg-fuchsia-500 group-hover:inline-flex"
|
|
||||||
>
|
|
||||||
Chat
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Dialog.Panel>
|
|
||||||
</Transition.Child>
|
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog.Content>
|
||||||
</Transition>
|
</Dialog.Portal>
|
||||||
</>
|
</Dialog.Root>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,46 +6,42 @@ import { Image } from '@shared/image';
|
|||||||
import { DEFAULT_AVATAR } from '@stores/constants';
|
import { DEFAULT_AVATAR } from '@stores/constants';
|
||||||
|
|
||||||
import { useProfile } from '@utils/hooks/useProfile';
|
import { useProfile } from '@utils/hooks/useProfile';
|
||||||
import { shortenKey } from '@utils/shortenKey';
|
import { displayNpub } from '@utils/shortenKey';
|
||||||
|
|
||||||
export function ChatsListSelfItem({ data }: { data: { pubkey: string } }) {
|
export function ChatsListSelfItem({ data }: { data: { pubkey: string } }) {
|
||||||
const { status, user } = useProfile(data.pubkey);
|
const { status, user } = useProfile(data.pubkey);
|
||||||
|
|
||||||
if (status === 'loading') {
|
if (status === 'loading') {
|
||||||
return (
|
return (
|
||||||
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
|
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2">
|
||||||
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-zinc-800" />
|
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-white/10" />
|
||||||
<div>
|
<div className="h-2.5 w-2/3 animate-pulse rounded bg-white/10" />
|
||||||
<div className="h-2.5 w-full animate-pulse truncate rounded bg-zinc-800 text-base font-medium" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
to={`/app/chats/${data.pubkey}`}
|
to={`/chats/${data.pubkey}`}
|
||||||
preventScrollReset={true}
|
preventScrollReset={true}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
twMerge(
|
twMerge(
|
||||||
'inline-flex h-9 items-center gap-2.5 rounded-md px-2.5',
|
'inline-flex h-9 items-center gap-2.5 rounded-md px-2',
|
||||||
isActive ? 'bg-zinc-900/50 text-zinc-100' : ''
|
isActive ? 'bg-white/10 text-white' : 'text-white/80'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
|
<Image
|
||||||
<Image
|
src={user?.picture || user?.image}
|
||||||
src={user?.picture || user?.image}
|
fallback={DEFAULT_AVATAR}
|
||||||
fallback={DEFAULT_AVATAR}
|
alt={data.pubkey}
|
||||||
alt={data.pubkey}
|
className="h-6 w-6 shrink-0 rounded bg-white object-cover"
|
||||||
className="h-6 w-6 rounded bg-white object-cover"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="inline-flex items-baseline gap-1">
|
<div className="inline-flex items-baseline gap-1">
|
||||||
<h5 className="max-w-[9rem] truncate font-medium text-zinc-200">
|
<h5 className="max-w-[10rem] truncate">
|
||||||
{user?.nip05 || user?.name || shortenKey(data.pubkey)}
|
{user?.nip05 || user?.name || displayNpub(data.pubkey, 16)}
|
||||||
</h5>
|
</h5>
|
||||||
<span className="text-zinc-500">(you)</span>
|
<span className="text-white/50">(you)</span>
|
||||||
</div>
|
</div>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Image } from '@shared/image';
|
|||||||
import { DEFAULT_AVATAR } from '@stores/constants';
|
import { DEFAULT_AVATAR } from '@stores/constants';
|
||||||
|
|
||||||
import { useProfile } from '@utils/hooks/useProfile';
|
import { useProfile } from '@utils/hooks/useProfile';
|
||||||
import { shortenKey } from '@utils/shortenKey';
|
import { displayNpub } from '@utils/shortenKey';
|
||||||
|
|
||||||
export function ChatSidebar({ pubkey }: { pubkey: string }) {
|
export function ChatSidebar({ pubkey }: { pubkey: string }) {
|
||||||
const { user } = useProfile(pubkey);
|
const { user } = useProfile(pubkey);
|
||||||
@@ -24,17 +24,17 @@ export function ChatSidebar({ pubkey }: { pubkey: string }) {
|
|||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<h3 className="text-lg font-semibold leading-none">
|
<h3 className="text-lg font-semibold leading-none">
|
||||||
{user?.displayName || user?.name}
|
{user?.display_name || user?.name}
|
||||||
</h3>
|
</h3>
|
||||||
<h5 className="leading-none text-zinc-400">
|
<h5 className="leading-none text-white/50">
|
||||||
{user?.nip05 || shortenKey(pubkey)}
|
{user?.nip05 || displayNpub(pubkey, 16)}
|
||||||
</h5>
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="leading-tight">{user?.bio || user?.about}</p>
|
<p className="leading-tight">{user?.bio || user?.about}</p>
|
||||||
<Link
|
<Link
|
||||||
to={`/app/users/${pubkey}`}
|
to={`/users/${pubkey}`}
|
||||||
className="mt-3 inline-flex h-10 w-full items-center justify-center rounded-md bg-zinc-900 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:text-zinc-100"
|
className="mt-3 inline-flex h-10 w-full items-center justify-center rounded-md bg-white/10 text-sm font-medium text-white hover:bg-fuchsia-500"
|
||||||
>
|
>
|
||||||
View full profile
|
View full profile
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,117 +1,81 @@
|
|||||||
import { Dialog, Transition } from '@headlessui/react';
|
import * as Dialog from '@radix-ui/react-dialog';
|
||||||
import { Fragment, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import { User } from '@app/auth/components/user';
|
import { User } from '@app/auth/components/user';
|
||||||
|
|
||||||
import { CancelIcon, StrangersIcon } from '@shared/icons';
|
import { CancelIcon, PlusIcon } from '@shared/icons';
|
||||||
|
|
||||||
import { compactNumber } from '@utils/number';
|
import { compactNumber } from '@utils/number';
|
||||||
import { Chats } from '@utils/types';
|
import { Chats } from '@utils/types';
|
||||||
|
|
||||||
export function UnknownsModal({ data }: { data: Chats[] }) {
|
export function UnknownsModal({ data }: { data: Chats[] }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const closeModal = () => {
|
|
||||||
setIsOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openModal = () => {
|
|
||||||
setIsOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openChat = (pubkey: string) => {
|
const openChat = (pubkey: string) => {
|
||||||
closeModal();
|
setOpen(false);
|
||||||
navigate(`/app/chats/${pubkey}`);
|
navigate(`/chats/${pubkey}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Dialog.Root open={open} onOpenChange={setOpen}>
|
||||||
<button
|
<Dialog.Trigger asChild>
|
||||||
type="button"
|
<button
|
||||||
onClick={() => openModal()}
|
type="button"
|
||||||
className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5"
|
className="inline-flex h-9 items-center gap-2.5 rounded-md px-2"
|
||||||
>
|
>
|
||||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
|
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-white/10">
|
||||||
<StrangersIcon className="h-3 w-3 text-zinc-200" />
|
<PlusIcon className="h-3 w-3 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h5 className="font-medium text-zinc-400">
|
<h5 className="text-white/50">
|
||||||
{compactNumber.format(data.length)} unknowns
|
{compactNumber.format(data.length)} unknowns
|
||||||
</h5>
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<Transition appear show={isOpen} as={Fragment}>
|
</Dialog.Trigger>
|
||||||
<Dialog as="div" className="relative z-10" onClose={closeModal}>
|
<Dialog.Portal className="relative z-10">
|
||||||
<Transition.Child
|
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xl" />
|
||||||
as={Fragment}
|
<Dialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
|
||||||
enter="ease-out duration-300"
|
<div className="relative h-min w-full max-w-xl rounded-xl bg-white/10">
|
||||||
enterFrom="opacity-0"
|
<div className="h-min w-full shrink-0 border-b border-white/10 bg-white/5 px-5 py-5">
|
||||||
enterTo="opacity-100"
|
<div className="flex flex-col gap-1">
|
||||||
leave="ease-in duration-200"
|
<div className="flex items-center justify-between">
|
||||||
leaveFrom="opacity-100"
|
<Dialog.Title className="text-lg font-semibold leading-none text-white">
|
||||||
leaveTo="opacity-0"
|
{data.length} unknowns
|
||||||
>
|
</Dialog.Title>
|
||||||
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
|
<Dialog.Close className="inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-white/10">
|
||||||
</Transition.Child>
|
<CancelIcon className="h-4 w-4 text-white/50" />
|
||||||
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
|
</Dialog.Close>
|
||||||
<Transition.Child
|
</div>
|
||||||
as={Fragment}
|
<Dialog.Description className="text-sm leading-none text-white/50">
|
||||||
enter="ease-out duration-300"
|
All messages from people you not follow
|
||||||
enterFrom="opacity-0 scale-95"
|
</Dialog.Description>
|
||||||
enterTo="opacity-100 scale-100"
|
</div>
|
||||||
leave="ease-in duration-200"
|
</div>
|
||||||
leaveFrom="opacity-100 scale-100"
|
<div className="flex h-[500px] flex-col overflow-y-auto overflow-x-hidden pb-2 pt-2">
|
||||||
leaveTo="opacity-0 scale-95"
|
{data.map((user) => (
|
||||||
>
|
<div
|
||||||
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col gap-2 rounded-lg border-t border-zinc-800/50 bg-zinc-900">
|
key={user.sender_pubkey}
|
||||||
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-5">
|
className="group flex items-center justify-between px-4 py-2 hover:bg-white/10"
|
||||||
<div className="flex flex-col gap-1">
|
>
|
||||||
<div className="flex items-center justify-between">
|
<User pubkey={user.sender_pubkey} />
|
||||||
<Dialog.Title
|
<div>
|
||||||
as="h3"
|
<button
|
||||||
className="text-lg font-semibold leading-none text-zinc-100"
|
type="button"
|
||||||
>
|
onClick={() => openChat(user.sender_pubkey)}
|
||||||
{data.length} unknowns
|
className="hidden w-max rounded bg-white/10 px-3 py-1 text-sm font-medium hover:bg-fuchsia-500 group-hover:inline-flex"
|
||||||
</Dialog.Title>
|
>
|
||||||
<button
|
Chat
|
||||||
type="button"
|
</button>
|
||||||
onClick={closeModal}
|
|
||||||
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-800"
|
|
||||||
>
|
|
||||||
<CancelIcon className="h-4 w-4 text-zinc-300" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<Dialog.Description className="text-sm leading-tight text-zinc-400">
|
|
||||||
All messages from people you not follow
|
|
||||||
</Dialog.Description>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex h-[500px] flex-col overflow-y-auto overflow-x-hidden pb-5">
|
))}
|
||||||
{data.map((user) => (
|
</div>
|
||||||
<div
|
|
||||||
key={user.sender_pubkey}
|
|
||||||
className="group flex items-center justify-between px-4 py-3 hover:bg-zinc-800"
|
|
||||||
>
|
|
||||||
<User pubkey={user.sender_pubkey} />
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => openChat(user.sender_pubkey)}
|
|
||||||
className="hidden w-max rounded-md bg-zinc-700 px-3 py-1 text-sm font-medium hover:bg-fuchsia-500 group-hover:inline-flex"
|
|
||||||
>
|
|
||||||
Chat
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Dialog.Panel>
|
|
||||||
</Transition.Child>
|
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog.Content>
|
||||||
</Transition>
|
</Dialog.Portal>
|
||||||
</>
|
</Dialog.Root>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { nip04 } from 'nostr-tools';
|
import { nip04 } from 'nostr-tools';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
export function useDecryptMessage(data: any, userPubkey: string, userPriv: string) {
|
import { Chats } from '@utils/types';
|
||||||
|
|
||||||
|
export function useDecryptMessage(data: Chats, userPubkey: string, userPriv: string) {
|
||||||
const [content, setContent] = useState(data.content);
|
const [content, setContent] = useState(data.content);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { createChat, getChatMessages } from '@libs/storage';
|
|||||||
import { useStronghold } from '@stores/stronghold';
|
import { useStronghold } from '@stores/stronghold';
|
||||||
|
|
||||||
import { useAccount } from '@utils/hooks/useAccount';
|
import { useAccount } from '@utils/hooks/useAccount';
|
||||||
|
import { Chats } from '@utils/types';
|
||||||
|
|
||||||
export function ChatScreen() {
|
export function ChatScreen() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -34,7 +35,7 @@ export function ChatScreen() {
|
|||||||
|
|
||||||
const userPrivkey = useStronghold((state) => state.privkey);
|
const userPrivkey = useStronghold((state) => state.privkey);
|
||||||
|
|
||||||
const itemContent: any = useCallback(
|
const itemContent = useCallback(
|
||||||
(index: string | number) => {
|
(index: string | number) => {
|
||||||
return (
|
return (
|
||||||
<ChatMessageItem
|
<ChatMessageItem
|
||||||
@@ -55,7 +56,7 @@ export function ChatScreen() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const chat = useMutation({
|
const chat = useMutation({
|
||||||
mutationFn: (data: any) => {
|
mutationFn: (data: Chats) => {
|
||||||
return createChat(
|
return createChat(
|
||||||
data.id,
|
data.id,
|
||||||
data.receiver_pubkey,
|
data.receiver_pubkey,
|
||||||
@@ -100,16 +101,10 @@ export function ChatScreen() {
|
|||||||
}, [pubkey]);
|
}, [pubkey]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid h-full w-full grid-cols-3">
|
<div className="grid h-full w-full grid-cols-3 bg-white/10">
|
||||||
<div className="col-span-2 flex flex-col justify-between border-r border-zinc-900">
|
<div className="col-span-2 border-r border-white/5">
|
||||||
<div
|
|
||||||
data-tauri-drag-region
|
|
||||||
className="inline-flex h-11 w-full shrink-0 items-center justify-center border-b border-zinc-900"
|
|
||||||
>
|
|
||||||
<h3 className="font-semibold text-zinc-100">Encrypted Chat</h3>
|
|
||||||
</div>
|
|
||||||
<div className="h-full w-full flex-1 p-3">
|
<div className="h-full w-full flex-1 p-3">
|
||||||
<div className="flex h-full flex-col justify-between overflow-hidden rounded-xl border-t border-zinc-800/50 bg-zinc-900">
|
<div className="flex h-full flex-col justify-between overflow-hidden rounded-xl bg-white/10">
|
||||||
<div className="h-full w-full flex-1">
|
<div className="h-full w-full flex-1">
|
||||||
{status === 'loading' ? (
|
{status === 'loading' ? (
|
||||||
<p>Loading...</p>
|
<p>Loading...</p>
|
||||||
@@ -131,7 +126,7 @@ export function ChatScreen() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="z-50 shrink-0 rounded-b-xl border-t border-zinc-800 bg-zinc-900 p-3 px-5">
|
<div className="z-50 shrink-0 rounded-b-xl border-t border-white/5 bg-white/10 p-3 px-5">
|
||||||
<ChatMessageForm
|
<ChatMessageForm
|
||||||
receiverPubkey={pubkey}
|
receiverPubkey={pubkey}
|
||||||
userPubkey={account.pubkey}
|
userPubkey={account.pubkey}
|
||||||
@@ -141,11 +136,7 @@ export function ChatScreen() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-1">
|
<div className="col-span-1 pt-3">
|
||||||
<div
|
|
||||||
data-tauri-drag-region
|
|
||||||
className="inline-flex h-11 w-full shrink-0 items-center justify-center border-b border-zinc-900"
|
|
||||||
/>
|
|
||||||
<ChatSidebar pubkey={pubkey} />
|
<ChatSidebar pubkey={pubkey} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -155,7 +146,7 @@ export function ChatScreen() {
|
|||||||
const Empty = (
|
const Empty = (
|
||||||
<div className="absolute left-1/2 top-1/2 flex w-full -translate-x-1/2 -translate-y-1/2 transform flex-col gap-1 text-center">
|
<div className="absolute left-1/2 top-1/2 flex w-full -translate-x-1/2 -translate-y-1/2 transform flex-col gap-1 text-center">
|
||||||
<h3 className="mb-2 text-4xl">🙌</h3>
|
<h3 className="mb-2 text-4xl">🙌</h3>
|
||||||
<p className="leading-none text-zinc-400">
|
<p className="leading-none text-white/50">
|
||||||
You two didn't talk yet, let's send first message
|
You two didn't talk yet, let's send first message
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
51
src/app/events/index.tsx
Normal file
51
src/app/events/index.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
import {
|
||||||
|
NoteActions,
|
||||||
|
NoteContent,
|
||||||
|
NoteReplyForm,
|
||||||
|
NoteStats,
|
||||||
|
ThreadUser,
|
||||||
|
} from '@shared/notes';
|
||||||
|
import { RepliesList } from '@shared/notes/replies/list';
|
||||||
|
import { NoteSkeleton } from '@shared/notes/skeleton';
|
||||||
|
|
||||||
|
import { useAccount } from '@utils/hooks/useAccount';
|
||||||
|
import { useEvent } from '@utils/hooks/useEvent';
|
||||||
|
|
||||||
|
export function EventScreen() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const { account } = useAccount();
|
||||||
|
const { status, data } = useEvent(id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-[600px]">
|
||||||
|
<div className="scrollbar-hide flex h-full w-full flex-col gap-1.5 overflow-y-auto pt-11">
|
||||||
|
{status === 'loading' ? (
|
||||||
|
<div className="px-3 py-1.5">
|
||||||
|
<div className="rounded-xl bg-white/10 px-3 py-3">
|
||||||
|
<NoteSkeleton />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-min w-full px-3 pt-1.5">
|
||||||
|
<div className="rounded-xl bg-white/10 px-3 pt-3">
|
||||||
|
<ThreadUser pubkey={data.pubkey} time={data.created_at} />
|
||||||
|
<div className="mt-2">
|
||||||
|
<NoteContent content={data.content} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<NoteActions id={id} pubkey={data.pubkey} noOpenThread={true} />
|
||||||
|
<NoteStats id={id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="px-3">
|
||||||
|
<NoteReplyForm id={id} pubkey={account.pubkey} />
|
||||||
|
<RepliesList id={id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { useParams } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { useLiveThread } from '@app/space/hooks/useLiveThread';
|
|
||||||
|
|
||||||
import { NoteMetadata } from '@shared/notes/metadata';
|
|
||||||
import { NoteReplyForm } from '@shared/notes/replies/form';
|
|
||||||
import { RepliesList } from '@shared/notes/replies/list';
|
|
||||||
import { NoteSkeleton } from '@shared/notes/skeleton';
|
|
||||||
import { User } from '@shared/user';
|
|
||||||
|
|
||||||
import { useAccount } from '@utils/hooks/useAccount';
|
|
||||||
import { useEvent } from '@utils/hooks/useEvent';
|
|
||||||
|
|
||||||
export function NoteScreen() {
|
|
||||||
const { id } = useParams();
|
|
||||||
const { account } = useAccount();
|
|
||||||
const { status, data } = useEvent(id);
|
|
||||||
|
|
||||||
useLiveThread(id);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto w-[600px]">
|
|
||||||
<div className="scrollbar-hide flex h-full w-full flex-col gap-1.5 overflow-y-auto pb-20 pt-16">
|
|
||||||
{status === 'loading' ? (
|
|
||||||
<div className="px-3 py-1.5">
|
|
||||||
<div className="shadow-input rounded-md bg-zinc-900 px-3 py-3 shadow-black/20">
|
|
||||||
<NoteSkeleton />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="h-min w-full px-3 py-1.5">
|
|
||||||
<div className="rounded-md bg-zinc-900 px-5 pt-5">
|
|
||||||
<User pubkey={data.pubkey} time={data.created_at} />
|
|
||||||
<div className="mt-3">
|
|
||||||
<NoteMetadata id={data.event_id || id} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 rounded-md bg-zinc-900">
|
|
||||||
{account && <NoteReplyForm rootID={id} userPubkey={account.pubkey} />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="px-3">
|
|
||||||
<RepliesList id={id} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
200
src/app/root.tsx
200
src/app/root.tsx
@@ -1,200 +0,0 @@
|
|||||||
import { NDKUser } from '@nostr-dev-kit/ndk';
|
|
||||||
import { nip19 } from 'nostr-tools';
|
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { useNDK } from '@libs/ndk/provider';
|
|
||||||
import {
|
|
||||||
countTotalNotes,
|
|
||||||
createChat,
|
|
||||||
createNote,
|
|
||||||
getLastLogin,
|
|
||||||
updateAccount,
|
|
||||||
updateLastLogin,
|
|
||||||
} from '@libs/storage';
|
|
||||||
|
|
||||||
import { LoaderIcon } from '@shared/icons';
|
|
||||||
|
|
||||||
import { nHoursAgo } from '@utils/date';
|
|
||||||
import { useAccount } from '@utils/hooks/useAccount';
|
|
||||||
|
|
||||||
const totalNotes = await countTotalNotes();
|
|
||||||
const lastLogin = await getLastLogin();
|
|
||||||
|
|
||||||
export function Root() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const { ndk, relayUrls, fetcher } = useNDK();
|
|
||||||
const { status, account } = useAccount();
|
|
||||||
|
|
||||||
async function getFollows() {
|
|
||||||
const authors: string[] = [];
|
|
||||||
|
|
||||||
const user = ndk.getUser({ hexpubkey: account.pubkey });
|
|
||||||
const follows = await user.follows();
|
|
||||||
|
|
||||||
follows.forEach((follow: NDKUser) => {
|
|
||||||
authors.push(nip19.decode(follow.npub).data as string);
|
|
||||||
});
|
|
||||||
|
|
||||||
// update follows in db
|
|
||||||
await updateAccount('follows', authors, account.pubkey);
|
|
||||||
|
|
||||||
return authors;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchNotes() {
|
|
||||||
try {
|
|
||||||
const follows = await getFollows();
|
|
||||||
|
|
||||||
if (follows.length > 0) {
|
|
||||||
let since: number;
|
|
||||||
if (totalNotes === 0 || lastLogin === 0) {
|
|
||||||
since = nHoursAgo(48);
|
|
||||||
} else {
|
|
||||||
since = lastLogin;
|
|
||||||
}
|
|
||||||
|
|
||||||
const events = fetcher.allEventsIterator(
|
|
||||||
relayUrls,
|
|
||||||
{ kinds: [1], authors: follows },
|
|
||||||
{ since: since },
|
|
||||||
{ skipVerification: true }
|
|
||||||
);
|
|
||||||
for await (const event of events) {
|
|
||||||
await createNote(
|
|
||||||
event.id,
|
|
||||||
event.pubkey,
|
|
||||||
event.kind,
|
|
||||||
event.tags,
|
|
||||||
event.content,
|
|
||||||
event.created_at
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
console.log('error: ', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchChats() {
|
|
||||||
try {
|
|
||||||
const sendMessages = await fetcher.fetchAllEvents(
|
|
||||||
relayUrls,
|
|
||||||
{
|
|
||||||
kinds: [4],
|
|
||||||
authors: [account.pubkey],
|
|
||||||
},
|
|
||||||
{ since: lastLogin }
|
|
||||||
);
|
|
||||||
|
|
||||||
const receiveMessages = await fetcher.fetchAllEvents(
|
|
||||||
relayUrls,
|
|
||||||
{
|
|
||||||
kinds: [4],
|
|
||||||
'#p': [account.pubkey],
|
|
||||||
},
|
|
||||||
{ since: lastLogin }
|
|
||||||
);
|
|
||||||
|
|
||||||
const events = [...sendMessages, ...receiveMessages];
|
|
||||||
for (const event of events) {
|
|
||||||
const receiverPubkey = event.tags.find((t) => t[0] === 'p')[1] || account.pubkey;
|
|
||||||
await createChat(
|
|
||||||
event.id,
|
|
||||||
receiverPubkey,
|
|
||||||
event.pubkey,
|
|
||||||
event.content,
|
|
||||||
event.tags,
|
|
||||||
event.created_at
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
console.log('error: ', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
async function fetchChannelMessages() {
|
|
||||||
try {
|
|
||||||
const ids = [];
|
|
||||||
const channels: any = await getChannels();
|
|
||||||
channels.forEach((channel) => {
|
|
||||||
ids.push(channel.event_id);
|
|
||||||
});
|
|
||||||
|
|
||||||
const since = lastLogin === 0 ? dateToUnix(getHourAgo(48, now.current)) : lastLogin;
|
|
||||||
|
|
||||||
const filter: NDKFilter = {
|
|
||||||
'#e': ids,
|
|
||||||
kinds: [42],
|
|
||||||
since: since,
|
|
||||||
};
|
|
||||||
|
|
||||||
const events = await prefetchEvents(ndk, filter);
|
|
||||||
events.forEach((event) => {
|
|
||||||
const channel_id = event.tags[0][1];
|
|
||||||
if (channel_id) {
|
|
||||||
createChannelMessage(
|
|
||||||
channel_id,
|
|
||||||
event.id,
|
|
||||||
event.pubkey,
|
|
||||||
event.kind,
|
|
||||||
event.content,
|
|
||||||
event.tags,
|
|
||||||
event.created_at
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
console.log('error: ', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function prefetch() {
|
|
||||||
const notes = await fetchNotes();
|
|
||||||
const chats = await fetchChats();
|
|
||||||
if (notes && chats) {
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
await updateLastLogin(now);
|
|
||||||
navigate('/app/space', { replace: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === 'success' && account) {
|
|
||||||
prefetch();
|
|
||||||
}
|
|
||||||
}, [status]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-screen w-screen bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100">
|
|
||||||
<div className="flex h-screen w-full flex-col">
|
|
||||||
<div
|
|
||||||
data-tauri-drag-region
|
|
||||||
className="relative h-11 shrink-0 border border-zinc-100 bg-white dark:border-zinc-900 dark:bg-black"
|
|
||||||
/>
|
|
||||||
<div className="relative flex min-h-0 w-full flex-1 items-center justify-center">
|
|
||||||
<div className="flex flex-col items-center justify-center gap-4">
|
|
||||||
<LoaderIcon className="h-6 w-6 animate-spin text-zinc-100" />
|
|
||||||
<div className="text-center">
|
|
||||||
<h3 className="text-lg font-semibold leading-tight text-zinc-100">
|
|
||||||
Prefetching data...
|
|
||||||
</h3>
|
|
||||||
<p className="text-zinc-600">
|
|
||||||
This may take a few seconds, please don't close app.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -23,36 +23,36 @@ export function AccountSettingsScreen() {
|
|||||||
return (
|
return (
|
||||||
<div className="h-full w-full px-3 pt-12">
|
<div className="h-full w-full px-3 pt-12">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<h1 className="text-lg font-semibold text-zinc-100">Account</h1>
|
<h1 className="text-lg font-semibold text-white">Account</h1>
|
||||||
<div className="">
|
<div className="">
|
||||||
{status === 'loading' ? (
|
{status === 'loading' ? (
|
||||||
<p>Loading...</p>
|
<p>Loading...</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label htmlFor="pubkey" className="text-base font-semibold text-zinc-400">
|
<label htmlFor="pubkey" className="text-base font-semibold text-white/50">
|
||||||
Public Key
|
Public Key
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
readOnly
|
readOnly
|
||||||
value={account.pubkey}
|
value={account.pubkey}
|
||||||
className="relative w-2/3 rounded-lg bg-zinc-800 py-3 pl-3.5 pr-11 text-zinc-100 !outline-none placeholder:text-zinc-400"
|
className="relative w-2/3 rounded-lg bg-white/10 py-3 pl-3.5 pr-11 text-white !outline-none placeholder:text-white/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label htmlFor="npub" className="text-base font-semibold text-zinc-400">
|
<label htmlFor="npub" className="text-base font-semibold text-white/50">
|
||||||
Npub
|
Npub
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
readOnly
|
readOnly
|
||||||
value={account.npub}
|
value={account.npub}
|
||||||
className="relative w-2/3 rounded-lg bg-zinc-800 py-3 pl-3.5 pr-11 text-zinc-100 !outline-none placeholder:text-zinc-400"
|
className="relative w-2/3 rounded-lg bg-white/10 py-3 pl-3.5 pr-11 text-white !outline-none placeholder:text-white/50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label
|
<label
|
||||||
htmlFor="privkey"
|
htmlFor="privkey"
|
||||||
className="text-base font-semibold text-zinc-400"
|
className="text-base font-semibold text-white/50"
|
||||||
>
|
>
|
||||||
Private Key
|
Private Key
|
||||||
</label>
|
</label>
|
||||||
@@ -61,7 +61,7 @@ export function AccountSettingsScreen() {
|
|||||||
readOnly
|
readOnly
|
||||||
type={type}
|
type={type}
|
||||||
value={privkey}
|
value={privkey}
|
||||||
className="relative w-full rounded-lg bg-zinc-800 py-3 pl-3.5 pr-11 text-zinc-100 !outline-none placeholder:text-zinc-400"
|
className="relative w-full rounded-lg bg-white/10 py-3 pl-3.5 pr-11 text-white !outline-none placeholder:text-white/50"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -72,13 +72,13 @@ export function AccountSettingsScreen() {
|
|||||||
<EyeOffIcon
|
<EyeOffIcon
|
||||||
width={20}
|
width={20}
|
||||||
height={20}
|
height={20}
|
||||||
className="text-zinc-500 group-hover:text-zinc-100"
|
className="text-white/50 group-hover:text-white"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<EyeOnIcon
|
<EyeOnIcon
|
||||||
width={20}
|
width={20}
|
||||||
height={20}
|
height={20}
|
||||||
className="text-zinc-500 group-hover:text-zinc-100"
|
className="text-white/50 group-hover:text-white"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Switch } from '@headlessui/react';
|
import { Switch } from '@headlessui/react';
|
||||||
|
import { disable, enable, isEnabled } from '@tauri-apps/plugin-autostart';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
import { disable, enable, isEnabled } from 'tauri-plugin-autostart-api';
|
|
||||||
|
|
||||||
import { getSetting, updateSetting } from '@libs/storage';
|
import { getSetting, updateSetting } from '@libs/storage';
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ export function AutoStartSetting() {
|
|||||||
<div className="inline-flex items-center justify-between px-5 py-4">
|
<div className="inline-flex items-center justify-between px-5 py-4">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="font-medium leading-none text-zinc-200">Auto start</span>
|
<span className="font-medium leading-none text-zinc-200">Auto start</span>
|
||||||
<span className="text-sm leading-none text-zinc-400">Auto start at login</span>
|
<span className="text-sm leading-none text-white/50">Auto start at login</span>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={enabled}
|
checked={enabled}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function CacheTimeSetting() {
|
|||||||
<span className="font-medium leading-none text-zinc-200">
|
<span className="font-medium leading-none text-zinc-200">
|
||||||
Cache time (milliseconds)
|
Cache time (milliseconds)
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm leading-none text-zinc-400">
|
<span className="text-sm leading-none text-white/50">
|
||||||
The length of time before inactive data gets removed from the cache
|
The length of time before inactive data gets removed from the cache
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -37,7 +37,7 @@ export function CacheTimeSetting() {
|
|||||||
onClick={() => update()}
|
onClick={() => update()}
|
||||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-zinc-800 font-medium hover:bg-fuchsia-500"
|
className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-zinc-800 font-medium hover:bg-fuchsia-500"
|
||||||
>
|
>
|
||||||
<CheckCircleIcon className="h-4 w-4 text-zinc-100" />
|
<CheckCircleIcon className="h-4 w-4 text-white" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,25 +1,34 @@
|
|||||||
import { getVersion } from '@tauri-apps/api/app';
|
import { getVersion } from '@tauri-apps/plugin-app';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { RefreshIcon } from '@shared/icons';
|
import { RefreshIcon } from '@shared/icons';
|
||||||
|
|
||||||
const appVersion = await getVersion();
|
|
||||||
|
|
||||||
export function VersionSetting() {
|
export function VersionSetting() {
|
||||||
|
const [version, setVersion] = useState<string>('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function checkVersion() {
|
||||||
|
const appVersion = await getVersion();
|
||||||
|
setVersion(appVersion);
|
||||||
|
}
|
||||||
|
checkVersion();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="inline-flex items-center justify-between px-5 py-4">
|
<div className="inline-flex items-center justify-between px-5 py-4">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="font-medium leading-none text-zinc-200">Version</span>
|
<span className="font-medium leading-none text-zinc-200">Version</span>
|
||||||
<span className="text-sm leading-none text-zinc-400">
|
<span className="text-sm leading-none text-white/50">
|
||||||
You're using latest version
|
You're using latest version
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="inline-flex items-center gap-2">
|
<div className="inline-flex items-center gap-2">
|
||||||
<span className="font-medium text-zinc-300">{appVersion}</span>
|
<span className="font-medium text-zinc-300">{version}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-zinc-800 font-medium hover:bg-fuchsia-500"
|
className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-zinc-800 font-medium hover:bg-fuchsia-500"
|
||||||
>
|
>
|
||||||
<RefreshIcon className="h-4 w-4 text-zinc-100" />
|
<RefreshIcon className="h-4 w-4 text-white" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ export function GeneralSettingsScreen() {
|
|||||||
return (
|
return (
|
||||||
<div className="h-full w-full px-3 pt-12">
|
<div className="h-full w-full px-3 pt-12">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<h1 className="text-lg font-semibold text-zinc-100">General</h1>
|
<h1 className="text-lg font-semibold text-white">General</h1>
|
||||||
<div className="w-full rounded-xl border-t border-zinc-800/50 bg-zinc-900">
|
<div className="w-full rounded-xl bg-white/10">
|
||||||
<div className="flex h-full w-full flex-col divide-y divide-zinc-800">
|
<div className="flex h-full w-full flex-col divide-y divide-white/5">
|
||||||
<AutoStartSetting />
|
<AutoStartSetting />
|
||||||
<CacheTimeSetting />
|
<CacheTimeSetting />
|
||||||
<VersionSetting />
|
<VersionSetting />
|
||||||
|
|||||||
@@ -4,81 +4,79 @@ export function ShortcutsSettingsScreen() {
|
|||||||
return (
|
return (
|
||||||
<div className="h-full w-full px-3 pt-12">
|
<div className="h-full w-full px-3 pt-12">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<h1 className="text-lg font-semibold text-zinc-100">Shortcuts</h1>
|
<h1 className="text-lg font-semibold text-white">Shortcuts</h1>
|
||||||
<div className="w-full rounded-xl border-t border-zinc-800/50 bg-zinc-900">
|
<div className="w-full rounded-xl bg-white/10">
|
||||||
<div className="flex h-full w-full flex-col divide-y divide-zinc-800">
|
<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="inline-flex items-center justify-between px-5 py-4">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="font-medium leading-none text-zinc-200">
|
<span className="font-medium leading-none text-white">Open composer</span>
|
||||||
Open composer
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800">
|
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-white/10">
|
||||||
<CommandIcon width={12} height={12} className="text-zinc-500" />
|
<CommandIcon width={12} height={12} className="text-white/50" />
|
||||||
</div>
|
</div>
|
||||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800">
|
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-white/10">
|
||||||
<span className="text-sm leading-none text-zinc-500">N</span>
|
<span className="text-sm leading-none text-white/50">N</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="inline-flex items-center justify-between px-5 py-4">
|
<div className="inline-flex items-center justify-between px-5 py-4">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="font-medium leading-none text-zinc-200">
|
<span className="font-medium leading-none text-white">
|
||||||
Add image block
|
Add image block
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800">
|
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-white/10">
|
||||||
<CommandIcon width={12} height={12} className="text-zinc-500" />
|
<CommandIcon width={12} height={12} className="text-white/50" />
|
||||||
</div>
|
</div>
|
||||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800">
|
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-white/10">
|
||||||
<span className="text-sm leading-none text-zinc-500">I</span>
|
<span className="text-sm leading-none text-white/50">I</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="inline-flex items-center justify-between px-5 py-4">
|
<div className="inline-flex items-center justify-between px-5 py-4">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="font-medium leading-none text-zinc-200">
|
<span className="font-medium leading-none text-white">
|
||||||
Add newsfeed block
|
Add newsfeed block
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800">
|
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-white/10">
|
||||||
<CommandIcon width={12} height={12} className="text-zinc-500" />
|
<CommandIcon width={12} height={12} className="text-white/50" />
|
||||||
</div>
|
</div>
|
||||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800">
|
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-white/10">
|
||||||
<span className="text-sm leading-none text-zinc-500">F</span>
|
<span className="text-sm leading-none text-white/50">F</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="inline-flex items-center justify-between px-5 py-4">
|
<div className="inline-flex items-center justify-between px-5 py-4">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="font-medium leading-none text-zinc-200">
|
<span className="font-medium leading-none text-white">
|
||||||
Open personal page
|
Open personal page
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800">
|
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-white/10">
|
||||||
<CommandIcon width={12} height={12} className="text-zinc-500" />
|
<CommandIcon width={12} height={12} className="text-white/50" />
|
||||||
</div>
|
</div>
|
||||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800">
|
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-white/10">
|
||||||
<span className="text-sm leading-none text-zinc-500">P</span>
|
<span className="text-sm leading-none text-white/50">P</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="inline-flex items-center justify-between px-5 py-4">
|
<div className="inline-flex items-center justify-between px-5 py-4">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="font-medium leading-none text-zinc-200">
|
<span className="font-medium leading-none text-white">
|
||||||
Open notification
|
Open notification
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800">
|
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-white/10">
|
||||||
<CommandIcon width={12} height={12} className="text-zinc-500" />
|
<CommandIcon width={12} height={12} className="text-white/50" />
|
||||||
</div>
|
</div>
|
||||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800">
|
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-white/10">
|
||||||
<span className="text-sm leading-none text-zinc-500">B</span>
|
<span className="text-sm leading-none text-white/50">B</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
import { AddFeedBlock } from '@app/space/components/addFeed';
|
|
||||||
import { AddHashTagBlock } from '@app/space/components/addHashtag';
|
|
||||||
import { AddImageBlock } from '@app/space/components/addImage';
|
|
||||||
|
|
||||||
export function AddBlock() {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<AddImageBlock />
|
|
||||||
<AddFeedBlock />
|
|
||||||
<AddHashTagBlock />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,252 +0,0 @@
|
|||||||
import { Dialog, Transition } from '@headlessui/react';
|
|
||||||
import { Combobox } from '@headlessui/react';
|
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { nip19 } from 'nostr-tools';
|
|
||||||
import { Fragment, useState } from 'react';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
|
||||||
|
|
||||||
import { User } from '@app/auth/components/user';
|
|
||||||
|
|
||||||
import { createBlock } from '@libs/storage';
|
|
||||||
|
|
||||||
import { CancelIcon, CheckCircleIcon, CommandIcon, LoaderIcon } from '@shared/icons';
|
|
||||||
|
|
||||||
import { BLOCK_KINDS, DEFAULT_AVATAR } from '@stores/constants';
|
|
||||||
import { ADD_FEEDBLOCK_SHORTCUT } from '@stores/shortcuts';
|
|
||||||
|
|
||||||
import { useAccount } from '@utils/hooks/useAccount';
|
|
||||||
|
|
||||||
export function AddFeedBlock() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const [selected, setSelected] = useState([]);
|
|
||||||
const [query, setQuery] = useState('');
|
|
||||||
|
|
||||||
const { status, account } = useAccount();
|
|
||||||
|
|
||||||
const openModal = () => {
|
|
||||||
setIsOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeModal = () => {
|
|
||||||
setIsOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
useHotkeys(ADD_FEEDBLOCK_SHORTCUT, () => openModal());
|
|
||||||
|
|
||||||
const block = useMutation({
|
|
||||||
mutationFn: (data: { kind: number; title: string; content: string }) => {
|
|
||||||
return createBlock(data.kind, data.title, data.content);
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['blocks'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
handleSubmit,
|
|
||||||
reset,
|
|
||||||
formState: { isDirty, isValid },
|
|
||||||
} = useForm();
|
|
||||||
|
|
||||||
const onSubmit = (data: { kind: number; title: string; content: string }) => {
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
selected.forEach((item, index) => {
|
|
||||||
if (item.substring(0, 4) === 'npub') {
|
|
||||||
selected[index] = nip19.decode(item).data;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// insert to database
|
|
||||||
block.mutate({
|
|
||||||
kind: BLOCK_KINDS.feed,
|
|
||||||
title: data.title,
|
|
||||||
content: JSON.stringify(selected),
|
|
||||||
});
|
|
||||||
|
|
||||||
setLoading(false);
|
|
||||||
// reset form
|
|
||||||
reset();
|
|
||||||
// close modal
|
|
||||||
closeModal();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => openModal()}
|
|
||||||
className="inline-flex h-9 w-72 items-center justify-start gap-2.5 rounded-md px-2.5"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
|
|
||||||
<CommandIcon width={12} height={12} className="text-zinc-500" />
|
|
||||||
</div>
|
|
||||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
|
|
||||||
<span className="text-sm leading-none text-zinc-500">F</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h5 className="font-medium text-zinc-400">New feed block</h5>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<Transition appear show={isOpen} as={Fragment}>
|
|
||||||
<Dialog as="div" className="relative z-50" onClose={closeModal}>
|
|
||||||
<Transition.Child
|
|
||||||
as={Fragment}
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0"
|
|
||||||
enterTo="opacity-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100"
|
|
||||||
leaveTo="opacity-0"
|
|
||||||
>
|
|
||||||
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
|
|
||||||
</Transition.Child>
|
|
||||||
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
|
|
||||||
<Transition.Child
|
|
||||||
as={Fragment}
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0 scale-95"
|
|
||||||
enterTo="opacity-100 scale-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100 scale-100"
|
|
||||||
leaveTo="opacity-0 scale-95"
|
|
||||||
>
|
|
||||||
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col gap-2 rounded-xl border-t border-zinc-800/50 bg-zinc-900">
|
|
||||||
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-5">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Dialog.Title
|
|
||||||
as="h3"
|
|
||||||
className="text-lg font-semibold leading-none text-zinc-100"
|
|
||||||
>
|
|
||||||
Create feed block
|
|
||||||
</Dialog.Title>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={closeModal}
|
|
||||||
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
|
|
||||||
>
|
|
||||||
<CancelIcon width={14} height={14} className="text-zinc-300" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<Dialog.Description className="text-sm leading-tight text-zinc-400">
|
|
||||||
Specific newsfeed space for people you want to keep up to date
|
|
||||||
</Dialog.Description>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex h-full w-full flex-col overflow-y-auto px-5 pb-5 pt-3">
|
|
||||||
<form
|
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
|
||||||
className="mb-0 flex h-full w-full flex-col gap-4"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label
|
|
||||||
htmlFor="title"
|
|
||||||
className="text-sm font-medium uppercase tracking-wider text-zinc-400"
|
|
||||||
>
|
|
||||||
Title *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type={'text'}
|
|
||||||
{...register('title', {
|
|
||||||
required: true,
|
|
||||||
})}
|
|
||||||
spellCheck={false}
|
|
||||||
className="relative h-10 w-full rounded-md bg-zinc-800 px-3 py-2 text-zinc-100 !outline-none placeholder:text-zinc-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="text-sm font-medium uppercase tracking-wider text-zinc-400">
|
|
||||||
Choose at least 1 user *
|
|
||||||
</span>
|
|
||||||
<div className="flex h-[300px] w-full flex-col overflow-y-auto overflow-x-hidden rounded-lg border-t border-zinc-700/50 bg-zinc-800">
|
|
||||||
<div className="w-full px-3 py-2">
|
|
||||||
<Combobox value={selected} onChange={setSelected} multiple>
|
|
||||||
<Combobox.Input
|
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
|
||||||
spellCheck={false}
|
|
||||||
placeholder="Enter pubkey or npub..."
|
|
||||||
className="relative mb-2 h-10 w-full rounded-md bg-zinc-700 px-3 py-2 text-zinc-100 !outline-none placeholder:text-zinc-500"
|
|
||||||
/>
|
|
||||||
<Combobox.Options static>
|
|
||||||
{query.length > 0 && (
|
|
||||||
<Combobox.Option
|
|
||||||
value={query}
|
|
||||||
className="group flex w-full items-center justify-between rounded-md px-2 py-2 hover:bg-zinc-700"
|
|
||||||
>
|
|
||||||
{({ selected }) => (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<img
|
|
||||||
alt={query}
|
|
||||||
src={DEFAULT_AVATAR}
|
|
||||||
className="h-11 w-11 shrink-0 rounded object-cover"
|
|
||||||
/>
|
|
||||||
<div className="inline-flex flex-col gap-1">
|
|
||||||
<span className="text-base leading-tight text-zinc-400">
|
|
||||||
{query}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{selected && (
|
|
||||||
<CheckCircleIcon className="h-4 w-4 text-green-500" />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Combobox.Option>
|
|
||||||
)}
|
|
||||||
{status === 'loading' ? (
|
|
||||||
<p>Loading...</p>
|
|
||||||
) : (
|
|
||||||
JSON.parse(account.follows as string).map((follow) => (
|
|
||||||
<Combobox.Option
|
|
||||||
key={follow}
|
|
||||||
value={follow}
|
|
||||||
className="group flex w-full items-center justify-between rounded-md px-2 py-2 hover:bg-zinc-700"
|
|
||||||
>
|
|
||||||
{({ selected }) => (
|
|
||||||
<>
|
|
||||||
<User pubkey={follow} />
|
|
||||||
{selected && (
|
|
||||||
<CheckCircleIcon className="h-4 w-4 text-green-500" />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Combobox.Option>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</Combobox.Options>
|
|
||||||
</Combobox>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={!isDirty || !isValid}
|
|
||||||
className="shadow-button inline-flex h-11 w-full transform items-center justify-center rounded-lg bg-fuchsia-500 font-medium text-zinc-100 active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-30"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
|
|
||||||
) : (
|
|
||||||
'Confirm'
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</Dialog.Panel>
|
|
||||||
</Transition.Child>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
</Transition>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
import { Dialog, Transition } from '@headlessui/react';
|
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { Fragment, useState } from 'react';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
|
||||||
|
|
||||||
import { createBlock } from '@libs/storage';
|
|
||||||
|
|
||||||
import { CancelIcon, CommandIcon, LoaderIcon } from '@shared/icons';
|
|
||||||
|
|
||||||
import { BLOCK_KINDS } from '@stores/constants';
|
|
||||||
import { ADD_HASHTAGBLOCK_SHORTCUT } from '@stores/shortcuts';
|
|
||||||
|
|
||||||
export function AddHashTagBlock() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
|
|
||||||
const openModal = () => {
|
|
||||||
setIsOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeModal = () => {
|
|
||||||
setIsOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
useHotkeys(ADD_HASHTAGBLOCK_SHORTCUT, () => openModal());
|
|
||||||
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
handleSubmit,
|
|
||||||
reset,
|
|
||||||
formState: { isDirty, isValid },
|
|
||||||
} = useForm();
|
|
||||||
|
|
||||||
const block = useMutation({
|
|
||||||
mutationFn: (data: { kind: number; title: string; content: string }) => {
|
|
||||||
return createBlock(data.kind, data.title, data.content);
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['blocks'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSubmit = async (data: { hashtag: string }) => {
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
// mutate
|
|
||||||
block.mutate({
|
|
||||||
kind: BLOCK_KINDS.hashtag,
|
|
||||||
title: data.hashtag,
|
|
||||||
content: data.hashtag.replace('#', ''),
|
|
||||||
});
|
|
||||||
|
|
||||||
setLoading(false);
|
|
||||||
// reset form
|
|
||||||
reset();
|
|
||||||
// close modal
|
|
||||||
closeModal();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => openModal()}
|
|
||||||
className="inline-flex h-9 w-72 items-center justify-start gap-2.5 rounded-md px-2.5"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
|
|
||||||
<CommandIcon width={12} height={12} className="text-zinc-500" />
|
|
||||||
</div>
|
|
||||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
|
|
||||||
<span className="text-sm leading-none text-zinc-500">T</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h5 className="font-medium text-zinc-400">New hashtag block</h5>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<Transition appear show={isOpen} as={Fragment}>
|
|
||||||
<Dialog as="div" className="relative z-50" onClose={closeModal}>
|
|
||||||
<Transition.Child
|
|
||||||
as={Fragment}
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0"
|
|
||||||
enterTo="opacity-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100"
|
|
||||||
leaveTo="opacity-0"
|
|
||||||
>
|
|
||||||
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
|
|
||||||
</Transition.Child>
|
|
||||||
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
|
|
||||||
<Transition.Child
|
|
||||||
as={Fragment}
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0 scale-95"
|
|
||||||
enterTo="opacity-100 scale-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100 scale-100"
|
|
||||||
leaveTo="opacity-0 scale-95"
|
|
||||||
>
|
|
||||||
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col gap-2 rounded-xl border-t border-zinc-800/50 bg-zinc-900">
|
|
||||||
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-5">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Dialog.Title
|
|
||||||
as="h3"
|
|
||||||
className="text-lg font-semibold leading-none text-zinc-100"
|
|
||||||
>
|
|
||||||
Create hashtag block
|
|
||||||
</Dialog.Title>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={closeModal}
|
|
||||||
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
|
|
||||||
>
|
|
||||||
<CancelIcon width={14} height={14} className="text-zinc-300" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<Dialog.Description className="text-sm leading-tight text-zinc-400">
|
|
||||||
Pin the hashtag you want to keep follow up
|
|
||||||
</Dialog.Description>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex h-full w-full flex-col overflow-y-auto px-5 pb-5 pt-3">
|
|
||||||
<form
|
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
|
||||||
className="mb-0 flex h-full w-full flex-col gap-4"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label
|
|
||||||
htmlFor="title"
|
|
||||||
className="text-sm font-medium uppercase tracking-wider text-zinc-400"
|
|
||||||
>
|
|
||||||
Hashtag *
|
|
||||||
</label>
|
|
||||||
<div className="after:shadow-highlight relative w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[6px] before:border before:border-fuchsia-500 before:opacity-0 before:ring-2 before:ring-fuchsia-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[6px] after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20">
|
|
||||||
<input
|
|
||||||
type={'text'}
|
|
||||||
{...register('hashtag', {
|
|
||||||
required: true,
|
|
||||||
})}
|
|
||||||
spellCheck={false}
|
|
||||||
placeholder="#"
|
|
||||||
className="shadow-input relative h-10 w-full rounded-md border border-black/5 px-3 py-2 shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-100 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={!isDirty || !isValid}
|
|
||||||
className="shadow-button inline-flex h-11 w-full transform items-center justify-center rounded-lg bg-fuchsia-500 font-medium text-zinc-100 active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-30"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
|
|
||||||
) : (
|
|
||||||
'Confirm'
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</Dialog.Panel>
|
|
||||||
</Transition.Child>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
</Transition>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
import { Dialog, Transition } from '@headlessui/react';
|
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { Fragment, useEffect, useRef, useState } from 'react';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
|
||||||
|
|
||||||
import { createBlock } from '@libs/storage';
|
|
||||||
|
|
||||||
import { CancelIcon, CommandIcon, LoaderIcon } from '@shared/icons';
|
|
||||||
import { Image } from '@shared/image';
|
|
||||||
|
|
||||||
import { BLOCK_KINDS, DEFAULT_AVATAR } from '@stores/constants';
|
|
||||||
import { ADD_IMAGEBLOCK_SHORTCUT } from '@stores/shortcuts';
|
|
||||||
|
|
||||||
import { usePublish } from '@utils/hooks/usePublish';
|
|
||||||
import { useImageUploader } from '@utils/hooks/useUploader';
|
|
||||||
|
|
||||||
export function AddImageBlock() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const upload = useImageUploader();
|
|
||||||
|
|
||||||
const { publish } = usePublish();
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const [image, setImage] = useState('');
|
|
||||||
|
|
||||||
const tags = useRef(null);
|
|
||||||
|
|
||||||
const openModal = () => {
|
|
||||||
setIsOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeModal = () => {
|
|
||||||
setIsOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
useHotkeys(ADD_IMAGEBLOCK_SHORTCUT, () => openModal());
|
|
||||||
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
handleSubmit,
|
|
||||||
reset,
|
|
||||||
setValue,
|
|
||||||
formState: { isDirty, isValid },
|
|
||||||
} = useForm();
|
|
||||||
|
|
||||||
const block = useMutation({
|
|
||||||
mutationFn: (data: { kind: number; title: string; content: string }) => {
|
|
||||||
return createBlock(data.kind, data.title, data.content);
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['blocks'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const uploadImage = async () => {
|
|
||||||
const image = await upload(null);
|
|
||||||
if (image.url) {
|
|
||||||
setImage(image.url);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSubmit = async (data: { kind: number; title: string; content: string }) => {
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
// publish file metedata
|
|
||||||
await publish({ content: data.title, kind: 1063, tags: tags.current });
|
|
||||||
|
|
||||||
// mutate
|
|
||||||
block.mutate({ kind: BLOCK_KINDS.image, title: data.title, content: data.content });
|
|
||||||
|
|
||||||
setLoading(false);
|
|
||||||
// reset form
|
|
||||||
reset();
|
|
||||||
// close modal
|
|
||||||
closeModal();
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setValue('content', image);
|
|
||||||
}, [setValue, image]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => openModal()}
|
|
||||||
className="inline-flex h-9 w-72 items-center justify-start gap-2.5 rounded-md px-2.5"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
|
|
||||||
<CommandIcon width={12} height={12} className="text-zinc-500" />
|
|
||||||
</div>
|
|
||||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
|
|
||||||
<span className="text-sm leading-none text-zinc-500">I</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h5 className="font-medium text-zinc-400">New image block</h5>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<Transition appear show={isOpen} as={Fragment}>
|
|
||||||
<Dialog as="div" className="relative z-50" onClose={closeModal}>
|
|
||||||
<Transition.Child
|
|
||||||
as={Fragment}
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0"
|
|
||||||
enterTo="opacity-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100"
|
|
||||||
leaveTo="opacity-0"
|
|
||||||
>
|
|
||||||
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
|
|
||||||
</Transition.Child>
|
|
||||||
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
|
|
||||||
<Transition.Child
|
|
||||||
as={Fragment}
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0 scale-95"
|
|
||||||
enterTo="opacity-100 scale-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100 scale-100"
|
|
||||||
leaveTo="opacity-0 scale-95"
|
|
||||||
>
|
|
||||||
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col gap-2 rounded-xl border-t border-zinc-800/50 bg-zinc-900">
|
|
||||||
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-5">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Dialog.Title
|
|
||||||
as="h3"
|
|
||||||
className="text-lg font-semibold leading-none text-zinc-100"
|
|
||||||
>
|
|
||||||
Create image block
|
|
||||||
</Dialog.Title>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={closeModal}
|
|
||||||
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
|
|
||||||
>
|
|
||||||
<CancelIcon width={14} height={14} className="text-zinc-300" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<Dialog.Description className="text-sm leading-tight text-zinc-400">
|
|
||||||
Pin your favorite image to Space then you can view every time that
|
|
||||||
you use Lume, your image will be broadcast to Nostr Relay as well
|
|
||||||
</Dialog.Description>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex h-full w-full flex-col overflow-y-auto px-5 pb-5 pt-3">
|
|
||||||
<form
|
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
|
||||||
className="mb-0 flex h-full w-full flex-col gap-4"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type={'hidden'}
|
|
||||||
{...register('content')}
|
|
||||||
value={image}
|
|
||||||
className="shadow-input relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-100 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label
|
|
||||||
htmlFor="title"
|
|
||||||
className="text-sm font-medium uppercase tracking-wider text-zinc-400"
|
|
||||||
>
|
|
||||||
Title *
|
|
||||||
</label>
|
|
||||||
<div className="after:shadow-highlight relative w-full shrink-0 overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[6px] before:border before:border-fuchsia-500 before:opacity-0 before:ring-2 before:ring-fuchsia-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[6px] after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20">
|
|
||||||
<input
|
|
||||||
type={'text'}
|
|
||||||
{...register('title', {
|
|
||||||
required: true,
|
|
||||||
})}
|
|
||||||
spellCheck={false}
|
|
||||||
className="shadow-input relative h-10 w-full rounded-md border border-black/5 px-3 py-2 shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-zinc-100 dark:shadow-black/10 dark:placeholder:text-zinc-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label
|
|
||||||
htmlFor="picture"
|
|
||||||
className="text-sm font-medium uppercase tracking-wider text-zinc-400"
|
|
||||||
>
|
|
||||||
Picture
|
|
||||||
</label>
|
|
||||||
<div className="relative inline-flex h-56 w-full items-center justify-center overflow-hidden rounded-lg border border-zinc-900 bg-zinc-950">
|
|
||||||
<Image
|
|
||||||
src={image}
|
|
||||||
fallback={DEFAULT_AVATAR}
|
|
||||||
alt="content"
|
|
||||||
className="relative z-10 h-auto max-h-[156px] w-[150px] rounded-md object-cover"
|
|
||||||
/>
|
|
||||||
<div className="absolute bottom-3 right-3 z-10">
|
|
||||||
<button
|
|
||||||
onClick={() => uploadImage()}
|
|
||||||
type="button"
|
|
||||||
className="inline-flex h-6 items-center justify-center rounded bg-zinc-900 px-3 text-sm font-medium text-zinc-300 ring-1 ring-zinc-800 hover:bg-zinc-800"
|
|
||||||
>
|
|
||||||
Upload
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={!isDirty || !isValid}
|
|
||||||
className="shadow-button inline-flex h-11 w-full transform items-center justify-center rounded-lg bg-fuchsia-500 font-medium text-zinc-100 active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-30"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
|
|
||||||
) : (
|
|
||||||
'Confirm'
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</Dialog.Panel>
|
|
||||||
</Transition.Child>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
</Transition>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -9,11 +9,11 @@ import { NoteKindUnsupport } from '@shared/notes/kinds/unsupport';
|
|||||||
import { NoteSkeleton } from '@shared/notes/skeleton';
|
import { NoteSkeleton } from '@shared/notes/skeleton';
|
||||||
import { TitleBar } from '@shared/titleBar';
|
import { TitleBar } from '@shared/titleBar';
|
||||||
|
|
||||||
import { Block, LumeEvent } from '@utils/types';
|
import { LumeEvent, Widget } from '@utils/types';
|
||||||
|
|
||||||
const ITEM_PER_PAGE = 10;
|
const ITEM_PER_PAGE = 10;
|
||||||
|
|
||||||
export function FeedBlock({ params }: { params: Block }) {
|
export function FeedBlock({ params }: { params: Widget }) {
|
||||||
const { status, data, fetchNextPage, hasNextPage, isFetchingNextPage } =
|
const { status, data, fetchNextPage, hasNextPage, isFetchingNextPage } =
|
||||||
useInfiniteQuery({
|
useInfiniteQuery({
|
||||||
queryKey: ['newsfeed', params.content],
|
queryKey: ['newsfeed', params.content],
|
||||||
@@ -34,6 +34,7 @@ export function FeedBlock({ params }: { params: Block }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const itemsVirtualizer = rowVirtualizer.getVirtualItems();
|
const itemsVirtualizer = rowVirtualizer.getVirtualItems();
|
||||||
|
const totalSize = rowVirtualizer.getTotalSize();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const [lastItem] = [...rowVirtualizer.getVirtualItems()].reverse();
|
const [lastItem] = [...rowVirtualizer.getVirtualItems()].reverse();
|
||||||
@@ -113,24 +114,20 @@ export function FeedBlock({ params }: { params: Block }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-[400px] shrink-0 border-r border-zinc-900">
|
<div className="relative w-[400px] shrink-0 bg-white/10">
|
||||||
<TitleBar id={params.id} title={params.title} />
|
<TitleBar id={params.id} title={params.title} />
|
||||||
<div
|
<div ref={parentRef} className="scrollbar-hide h-full overflow-y-auto pb-20">
|
||||||
ref={parentRef}
|
|
||||||
className="scrollbar-hide flex h-full w-full flex-col justify-between gap-1.5 overflow-y-auto pb-20 pt-1.5"
|
|
||||||
style={{ contain: 'strict' }}
|
|
||||||
>
|
|
||||||
{status === 'loading' ? (
|
{status === 'loading' ? (
|
||||||
<div className="px-3 py-1.5">
|
<div className="px-3 py-1.5">
|
||||||
<div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 py-3">
|
<div className="rounded-xl bg-white/10 px-3 py-3">
|
||||||
<NoteSkeleton />
|
<NoteSkeleton />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : itemsVirtualizer.length === 0 ? (
|
) : itemsVirtualizer.length === 0 ? (
|
||||||
<div className="px-3 py-1.5">
|
<div className="px-3 py-1.5">
|
||||||
<div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 py-6">
|
<div className="bbg-white/10 rounded-xl px-3 py-6">
|
||||||
<div className="flex flex-col items-center gap-4">
|
<div className="flex flex-col items-center gap-4">
|
||||||
<p className="text-center text-sm text-zinc-300">
|
<p className="text-center text-sm text-white">
|
||||||
Not found any posts from last 48 hours
|
Not found any posts from last 48 hours
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -140,7 +137,7 @@ export function FeedBlock({ params }: { params: Block }) {
|
|||||||
<div
|
<div
|
||||||
className="relative w-full"
|
className="relative w-full"
|
||||||
style={{
|
style={{
|
||||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
height: `${totalSize}px`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -157,7 +154,7 @@ export function FeedBlock({ params }: { params: Block }) {
|
|||||||
)}
|
)}
|
||||||
{isFetchingNextPage && (
|
{isFetchingNextPage && (
|
||||||
<div className="px-3 py-1.5">
|
<div className="px-3 py-1.5">
|
||||||
<div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 py-3">
|
<div className="rounded-xl bg-white/10 px-3 py-3">
|
||||||
<NoteSkeleton />
|
<NoteSkeleton />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,17 +8,17 @@ import { NoteKind_1, NoteSkeleton } from '@shared/notes';
|
|||||||
import { TitleBar } from '@shared/titleBar';
|
import { TitleBar } from '@shared/titleBar';
|
||||||
|
|
||||||
import { nHoursAgo } from '@utils/date';
|
import { nHoursAgo } from '@utils/date';
|
||||||
import { Block, LumeEvent } from '@utils/types';
|
import { LumeEvent, Widget } from '@utils/types';
|
||||||
|
|
||||||
export function HashtagBlock({ params }: { params: Block }) {
|
export function HashtagBlock({ params }: { params: Widget }) {
|
||||||
const { relayUrls, fetcher } = useNDK();
|
const { ndk } = useNDK();
|
||||||
const { status, data } = useQuery(['hashtag', params.content], async () => {
|
const { status, data } = useQuery(['hashtag', params.content], async () => {
|
||||||
const events = (await fetcher.fetchAllEvents(
|
const events = await ndk.fetchEvents({
|
||||||
relayUrls,
|
kinds: [1],
|
||||||
{ kinds: [1], '#t': [params.content] },
|
'#t': [params.content],
|
||||||
{ since: nHoursAgo(48) }
|
since: nHoursAgo(24),
|
||||||
)) as unknown as LumeEvent[];
|
});
|
||||||
return events;
|
return [...events] as unknown as LumeEvent[];
|
||||||
});
|
});
|
||||||
|
|
||||||
const parentRef = useRef();
|
const parentRef = useRef();
|
||||||
@@ -29,27 +29,24 @@ export function HashtagBlock({ params }: { params: Block }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const itemsVirtualizer = rowVirtualizer.getVirtualItems();
|
const itemsVirtualizer = rowVirtualizer.getVirtualItems();
|
||||||
|
const totalSize = rowVirtualizer.getTotalSize();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-[400px] shrink-0 border-r border-zinc-900">
|
<div className="relative w-[400px] shrink-0 bg-white/10">
|
||||||
<TitleBar id={params.id} title={params.title + ' in 48 hours ago'} />
|
<TitleBar id={params.id} title={params.title + ' in 24 hours ago'} />
|
||||||
<div
|
<div ref={parentRef} className="scrollbar-hide h-full overflow-y-auto pb-20">
|
||||||
ref={parentRef}
|
|
||||||
className="scrollbar-hide flex h-full w-full flex-col justify-between gap-1.5 overflow-y-auto pb-20 pt-1.5"
|
|
||||||
style={{ contain: 'strict' }}
|
|
||||||
>
|
|
||||||
{status === 'loading' ? (
|
{status === 'loading' ? (
|
||||||
<div className="px-3 py-1.5">
|
<div className="px-3 py-1.5">
|
||||||
<div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 pt-3">
|
<div className="rounded-xl bg-white/10 px-3 py-3">
|
||||||
<NoteSkeleton />
|
<NoteSkeleton />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : itemsVirtualizer.length === 0 ? (
|
) : itemsVirtualizer.length === 0 ? (
|
||||||
<div className="px-3 py-1.5">
|
<div className="px-3 py-1.5">
|
||||||
<div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 py-6">
|
<div className="rounded-xl bg-white/10 px-3 py-6">
|
||||||
<div className="flex flex-col items-center gap-4">
|
<div className="flex flex-col items-center gap-4">
|
||||||
<p className="text-center text-sm text-zinc-300">
|
<p className="text-center text-sm font-medium text-white">
|
||||||
No new posts about this hashtag in 48 hours ago
|
No new posts about this hashtag in 24 hours ago
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -58,7 +55,7 @@ export function HashtagBlock({ params }: { params: Block }) {
|
|||||||
<div
|
<div
|
||||||
className="relative w-full"
|
className="relative w-full"
|
||||||
style={{
|
style={{
|
||||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
height: `${totalSize}px`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -2,22 +2,22 @@ import { CancelIcon } from '@shared/icons';
|
|||||||
import { Image } from '@shared/image';
|
import { Image } from '@shared/image';
|
||||||
|
|
||||||
import { DEFAULT_AVATAR } from '@stores/constants';
|
import { DEFAULT_AVATAR } from '@stores/constants';
|
||||||
|
import { useWidgets } from '@stores/widgets';
|
||||||
|
|
||||||
import { useBlock } from '@utils/hooks/useBlock';
|
import { Widget } from '@utils/types';
|
||||||
import { Block } from '@utils/types';
|
|
||||||
|
|
||||||
export function ImageBlock({ params }: { params: Block }) {
|
export function ImageBlock({ params }: { params: Widget }) {
|
||||||
const { remove } = useBlock();
|
const remove = useWidgets((state) => state.removeWidget);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-[350px] shrink-0 flex-col justify-between border-r border-zinc-900">
|
<div className="flex h-full w-[400px] shrink-0 flex-col justify-between">
|
||||||
<div className="relative h-full w-full flex-1 overflow-hidden p-3">
|
<div className="relative h-full w-full flex-1 overflow-hidden p-3">
|
||||||
<div className="absolute left-0 top-3 h-16 w-full px-3">
|
<div className="absolute left-0 top-3 h-16 w-full px-3">
|
||||||
<div className="flex h-16 items-center justify-between overflow-hidden rounded-t-xl px-5">
|
<div className="flex h-16 items-center justify-between overflow-hidden rounded-t-xl px-5">
|
||||||
<h3 className="font-medium text-white drop-shadow-lg">{params.title}</h3>
|
<h3 className="font-medium text-white drop-shadow-lg">{params.title}</h3>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => remove.mutate(params.id)}
|
onClick={() => remove(params.id)}
|
||||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md bg-white/30 backdrop-blur-lg"
|
className="inline-flex h-7 w-7 items-center justify-center rounded-md bg-white/30 backdrop-blur-lg"
|
||||||
>
|
>
|
||||||
<CancelIcon width={16} height={16} className="text-white" />
|
<CancelIcon width={16} height={16} className="text-white" />
|
||||||
@@ -28,7 +28,7 @@ export function ImageBlock({ params }: { params: Block }) {
|
|||||||
src={params.content}
|
src={params.content}
|
||||||
fallback={DEFAULT_AVATAR}
|
fallback={DEFAULT_AVATAR}
|
||||||
alt={params.title}
|
alt={params.title}
|
||||||
className="h-full w-full rounded-xl border-t border-zinc-800/50 object-cover"
|
className="h-full w-full rounded-xl object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { LumeEvent } from '@utils/types';
|
|||||||
|
|
||||||
const ITEM_PER_PAGE = 10;
|
const ITEM_PER_PAGE = 10;
|
||||||
|
|
||||||
export function FollowingBlock() {
|
export function NetworkBlock() {
|
||||||
// subscribe for live update
|
// subscribe for live update
|
||||||
useNewsfeed();
|
useNewsfeed();
|
||||||
|
|
||||||
@@ -40,9 +40,10 @@ export function FollowingBlock() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const itemsVirtualizer = rowVirtualizer.getVirtualItems();
|
const itemsVirtualizer = rowVirtualizer.getVirtualItems();
|
||||||
|
const totalSize = rowVirtualizer.getTotalSize();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const [lastItem] = [...rowVirtualizer.getVirtualItems()].reverse();
|
const [lastItem] = [...itemsVirtualizer].reverse();
|
||||||
|
|
||||||
if (!lastItem) {
|
if (!lastItem) {
|
||||||
return;
|
return;
|
||||||
@@ -51,7 +52,7 @@ export function FollowingBlock() {
|
|||||||
if (lastItem.index >= notes.length - 1 && hasNextPage && !isFetchingNextPage) {
|
if (lastItem.index >= notes.length - 1 && hasNextPage && !isFetchingNextPage) {
|
||||||
fetchNextPage();
|
fetchNextPage();
|
||||||
}
|
}
|
||||||
}, [notes.length, fetchNextPage, rowVirtualizer.getVirtualItems()]);
|
}, [notes.length, fetchNextPage, itemsVirtualizer]);
|
||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
(index: string | number) => {
|
(index: string | number) => {
|
||||||
@@ -125,30 +126,26 @@ export function FollowingBlock() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-[400px] shrink-0 border-r border-zinc-900">
|
<div className="relative w-[400px] shrink-0 bg-white/10">
|
||||||
<TitleBar title="Your Circle" />
|
<TitleBar title="Network" />
|
||||||
<div
|
<div ref={parentRef} className="scrollbar-hide h-full overflow-y-auto pb-20">
|
||||||
ref={parentRef}
|
|
||||||
className="scrollbar-hide flex h-full w-full flex-col justify-between gap-1.5 overflow-y-auto pb-20 pt-1.5"
|
|
||||||
style={{ contain: 'strict' }}
|
|
||||||
>
|
|
||||||
{status === 'loading' ? (
|
{status === 'loading' ? (
|
||||||
<div className="px-3 py-1.5">
|
<div className="px-3 py-1.5">
|
||||||
<div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 py-3">
|
<div className="rounded-xl bg-white/10 px-3 py-3">
|
||||||
<NoteSkeleton />
|
<NoteSkeleton />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : itemsVirtualizer.length === 0 ? (
|
) : itemsVirtualizer.length === 0 ? (
|
||||||
<div className="px-3 py-1.5">
|
<div className="px-3 py-1.5">
|
||||||
<div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 py-6">
|
<div className="rounded-xl bg-white/10 px-3 py-6">
|
||||||
<div className="flex flex-col items-center gap-4">
|
<div className="flex flex-col items-center gap-4">
|
||||||
<p className="text-center text-sm text-zinc-300">
|
<p className="text-center text-sm text-white">
|
||||||
You not have any posts to see yet
|
You not have any posts to see yet
|
||||||
<br />
|
<br />
|
||||||
Follow more people to have more fun.
|
Follow more people to have more fun.
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
to="/app/trending"
|
to="/trending"
|
||||||
className="inline-flex w-max rounded bg-fuchsia-500 px-2.5 py-1.5 text-sm hover:bg-fuchsia-600"
|
className="inline-flex w-max rounded bg-fuchsia-500 px-2.5 py-1.5 text-sm hover:bg-fuchsia-600"
|
||||||
>
|
>
|
||||||
Trending
|
Trending
|
||||||
@@ -160,7 +157,7 @@ export function FollowingBlock() {
|
|||||||
<div
|
<div
|
||||||
className="relative w-full"
|
className="relative w-full"
|
||||||
style={{
|
style={{
|
||||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
height: `${totalSize}px`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -177,7 +174,7 @@ export function FollowingBlock() {
|
|||||||
)}
|
)}
|
||||||
{isFetchingNextPage && (
|
{isFetchingNextPage && (
|
||||||
<div className="px-3 py-1.5">
|
<div className="px-3 py-1.5">
|
||||||
<div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 py-3">
|
<div className="rounded-xl bg-white/10 px-3 py-3">
|
||||||
<NoteSkeleton />
|
<NoteSkeleton />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -12,9 +12,9 @@ import { TitleBar } from '@shared/titleBar';
|
|||||||
|
|
||||||
import { useAccount } from '@utils/hooks/useAccount';
|
import { useAccount } from '@utils/hooks/useAccount';
|
||||||
import { useEvent } from '@utils/hooks/useEvent';
|
import { useEvent } from '@utils/hooks/useEvent';
|
||||||
import { Block } from '@utils/types';
|
import { Widget } from '@utils/types';
|
||||||
|
|
||||||
export function ThreadBlock({ params }: { params: Block }) {
|
export function ThreadBlock({ params }: { params: Widget }) {
|
||||||
const { status, data } = useEvent(params.content);
|
const { status, data } = useEvent(params.content);
|
||||||
const { account } = useAccount();
|
const { account } = useAccount();
|
||||||
|
|
||||||
@@ -22,31 +22,35 @@ export function ThreadBlock({ params }: { params: Block }) {
|
|||||||
// useLiveThread(params.content);
|
// useLiveThread(params.content);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-[400px] shrink-0 border-r border-zinc-900">
|
<div className="scrollbar-hide h-full w-[400px] shrink-0 overflow-y-auto bg-white/10 pb-20">
|
||||||
<TitleBar id={params.id} title={params.title} />
|
<TitleBar id={params.id} title={params.title} />
|
||||||
<div className="scrollbar-hide flex h-full w-full flex-col gap-3 overflow-y-auto pb-20 pt-1.5">
|
<div className="h-full">
|
||||||
{status === 'loading' ? (
|
{status === 'loading' ? (
|
||||||
<div className="px-3 py-1.5">
|
<div className="px-3 py-1.5">
|
||||||
<div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 py-3">
|
<div className="rounded-xl bg-white/10 px-3 py-3">
|
||||||
<NoteSkeleton />
|
<NoteSkeleton />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-min w-full px-3 pt-1.5">
|
<div className="h-min w-full px-3 pt-1.5">
|
||||||
<div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 pt-3">
|
<div className="rounded-xl bg-white/10 px-3 pt-3">
|
||||||
<ThreadUser pubkey={data.pubkey} time={data.created_at} />
|
<ThreadUser pubkey={data.pubkey} time={data.created_at} />
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<NoteContent content={data.content} />
|
<NoteContent content={data.content} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<NoteActions id={data.id} pubkey={data.pubkey} noOpenThread={true} />
|
<NoteActions
|
||||||
<NoteStats id={data.id} />
|
id={params.content}
|
||||||
|
pubkey={data.pubkey}
|
||||||
|
noOpenThread={true}
|
||||||
|
/>
|
||||||
|
<NoteStats id={params.content} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="px-3">
|
<div className="px-3">
|
||||||
<NoteReplyForm id={params.content} pubkey={account.pubkey} />
|
{account && <NoteReplyForm id={params.content} pubkey={account.pubkey} />}
|
||||||
<RepliesList id={params.content} />
|
<RepliesList id={params.content} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,20 +9,19 @@ import { TitleBar } from '@shared/titleBar';
|
|||||||
import { UserProfile } from '@shared/userProfile';
|
import { UserProfile } from '@shared/userProfile';
|
||||||
|
|
||||||
import { nHoursAgo } from '@utils/date';
|
import { nHoursAgo } from '@utils/date';
|
||||||
import { Block, LumeEvent } from '@utils/types';
|
import { LumeEvent, Widget } from '@utils/types';
|
||||||
|
|
||||||
export function UserBlock({ params }: { params: Block }) {
|
export function UserBlock({ params }: { params: Widget }) {
|
||||||
const parentRef = useRef<HTMLDivElement>(null);
|
const parentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const { fetcher, relayUrls } = useNDK();
|
const { ndk } = useNDK();
|
||||||
const { status, data } = useQuery(['user-feed', params.content], async () => {
|
const { status, data } = useQuery(['user-feed', params.content], async () => {
|
||||||
const events = await fetcher.fetchAllEvents(
|
const events = await ndk.fetchEvents({
|
||||||
relayUrls,
|
kinds: [1],
|
||||||
{ kinds: [1], authors: [params.content] },
|
authors: [params.content],
|
||||||
{ since: nHoursAgo(48) },
|
since: nHoursAgo(48),
|
||||||
{ sort: true }
|
});
|
||||||
);
|
return [...events] as unknown as LumeEvent[];
|
||||||
return events as unknown as LumeEvent[];
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const rowVirtualizer = useVirtualizer({
|
const rowVirtualizer = useVirtualizer({
|
||||||
@@ -35,32 +34,27 @@ export function UserBlock({ params }: { params: Block }) {
|
|||||||
const itemsVirtualizer = rowVirtualizer.getVirtualItems();
|
const itemsVirtualizer = rowVirtualizer.getVirtualItems();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-[400px] shrink-0 border-r border-zinc-900">
|
<div className="relative w-[400px] shrink-0 bg-white/10">
|
||||||
<TitleBar id={params.id} title={params.title} />
|
<TitleBar id={params.id} title={params.title} />
|
||||||
<div
|
<div ref={parentRef} className="scrollbar-hide h-full overflow-y-auto pb-20">
|
||||||
ref={parentRef}
|
|
||||||
className="scrollbar-hide flex h-full flex-1 flex-col gap-1.5 overflow-y-auto pt-1.5"
|
|
||||||
>
|
|
||||||
<div className="px-3 pt-1.5">
|
<div className="px-3 pt-1.5">
|
||||||
<UserProfile pubkey={params.content} />
|
<UserProfile pubkey={params.content} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mt-2 px-3 text-lg font-semibold text-zinc-300">
|
<h3 className="mt-4 px-3 text-lg font-semibold text-white">Latest postrs</h3>
|
||||||
Latest activities
|
|
||||||
</h3>
|
|
||||||
<div className="flex h-full w-full flex-col justify-between gap-1.5 pb-10">
|
<div className="flex h-full w-full flex-col justify-between gap-1.5 pb-10">
|
||||||
{status === 'loading' ? (
|
{status === 'loading' ? (
|
||||||
<div className="px-3 py-1.5">
|
<div className="px-3 py-1.5">
|
||||||
<div className="shadow-input rounded-md bg-zinc-900 px-3 py-3 shadow-black/20">
|
<div className="rounded-md bg-white/10 px-3 py-3">
|
||||||
<NoteSkeleton />
|
<NoteSkeleton />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : itemsVirtualizer.length === 0 ? (
|
) : itemsVirtualizer.length === 0 ? (
|
||||||
<div className="px-3 py-1.5">
|
<div className="px-3 py-1.5">
|
||||||
<div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 py-6">
|
<div className="rounded-xl bg-white/10 px-3 py-6">
|
||||||
<div className="flex flex-col items-center gap-4">
|
<div className="flex flex-col items-center gap-4">
|
||||||
<p className="text-center text-sm text-zinc-300">
|
<p className="text-center text-sm text-white">
|
||||||
No new posts about this hashtag in 48 hours ago
|
No new posts from this user in 48 hours ago
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
201
src/app/space/components/modals/feed.tsx
Normal file
201
src/app/space/components/modals/feed.tsx
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import { Combobox } from '@headlessui/react';
|
||||||
|
import * as Dialog from '@radix-ui/react-dialog';
|
||||||
|
import { nip19 } from 'nostr-tools';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
|
|
||||||
|
import { User } from '@app/auth/components/user';
|
||||||
|
|
||||||
|
import { CancelIcon, CheckCircleIcon, CommandIcon, LoaderIcon } from '@shared/icons';
|
||||||
|
|
||||||
|
import { BLOCK_KINDS, DEFAULT_AVATAR } from '@stores/constants';
|
||||||
|
import { ADD_FEEDBLOCK_SHORTCUT } from '@stores/shortcuts';
|
||||||
|
import { useWidgets } from '@stores/widgets';
|
||||||
|
|
||||||
|
import { useAccount } from '@utils/hooks/useAccount';
|
||||||
|
|
||||||
|
export function FeedModal() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [selected, setSelected] = useState([]);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
|
||||||
|
const { status, account } = useAccount();
|
||||||
|
|
||||||
|
const setWidget = useWidgets((state) => state.setWidget);
|
||||||
|
|
||||||
|
useHotkeys(ADD_FEEDBLOCK_SHORTCUT, () => setOpen(true));
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
formState: { isDirty, isValid },
|
||||||
|
} = useForm();
|
||||||
|
|
||||||
|
const onSubmit = (data: { kind: number; title: string; content: string }) => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
selected.forEach((item, index) => {
|
||||||
|
if (item.substring(0, 4) === 'npub') {
|
||||||
|
selected[index] = nip19.decode(item).data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// insert to database
|
||||||
|
setWidget({
|
||||||
|
kind: BLOCK_KINDS.feed,
|
||||||
|
title: data.title,
|
||||||
|
content: JSON.stringify(selected),
|
||||||
|
});
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
// reset form
|
||||||
|
reset();
|
||||||
|
// close modal
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Root open={open} onOpenChange={setOpen}>
|
||||||
|
<Dialog.Trigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex h-9 w-72 items-center justify-start gap-2.5 rounded-md px-2.5"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-white/10">
|
||||||
|
<CommandIcon className="h-3 w-3 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-white/10">
|
||||||
|
<span className="text-sm leading-none text-white">F</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h5 className="font-medium text-white/50">Add newsfeed widget</h5>
|
||||||
|
</button>
|
||||||
|
</Dialog.Trigger>
|
||||||
|
<Dialog.Portal className="relative z-10">
|
||||||
|
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xl" />
|
||||||
|
<Dialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
|
||||||
|
<div className="relative h-min w-full max-w-xl rounded-xl bg-white/10">
|
||||||
|
<div className="h-min w-full shrink-0 border-b border-white/10 bg-white/5 px-5 py-5">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Dialog.Title className="text-lg font-semibold leading-none text-white">
|
||||||
|
Create feed block
|
||||||
|
</Dialog.Title>
|
||||||
|
<Dialog.Close className="inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-white/10">
|
||||||
|
<CancelIcon className="h-4 w-4 text-white/50" />
|
||||||
|
</Dialog.Close>
|
||||||
|
</div>
|
||||||
|
<Dialog.Description className="text-sm leading-tight text-white/50">
|
||||||
|
Specific newsfeed space for people you want to keep up to date
|
||||||
|
</Dialog.Description>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col overflow-y-auto overflow-x-hidden px-5 pb-5 pt-2">
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
className="mb-0 flex h-full w-full flex-col gap-4"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label
|
||||||
|
htmlFor="title"
|
||||||
|
className="text-sm font-medium uppercase tracking-wider text-white/50"
|
||||||
|
>
|
||||||
|
Title *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type={'text'}
|
||||||
|
{...register('title', {
|
||||||
|
required: true,
|
||||||
|
})}
|
||||||
|
spellCheck={false}
|
||||||
|
className="relative h-11 w-full rounded-lg bg-white/10 px-3 py-2 text-white !outline-none placeholder:text-white/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-sm font-medium uppercase tracking-wider text-white/50">
|
||||||
|
Choose at least 1 user *
|
||||||
|
</span>
|
||||||
|
<div className="flex h-[300px] w-full flex-col overflow-y-auto overflow-x-hidden rounded-lg bg-white/10">
|
||||||
|
<div className="w-full px-3 py-2">
|
||||||
|
<Combobox value={selected} onChange={setSelected} multiple>
|
||||||
|
<Combobox.Input
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder="Enter pubkey or npub..."
|
||||||
|
className="relative mb-2 h-10 w-full rounded-md bg-white/10 px-3 py-2 text-white !outline-none placeholder:text-white/50"
|
||||||
|
/>
|
||||||
|
<Combobox.Options static>
|
||||||
|
{query.length > 0 && (
|
||||||
|
<Combobox.Option
|
||||||
|
value={query}
|
||||||
|
className="group flex w-full items-center justify-between rounded-md px-2 py-2 hover:bg-white/10"
|
||||||
|
>
|
||||||
|
{({ selected }) => (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<img
|
||||||
|
alt={query}
|
||||||
|
src={DEFAULT_AVATAR}
|
||||||
|
className="h-11 w-11 shrink-0 rounded object-cover"
|
||||||
|
/>
|
||||||
|
<div className="inline-flex flex-col gap-1">
|
||||||
|
<span className="text-base leading-tight text-white/50">
|
||||||
|
{query}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{selected && (
|
||||||
|
<CheckCircleIcon className="h-4 w-4 text-green-500" />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Combobox.Option>
|
||||||
|
)}
|
||||||
|
{status === 'loading' ? (
|
||||||
|
<p>Loading...</p>
|
||||||
|
) : (
|
||||||
|
account?.follows?.map((follow) => (
|
||||||
|
<Combobox.Option
|
||||||
|
key={follow}
|
||||||
|
value={follow}
|
||||||
|
className="group flex w-full items-center justify-between rounded-md px-2 py-2 hover:bg-white/10"
|
||||||
|
>
|
||||||
|
{({ selected }) => (
|
||||||
|
<>
|
||||||
|
<User pubkey={follow} />
|
||||||
|
{selected && (
|
||||||
|
<CheckCircleIcon className="h-4 w-4 text-green-500" />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Combobox.Option>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Combobox.Options>
|
||||||
|
</Combobox>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!isDirty || !isValid}
|
||||||
|
className="shadow-button inline-flex h-11 w-full transform items-center justify-center rounded-lg bg-fuchsia-500 font-medium text-white active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-30"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-white" />
|
||||||
|
) : (
|
||||||
|
'Confirm'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
src/app/space/components/modals/hashtag.tsx
Normal file
121
src/app/space/components/modals/hashtag.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import * as Dialog from '@radix-ui/react-dialog';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
|
|
||||||
|
import { CancelIcon, CommandIcon, LoaderIcon } from '@shared/icons';
|
||||||
|
|
||||||
|
import { BLOCK_KINDS } from '@stores/constants';
|
||||||
|
import { ADD_HASHTAGBLOCK_SHORTCUT } from '@stores/shortcuts';
|
||||||
|
import { useWidgets } from '@stores/widgets';
|
||||||
|
|
||||||
|
export function HashtagModal() {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const setWidget = useWidgets((state) => state.setWidget);
|
||||||
|
|
||||||
|
useHotkeys(ADD_HASHTAGBLOCK_SHORTCUT, () => setOpen(false));
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
formState: { isDirty, isValid },
|
||||||
|
} = useForm();
|
||||||
|
|
||||||
|
const onSubmit = async (data: { hashtag: string }) => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// mutate
|
||||||
|
setWidget({
|
||||||
|
kind: BLOCK_KINDS.hashtag,
|
||||||
|
title: data.hashtag,
|
||||||
|
content: data.hashtag.replace('#', ''),
|
||||||
|
});
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
// reset form
|
||||||
|
reset();
|
||||||
|
// close modal
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Root open={open} onOpenChange={setOpen}>
|
||||||
|
<Dialog.Trigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex h-9 w-72 items-center justify-start gap-2.5 rounded-md px-2.5"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-white/10">
|
||||||
|
<CommandIcon className="h-3 w-3 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-white/10">
|
||||||
|
<span className="text-sm leading-none text-white">T</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h5 className="font-medium text-white/50">Add hashtag widget</h5>
|
||||||
|
</button>
|
||||||
|
</Dialog.Trigger>
|
||||||
|
<Dialog.Portal className="relative z-10">
|
||||||
|
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xl" />
|
||||||
|
<Dialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
|
||||||
|
<div className="relative h-min w-full max-w-xl rounded-xl bg-white/10">
|
||||||
|
<div className="h-min w-full shrink-0 border-b border-white/10 bg-white/5 px-5 py-5">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Dialog.Title className="text-lg font-semibold leading-none text-white">
|
||||||
|
Create hashtag block
|
||||||
|
</Dialog.Title>
|
||||||
|
<Dialog.Close className="inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-white/10">
|
||||||
|
<CancelIcon className="h-4 w-4 text-white/50" />
|
||||||
|
</Dialog.Close>
|
||||||
|
</div>
|
||||||
|
<Dialog.Description className="text-sm leading-tight text-white/50">
|
||||||
|
Pin the hashtag you want to keep follow up
|
||||||
|
</Dialog.Description>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex h-full w-full flex-col overflow-y-auto px-5 pb-5 pt-3">
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
className="mb-0 flex h-full w-full flex-col gap-3"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label
|
||||||
|
htmlFor="title"
|
||||||
|
className="text-sm font-medium uppercase tracking-wider text-white/50"
|
||||||
|
>
|
||||||
|
Hashtag *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type={'text'}
|
||||||
|
{...register('hashtag', {
|
||||||
|
required: true,
|
||||||
|
})}
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder="#"
|
||||||
|
className="relative h-11 w-full rounded-lg bg-white/10 px-3 py-2 text-white !outline-none placeholder:text-white/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!isDirty || !isValid}
|
||||||
|
className="inline-flex h-11 w-full transform items-center justify-center rounded-lg bg-fuchsia-500 font-medium text-white active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-30"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<LoaderIcon className="h-4 w-4 animate-spin text-white" />
|
||||||
|
) : (
|
||||||
|
'Confirm'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
160
src/app/space/components/modals/image.tsx
Normal file
160
src/app/space/components/modals/image.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import * as Dialog from '@radix-ui/react-dialog';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
|
|
||||||
|
import { CancelIcon, CommandIcon, LoaderIcon } from '@shared/icons';
|
||||||
|
import { Image } from '@shared/image';
|
||||||
|
|
||||||
|
import { BLOCK_KINDS, DEFAULT_AVATAR } from '@stores/constants';
|
||||||
|
import { ADD_IMAGEBLOCK_SHORTCUT } from '@stores/shortcuts';
|
||||||
|
import { useWidgets } from '@stores/widgets';
|
||||||
|
|
||||||
|
import { useImageUploader } from '@utils/hooks/useUploader';
|
||||||
|
|
||||||
|
export function ImageModal() {
|
||||||
|
const upload = useImageUploader();
|
||||||
|
const setWidget = useWidgets((state) => state.setWidget);
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [image, setImage] = useState('');
|
||||||
|
|
||||||
|
useHotkeys(ADD_IMAGEBLOCK_SHORTCUT, () => setOpen(false));
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
setValue,
|
||||||
|
formState: { isDirty, isValid },
|
||||||
|
} = useForm();
|
||||||
|
|
||||||
|
const uploadImage = async () => {
|
||||||
|
const image = await upload(null, true);
|
||||||
|
if (image.url) {
|
||||||
|
setImage(image.url);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (data: { kind: number; title: string; content: string }) => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// mutate
|
||||||
|
setWidget({ kind: BLOCK_KINDS.image, title: data.title, content: data.content });
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
// reset form
|
||||||
|
reset();
|
||||||
|
// close modal
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setValue('content', image);
|
||||||
|
}, [setValue, image]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Root open={open} onOpenChange={setOpen}>
|
||||||
|
<Dialog.Trigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex h-9 w-72 items-center justify-start gap-2.5 rounded-md px-2.5"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-white/10">
|
||||||
|
<CommandIcon width={12} height={12} className="text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-white/10">
|
||||||
|
<span className="text-sm leading-none text-white">I</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h5 className="font-medium text-white/50">Add image widget</h5>
|
||||||
|
</button>
|
||||||
|
</Dialog.Trigger>
|
||||||
|
<Dialog.Portal className="relative z-10">
|
||||||
|
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xl" />
|
||||||
|
<Dialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
|
||||||
|
<div className="relative h-min w-full max-w-xl rounded-xl bg-white/10">
|
||||||
|
<div className="h-min w-full shrink-0 border-b border-white/10 bg-white/5 px-5 py-5">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Dialog.Title className="text-lg font-semibold leading-none text-white">
|
||||||
|
Create image block
|
||||||
|
</Dialog.Title>
|
||||||
|
<Dialog.Close className="inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-white/10">
|
||||||
|
<CancelIcon className="h-4 w-4 text-white/50" />
|
||||||
|
</Dialog.Close>
|
||||||
|
</div>
|
||||||
|
<Dialog.Description className="text-sm leading-tight text-white/50">
|
||||||
|
Pin your favorite image to Space then you can view every time that you
|
||||||
|
use Lume, your image will be broadcast to Nostr Relay as well
|
||||||
|
</Dialog.Description>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex h-full w-full flex-col overflow-y-auto px-5 pb-5 pt-3">
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
className="mb-0 flex h-full w-full flex-col gap-3"
|
||||||
|
>
|
||||||
|
<input type={'hidden'} {...register('content')} value={image} />
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label
|
||||||
|
htmlFor="title"
|
||||||
|
className="text-sm font-medium uppercase tracking-wider text-white/50"
|
||||||
|
>
|
||||||
|
Title *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type={'text'}
|
||||||
|
{...register('title', {
|
||||||
|
required: true,
|
||||||
|
})}
|
||||||
|
spellCheck={false}
|
||||||
|
className="relative h-11 w-full rounded-lg bg-white/10 px-3 py-2 text-white !outline-none placeholder:text-white/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label
|
||||||
|
htmlFor="picture"
|
||||||
|
className="text-sm font-medium uppercase tracking-wider text-white/50"
|
||||||
|
>
|
||||||
|
Picture
|
||||||
|
</label>
|
||||||
|
<div className="relative inline-flex h-56 w-full items-center justify-center overflow-hidden rounded-lg bg-white/10">
|
||||||
|
<Image
|
||||||
|
src={image}
|
||||||
|
fallback={DEFAULT_AVATAR}
|
||||||
|
alt="content"
|
||||||
|
className="relative z-10 h-auto max-h-[156px] w-[150px] rounded-md object-cover"
|
||||||
|
/>
|
||||||
|
<div className="absolute bottom-3 right-3 z-10">
|
||||||
|
<button
|
||||||
|
onClick={() => uploadImage()}
|
||||||
|
type="button"
|
||||||
|
className="inline-flex h-6 items-center justify-center rounded bg-white/10 px-3 text-sm font-medium text-white hover:bg-fuchsia-500"
|
||||||
|
>
|
||||||
|
Upload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!isDirty || !isValid}
|
||||||
|
className="inline-flex h-11 w-full transform items-center justify-center rounded-lg bg-fuchsia-500 font-medium text-white active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-30"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<LoaderIcon className="h-4 w-4 animate-spin text-white" />
|
||||||
|
) : (
|
||||||
|
'Confirm'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,8 +4,6 @@ import { useEffect, useRef } from 'react';
|
|||||||
import { useNDK } from '@libs/ndk/provider';
|
import { useNDK } from '@libs/ndk/provider';
|
||||||
import { createNote } from '@libs/storage';
|
import { createNote } from '@libs/storage';
|
||||||
|
|
||||||
import { useNote } from '@stores/note';
|
|
||||||
|
|
||||||
import { useAccount } from '@utils/hooks/useAccount';
|
import { useAccount } from '@utils/hooks/useAccount';
|
||||||
|
|
||||||
export function useNewsfeed() {
|
export function useNewsfeed() {
|
||||||
@@ -15,15 +13,11 @@ export function useNewsfeed() {
|
|||||||
const { ndk } = useNDK();
|
const { ndk } = useNDK();
|
||||||
const { status, account } = useAccount();
|
const { status, account } = useAccount();
|
||||||
|
|
||||||
const toggleHasNewNote = useNote((state) => state.toggleHasNewNote);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status === 'success' && account) {
|
if (status === 'success' && account) {
|
||||||
const follows = account ? JSON.parse(account.follows as string) : [];
|
|
||||||
|
|
||||||
const filter: NDKFilter = {
|
const filter: NDKFilter = {
|
||||||
kinds: [1, 6],
|
kinds: [1, 6],
|
||||||
authors: follows,
|
authors: account.follows,
|
||||||
since: now.current,
|
since: now.current,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -39,8 +33,6 @@ export function useNewsfeed() {
|
|||||||
event.content,
|
event.content,
|
||||||
event.created_at
|
event.created_at
|
||||||
);
|
);
|
||||||
// notify user about created note
|
|
||||||
toggleHasNewNote(true);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,93 +1,71 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useCallback, useEffect } from 'react';
|
||||||
import { useCallback } from 'react';
|
|
||||||
|
|
||||||
import { AddBlock } from '@app/space/components/add';
|
|
||||||
import { FeedBlock } from '@app/space/components/blocks/feed';
|
import { FeedBlock } from '@app/space/components/blocks/feed';
|
||||||
import { FollowingBlock } from '@app/space/components/blocks/following';
|
|
||||||
import { HashtagBlock } from '@app/space/components/blocks/hashtag';
|
import { HashtagBlock } from '@app/space/components/blocks/hashtag';
|
||||||
import { ImageBlock } from '@app/space/components/blocks/image';
|
import { ImageBlock } from '@app/space/components/blocks/image';
|
||||||
|
import { NetworkBlock } from '@app/space/components/blocks/network';
|
||||||
import { ThreadBlock } from '@app/space/components/blocks/thread';
|
import { ThreadBlock } from '@app/space/components/blocks/thread';
|
||||||
import { UserBlock } from '@app/space/components/blocks/user';
|
import { UserBlock } from '@app/space/components/blocks/user';
|
||||||
|
import { FeedModal } from '@app/space/components/modals/feed';
|
||||||
import { getBlocks } from '@libs/storage';
|
import { HashtagModal } from '@app/space/components/modals/hashtag';
|
||||||
|
import { ImageModal } from '@app/space/components/modals/image';
|
||||||
|
|
||||||
import { LoaderIcon } from '@shared/icons';
|
import { LoaderIcon } from '@shared/icons';
|
||||||
|
|
||||||
import { Block } from '@utils/types';
|
import { useWidgets } from '@stores/widgets';
|
||||||
|
|
||||||
|
import { Widget } from '@utils/types';
|
||||||
|
|
||||||
export function SpaceScreen() {
|
export function SpaceScreen() {
|
||||||
const {
|
const [widgets, fetchWidgets] = useWidgets((state) => [
|
||||||
status,
|
state.widgets,
|
||||||
data: blocks,
|
state.fetchWidgets,
|
||||||
isFetching,
|
]);
|
||||||
} = useQuery(
|
|
||||||
['blocks'],
|
|
||||||
async () => {
|
|
||||||
return await getBlocks();
|
|
||||||
},
|
|
||||||
{
|
|
||||||
staleTime: Infinity,
|
|
||||||
refetchOnMount: false,
|
|
||||||
refetchOnReconnect: false,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderBlock = useCallback(
|
const renderItem = useCallback(
|
||||||
(block: Block) => {
|
(widget: Widget) => {
|
||||||
switch (block.kind) {
|
switch (widget.kind) {
|
||||||
case 0:
|
case 0:
|
||||||
return <ImageBlock key={block.id} params={block} />;
|
return <ImageBlock key={widget.id} params={widget} />;
|
||||||
case 1:
|
case 1:
|
||||||
return <FeedBlock key={block.id} params={block} />;
|
return <FeedBlock key={widget.id} params={widget} />;
|
||||||
case 2:
|
case 2:
|
||||||
return <ThreadBlock key={block.id} params={block} />;
|
return <ThreadBlock key={widget.id} params={widget} />;
|
||||||
case 3:
|
case 3:
|
||||||
return <HashtagBlock key={block.id} params={block} />;
|
return <HashtagBlock key={widget.id} params={widget} />;
|
||||||
case 5:
|
case 5:
|
||||||
return <UserBlock key={block.id} params={block} />;
|
return <UserBlock key={widget.id} params={widget} />;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[blocks]
|
[widgets]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
useEffect(() => {
|
||||||
<div className="scrollbar-hide flex h-full w-full flex-nowrap overflow-x-auto overflow-y-hidden">
|
fetchWidgets();
|
||||||
<FollowingBlock />
|
}, [fetchWidgets]);
|
||||||
{status === 'loading' ? (
|
|
||||||
<div className="flex w-[350px] shrink-0 flex-col border-r border-zinc-900">
|
|
||||||
<div
|
|
||||||
data-tauri-drag-region
|
|
||||||
className="group flex h-11 w-full items-center justify-between overflow-hidden border-b border-zinc-900 px-3"
|
|
||||||
/>
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="scrollbar-hide flex h-full w-full flex-nowrap divide-x divide-white/5 overflow-x-auto overflow-y-hidden">
|
||||||
|
<NetworkBlock />
|
||||||
|
{!widgets ? (
|
||||||
|
<div className="flex w-[350px] shrink-0 flex-col">
|
||||||
<div className="flex w-full flex-1 items-center justify-center p-3">
|
<div className="flex w-full flex-1 items-center justify-center p-3">
|
||||||
<LoaderIcon className="h-5 w-5 animate-spin text-black dark:text-zinc-100" />
|
<LoaderIcon className="h-5 w-5 animate-spin text-white/10" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
blocks.map((block: Block) => renderBlock(block))
|
widgets.map((widget) => renderItem(widget))
|
||||||
)}
|
)}
|
||||||
{isFetching && (
|
<div className="flex w-[350px] shrink-0 flex-col">
|
||||||
<div className="flex w-[350px] shrink-0 flex-col border-r border-zinc-900">
|
<div className="inline-flex h-full w-full flex-col items-center justify-center gap-1">
|
||||||
<div
|
<FeedModal />
|
||||||
data-tauri-drag-region
|
<ImageModal />
|
||||||
className="group flex h-11 w-full items-center justify-between overflow-hidden border-b border-zinc-900 px-3"
|
<HashtagModal />
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex w-full flex-1 items-center justify-center p-3">
|
|
||||||
<LoaderIcon className="h-5 w-5 animate-spin text-black dark:text-zinc-100" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex w-[350px] shrink-0 flex-col border-r border-zinc-900">
|
|
||||||
<div className="inline-flex h-full w-full items-center justify-center">
|
|
||||||
<AddBlock />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-[350px] shrink-0" />
|
<div className="w-[250px] shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
91
src/app/splash.tsx
Normal file
91
src/app/splash.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { invoke } from '@tauri-apps/api/tauri';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { useNDK } from '@libs/ndk/provider';
|
||||||
|
import { updateLastLogin } from '@libs/storage';
|
||||||
|
|
||||||
|
import { LoaderIcon } from '@shared/icons';
|
||||||
|
|
||||||
|
import { useAccount } from '@utils/hooks/useAccount';
|
||||||
|
import { useNostr } from '@utils/hooks/useNostr';
|
||||||
|
|
||||||
|
export function SplashScreen() {
|
||||||
|
const { ndk, relayUrls } = useNDK();
|
||||||
|
const { status, account } = useAccount();
|
||||||
|
const { fetchChats, fetchNotes } = useNostr();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
|
const [error, setError] = useState<null | string>(null);
|
||||||
|
|
||||||
|
const skip = async () => {
|
||||||
|
await invoke('close_splashscreen');
|
||||||
|
};
|
||||||
|
|
||||||
|
const prefetch = async () => {
|
||||||
|
const onboarding = localStorage.getItem('onboarding');
|
||||||
|
const step = JSON.parse(onboarding).state.step || null;
|
||||||
|
if (step) await invoke('close_splashscreen');
|
||||||
|
|
||||||
|
const notes = await fetchNotes();
|
||||||
|
const chats = await fetchChats();
|
||||||
|
|
||||||
|
if (notes.status === 'ok' && chats.status === 'ok') {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
await updateLastLogin(now);
|
||||||
|
invoke('close_splashscreen');
|
||||||
|
} else {
|
||||||
|
setLoading(false);
|
||||||
|
setError(notes.message || chats.message);
|
||||||
|
console.log('fetch notes failed, error: ', notes.message);
|
||||||
|
console.log('fetch chats failed, error: ', chats.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === 'success' && !account) {
|
||||||
|
invoke('close_splashscreen');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ndk && account) {
|
||||||
|
console.log('prefetching...');
|
||||||
|
prefetch();
|
||||||
|
}
|
||||||
|
}, [ndk, account]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex h-screen w-screen items-center justify-center bg-black">
|
||||||
|
<div data-tauri-drag-region className="absolute left-0 top-0 z-10 h-11 w-full" />
|
||||||
|
<div className="flex min-h-0 w-full flex-1 items-center justify-center">
|
||||||
|
<div className="flex flex-col items-center justify-center gap-4">
|
||||||
|
<LoaderIcon className="h-6 w-6 animate-spin text-white" />
|
||||||
|
{loading ? (
|
||||||
|
<div className="mt-2 flex flex-col gap-1 text-center">
|
||||||
|
<h3 className="text-lg font-semibold leading-none text-white">
|
||||||
|
{!ndk
|
||||||
|
? 'Connecting to relay...'
|
||||||
|
: `Connected to ${relayUrls.length} relays`}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-white/50">
|
||||||
|
This may take a few seconds, please don't close app.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-2 flex flex-col gap-1 text-center">
|
||||||
|
<h3 className="text-lg font-semibold leading-none text-white">
|
||||||
|
Something wrong!
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-white/50">{error}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={skip}
|
||||||
|
className="mx-auto mt-4 inline-flex h-10 w-max items-center justify-center rounded-md bg-white/10 px-8 text-sm font-medium leading-none text-white backdrop-blur-xl hover:bg-white/20"
|
||||||
|
>
|
||||||
|
Skip
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { fetch } from '@tauri-apps/plugin-http';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { FollowIcon, LoaderIcon, UnfollowIcon } from '@shared/icons';
|
import { FollowIcon, LoaderIcon, UnfollowIcon } from '@shared/icons';
|
||||||
@@ -11,14 +12,23 @@ import { compactNumber } from '@utils/number';
|
|||||||
import { shortenKey } from '@utils/shortenKey';
|
import { shortenKey } from '@utils/shortenKey';
|
||||||
|
|
||||||
export function Profile({ data }: { data: any }) {
|
export function Profile({ data }: { data: any }) {
|
||||||
const { status, data: userStats } = useQuery(['user-stats', data.pubkey], async () => {
|
const { status: socialStatus, userFollows, follow, unfollow } = useSocial();
|
||||||
const res = await fetch(`https://api.nostr.band/v0/stats/profile/${data.pubkey}`);
|
const { status, data: userStats } = useQuery(
|
||||||
return res.json();
|
['user-stats', data.pubkey],
|
||||||
});
|
async () => {
|
||||||
|
const res = await fetch(`https://api.nostr.band/v0/stats/profile/${data.pubkey}`);
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
{
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
staleTime: Infinity,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const embedProfile = data.profile ? JSON.parse(data.profile.content) : null;
|
const embedProfile = data.profile ? JSON.parse(data.profile.content) : null;
|
||||||
const profile = embedProfile;
|
const profile = embedProfile;
|
||||||
const { status: socialStatus, userFollows, follow, unfollow } = useSocial();
|
|
||||||
|
|
||||||
const [followed, setFollowed] = useState(false);
|
const [followed, setFollowed] = useState(false);
|
||||||
|
|
||||||
@@ -50,29 +60,28 @@ export function Profile({ data }: { data: any }) {
|
|||||||
}
|
}
|
||||||
}, [status]);
|
}, [status]);
|
||||||
|
|
||||||
if (!profile)
|
if (!profile) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md bg-zinc-900 px-5 py-5">
|
<div className="rounded-xl bg-white/10 px-5 py-5">
|
||||||
<p>Can't fetch profile</p>
|
<p>Can't fetch profile</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-5 py-5">
|
<div className="rounded-xl bg-white/10 px-5 py-5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="inline-flex items-center gap-2">
|
<div className="inline-flex items-center gap-2">
|
||||||
<div className="h-11 w-11 shrink-0">
|
<Image
|
||||||
<Image
|
src={profile.picture}
|
||||||
src={profile.picture}
|
fallback={DEFAULT_AVATAR}
|
||||||
fallback={DEFAULT_AVATAR}
|
className="h-11 w-11 shrink-0 rounded-lg object-cover"
|
||||||
className="h-11 w-11 rounded-lg object-cover"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="inline-flex flex-col gap-1">
|
<div className="inline-flex flex-col gap-1">
|
||||||
<h3 className="max-w-[15rem] truncate font-semibold leading-none text-zinc-100">
|
<h3 className="max-w-[15rem] truncate font-semibold leading-none text-white">
|
||||||
{profile.display_name || profile.name}
|
{profile.display_name || profile.name}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="max-w-[10rem] truncate text-sm leading-none text-zinc-400">
|
<p className="max-w-[10rem] truncate text-sm leading-none text-white/50">
|
||||||
{profile.nip05 || shortenKey(data.pubkey)}
|
{profile.nip05 || shortenKey(data.pubkey)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -81,15 +90,15 @@ export function Profile({ data }: { data: any }) {
|
|||||||
{socialStatus === 'loading' ? (
|
{socialStatus === 'loading' ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-zinc-900 hover:bg-fuchsia-500"
|
className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-white/10 hover:bg-fuchsia-500"
|
||||||
>
|
>
|
||||||
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
|
<LoaderIcon className="h-4 w-4 animate-spin text-white" />
|
||||||
</button>
|
</button>
|
||||||
) : followed ? (
|
) : followed ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => unfollowUser(data.pubkey)}
|
onClick={() => unfollowUser(data.pubkey)}
|
||||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-zinc-800 text-zinc-400 hover:bg-fuchsia-500 hover:text-white"
|
className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-white/10 text-white hover:bg-fuchsia-500 hover:text-white"
|
||||||
>
|
>
|
||||||
<UnfollowIcon className="h-4 w-4" />
|
<UnfollowIcon className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -97,7 +106,7 @@ export function Profile({ data }: { data: any }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => followUser(data.pubkey)}
|
onClick={() => followUser(data.pubkey)}
|
||||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-zinc-800 text-zinc-400 hover:bg-fuchsia-500 hover:text-white"
|
className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-white/10 text-white hover:bg-fuchsia-500 hover:text-white"
|
||||||
>
|
>
|
||||||
<FollowIcon className="h-4 w-4" />
|
<FollowIcon className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -105,7 +114,7 @@ export function Profile({ data }: { data: any }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<p className="whitespace-pre-line break-words text-zinc-100">
|
<p className="whitespace-pre-line break-words text-white">
|
||||||
{profile.about || profile.bio}
|
{profile.about || profile.bio}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,26 +124,26 @@ export function Profile({ data }: { data: any }) {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex w-full items-center gap-8">
|
<div className="flex w-full items-center gap-8">
|
||||||
<div className="inline-flex flex-col gap-1">
|
<div className="inline-flex flex-col gap-1">
|
||||||
<span className="font-semibold leading-none text-zinc-100">
|
<span className="font-semibold leading-none text-white">
|
||||||
{userStats.stats[data.pubkey].followers_pubkey_count ?? 0}
|
{userStats.stats[data.pubkey].followers_pubkey_count ?? 0}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm leading-none text-zinc-400">Followers</span>
|
<span className="text-sm leading-none text-white/50">Followers</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="inline-flex flex-col gap-1">
|
<div className="inline-flex flex-col gap-1">
|
||||||
<span className="font-semibold leading-none text-zinc-100">
|
<span className="font-semibold leading-none text-white">
|
||||||
{userStats.stats[data.pubkey].pub_following_pubkey_count ?? 0}
|
{userStats.stats[data.pubkey].pub_following_pubkey_count ?? 0}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm leading-none text-zinc-400">Following</span>
|
<span className="text-sm leading-none text-white/50">Following</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="inline-flex flex-col gap-1">
|
<div className="inline-flex flex-col gap-1">
|
||||||
<span className="font-semibold leading-none text-zinc-100">
|
<span className="font-semibold leading-none text-white">
|
||||||
{userStats.stats[data.pubkey].zaps_received
|
{userStats.stats[data.pubkey].zaps_received
|
||||||
? compactNumber.format(
|
? compactNumber.format(
|
||||||
userStats.stats[data.pubkey].zaps_received.msats / 1000
|
userStats.stats[data.pubkey].zaps_received.msats / 1000
|
||||||
)
|
)
|
||||||
: 0}
|
: 0}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm leading-none text-zinc-400">Zaps received</span>
|
<span className="text-sm leading-none text-white/50">Zaps received</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,33 +1,52 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { fetch } from '@tauri-apps/plugin-http';
|
||||||
|
|
||||||
import { NoteKind_1 } from '@shared/notes';
|
import { NoteKind_1 } from '@shared/notes';
|
||||||
import { NoteSkeleton } from '@shared/notes/skeleton';
|
import { NoteSkeleton } from '@shared/notes/skeleton';
|
||||||
import { TitleBar } from '@shared/titleBar';
|
import { TitleBar } from '@shared/titleBar';
|
||||||
|
|
||||||
|
import { LumeEvent } from '@utils/types';
|
||||||
|
|
||||||
|
interface Response {
|
||||||
|
notes: Array<{ event: LumeEvent }>;
|
||||||
|
}
|
||||||
|
|
||||||
export function TrendingNotes() {
|
export function TrendingNotes() {
|
||||||
const { status, data, error } = useQuery(['trending-notes'], async () => {
|
const { status, data, error } = useQuery(
|
||||||
const res = await fetch('https://api.nostr.band/v0/trending/notes');
|
['trending-notes'],
|
||||||
if (!res.ok) {
|
async () => {
|
||||||
throw new Error('Error');
|
const res = await fetch('https://api.nostr.band/v0/trending/notes');
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('Error');
|
||||||
|
}
|
||||||
|
const json: Response = await res.json();
|
||||||
|
return json.notes;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
staleTime: Infinity,
|
||||||
}
|
}
|
||||||
return res.json();
|
);
|
||||||
});
|
|
||||||
|
console.log('notes: ', data);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-[360px] shrink-0 flex-col border-r border-zinc-900">
|
<div className="scrollbar-hide relative h-full w-[400px] shrink-0 overflow-y-auto bg-white/10 pb-20">
|
||||||
<TitleBar title="Trending Posts" />
|
<TitleBar title="Trending Posts" />
|
||||||
<div className="scrollbar-hide flex h-full w-full flex-col justify-between gap-1.5 overflow-y-auto pb-20 pt-1.5">
|
<div className="h-full">
|
||||||
{error && <p>Failed to fetch</p>}
|
{error && <p>Failed to fetch</p>}
|
||||||
{status === 'loading' ? (
|
{status === 'loading' ? (
|
||||||
<div className="px-3 py-1.5">
|
<div className="px-3 py-1.5">
|
||||||
<div className="shadow-input rounded-md bg-zinc-900 px-3 py-3 shadow-black/20">
|
<div className="rounded-xl bg-white/10 px-3 py-3">
|
||||||
<NoteSkeleton />
|
<NoteSkeleton />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="relative flex w-full flex-col pt-1.5">
|
<div className="relative flex w-full flex-col">
|
||||||
{data.notes.map((item) => (
|
{data.map((item) => (
|
||||||
<NoteKind_1 key={item.id} event={item.event} skipMetadata={true} />
|
<NoteKind_1 key={item.event.id} event={item.event} skipMetadata={true} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,33 +1,50 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { fetch } from '@tauri-apps/plugin-http';
|
||||||
|
|
||||||
import { Profile } from '@app/trending/components/profile';
|
import { Profile } from '@app/trending/components/profile';
|
||||||
|
|
||||||
import { NoteSkeleton } from '@shared/notes/skeleton';
|
import { NoteSkeleton } from '@shared/notes/skeleton';
|
||||||
import { TitleBar } from '@shared/titleBar';
|
import { TitleBar } from '@shared/titleBar';
|
||||||
|
|
||||||
|
interface Response {
|
||||||
|
profiles: Array<{ pubkey: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
export function TrendingProfiles() {
|
export function TrendingProfiles() {
|
||||||
const { status, data, error } = useQuery(['trending-profiles'], async () => {
|
const { status, data, error } = useQuery(
|
||||||
const res = await fetch('https://api.nostr.band/v0/trending/profiles');
|
['trending-profiles'],
|
||||||
if (!res.ok) {
|
async () => {
|
||||||
throw new Error('Error');
|
const res = await fetch('https://api.nostr.band/v0/trending/profiles');
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('Error');
|
||||||
|
}
|
||||||
|
const json: Response = await res.json();
|
||||||
|
return json.profiles;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
staleTime: Infinity,
|
||||||
}
|
}
|
||||||
return res.json();
|
);
|
||||||
});
|
|
||||||
|
console.log('profiles: ', data);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-[360px] shrink-0 flex-col border-r border-zinc-900">
|
<div className="scrollbar-hide relative h-full w-[400px] shrink-0 overflow-y-auto bg-white/10 pb-20">
|
||||||
<TitleBar title="Trending Profiles" />
|
<TitleBar title="Trending Profiles" />
|
||||||
<div className="scrollbar-hide flex h-full w-full flex-col justify-between gap-1.5 overflow-y-auto pb-20 pt-1.5">
|
<div className="h-full">
|
||||||
{error && <p>Failed to fetch</p>}
|
{error && <p>Failed to fetch</p>}
|
||||||
{status === 'loading' ? (
|
{status === 'loading' ? (
|
||||||
<div className="px-3 py-1.5">
|
<div className="px-3 py-1.5">
|
||||||
<div className="shadow-input rounded-md bg-zinc-900 px-3 py-3 shadow-black/20">
|
<div className="rounded-xl bg-white/10 px-3 py-3">
|
||||||
<NoteSkeleton />
|
<NoteSkeleton />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="relative flex w-full flex-col gap-3 px-3 pt-3">
|
<div className="relative flex w-full flex-col gap-3 px-3 pt-1.5">
|
||||||
{data.profiles.map((item) => (
|
{data?.map((item) => (
|
||||||
<Profile key={item.pubkey} data={item} />
|
<Profile key={item.pubkey} data={item} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { TrendingProfiles } from '@app/trending/components/trendingProfiles';
|
|||||||
|
|
||||||
export function TrendingScreen() {
|
export function TrendingScreen() {
|
||||||
return (
|
return (
|
||||||
<div className="scrollbar-hide flex h-full w-full flex-nowrap overflow-x-auto overflow-y-hidden">
|
<div className="scrollbar-hide flex h-full w-full flex-nowrap divide-x divide-white/5 overflow-x-auto overflow-y-hidden">
|
||||||
<TrendingProfiles />
|
<TrendingProfiles />
|
||||||
<TrendingNotes />
|
<TrendingNotes />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,15 +12,14 @@ import { LumeEvent } from '@utils/types';
|
|||||||
export function UserFeed({ pubkey }: { pubkey: string }) {
|
export function UserFeed({ pubkey }: { pubkey: string }) {
|
||||||
const parentRef = useRef();
|
const parentRef = useRef();
|
||||||
|
|
||||||
const { fetcher, relayUrls } = useNDK();
|
const { ndk } = useNDK();
|
||||||
const { status, data } = useQuery(['user-feed', pubkey], async () => {
|
const { status, data } = useQuery(['user-feed', pubkey], async () => {
|
||||||
const events = await fetcher.fetchAllEvents(
|
const events = await ndk.fetchEvents({
|
||||||
relayUrls,
|
kinds: [1],
|
||||||
{ kinds: [1], authors: [pubkey] },
|
authors: [pubkey],
|
||||||
{ since: nHoursAgo(48) },
|
since: nHoursAgo(48),
|
||||||
{ sort: true }
|
});
|
||||||
);
|
return [...events] as unknown as LumeEvent[];
|
||||||
return events as unknown as LumeEvent[];
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const rowVirtualizer = useVirtualizer({
|
const rowVirtualizer = useVirtualizer({
|
||||||
|
|||||||
@@ -18,32 +18,32 @@ export function UserMetadata({ pubkey }: { pubkey: string }) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex w-full items-center gap-10">
|
<div className="flex w-full items-center gap-10">
|
||||||
<div className="inline-flex flex-col gap-1">
|
<div className="inline-flex flex-col gap-1">
|
||||||
<span className="font-semibold leading-none text-zinc-100">
|
<span className="font-semibold leading-none text-white">
|
||||||
{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-zinc-400">Followers</span>
|
<span className="text-sm leading-none text-white/50">Followers</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="inline-flex flex-col gap-1">
|
<div className="inline-flex flex-col gap-1">
|
||||||
<span className="font-semibold leading-none text-zinc-100">
|
<span className="font-semibold leading-none text-white">
|
||||||
{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-zinc-400">Following</span>
|
<span className="text-sm leading-none text-white/50">Following</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="inline-flex flex-col gap-1">
|
<div className="inline-flex flex-col gap-1">
|
||||||
<span className="font-semibold leading-none text-zinc-100">
|
<span className="font-semibold leading-none text-white">
|
||||||
{data.stats[pubkey].zaps_received
|
{data.stats[pubkey].zaps_received
|
||||||
? compactNumber.format(data.stats[pubkey].zaps_received.msats / 1000)
|
? compactNumber.format(data.stats[pubkey].zaps_received.msats / 1000)
|
||||||
: 0}
|
: 0}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm leading-none text-zinc-400">Zaps received</span>
|
<span className="text-sm leading-none text-white/50">Zaps received</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="inline-flex flex-col gap-1">
|
<div className="inline-flex flex-col gap-1">
|
||||||
<span className="font-semibold leading-none text-zinc-100">
|
<span className="font-semibold leading-none text-white">
|
||||||
{data.stats[pubkey].zaps_sent
|
{data.stats[pubkey].zaps_sent
|
||||||
? compactNumber.format(data.stats[pubkey].zaps_sent.msats / 1000)
|
? compactNumber.format(data.stats[pubkey].zaps_sent.msats / 1000)
|
||||||
: 0}
|
: 0}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm leading-none text-zinc-400">Zaps sent</span>
|
<span className="text-sm leading-none text-white/50">Zaps sent</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="h-56 w-full bg-zinc-100">
|
<div className="h-56 w-full bg-white">
|
||||||
<Image
|
<Image
|
||||||
src={user?.banner}
|
src={user?.banner}
|
||||||
fallback="https://void.cat/d/QY1myro5tkHVs2nY7dy74b.jpg"
|
fallback="https://void.cat/d/QY1myro5tkHVs2nY7dy74b.jpg"
|
||||||
@@ -65,7 +65,7 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
|
|||||||
src={user?.picture || user?.image}
|
src={user?.picture || user?.image}
|
||||||
fallback={DEFAULT_AVATAR}
|
fallback={DEFAULT_AVATAR}
|
||||||
alt={pubkey}
|
alt={pubkey}
|
||||||
className="h-14 w-14 rounded-md ring-2 ring-black"
|
className="h-14 w-14 rounded-md ring-2 ring-white/50"
|
||||||
/>
|
/>
|
||||||
<div className="mt-2 flex flex-1 flex-col gap-4">
|
<div className="mt-2 flex flex-1 flex-col gap-4">
|
||||||
<div className="flex items-center gap-16">
|
<div className="flex items-center gap-16">
|
||||||
@@ -73,7 +73,7 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
|
|||||||
<h5 className="text-lg font-semibold leading-none">
|
<h5 className="text-lg font-semibold leading-none">
|
||||||
{user?.displayName || user?.name || 'No name'}
|
{user?.displayName || user?.name || 'No name'}
|
||||||
</h5>
|
</h5>
|
||||||
<span className="max-w-[15rem] truncate text-sm leading-none text-zinc-500">
|
<span className="max-w-[15rem] truncate text-sm leading-none text-white/50">
|
||||||
{user?.nip05 || shortenKey(pubkey)}
|
{user?.nip05 || shortenKey(pubkey)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -81,7 +81,7 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
|
|||||||
{status === 'loading' ? (
|
{status === 'loading' ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-zinc-900 text-sm font-medium hover:bg-fuchsia-500"
|
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-white/10 text-sm font-medium hover:bg-fuchsia-500"
|
||||||
>
|
>
|
||||||
Loading...
|
Loading...
|
||||||
</button>
|
</button>
|
||||||
@@ -89,7 +89,7 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => unfollowUser(pubkey)}
|
onClick={() => unfollowUser(pubkey)}
|
||||||
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-zinc-900 text-sm font-medium hover:bg-fuchsia-500"
|
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-white/10 text-sm font-medium hover:bg-fuchsia-500"
|
||||||
>
|
>
|
||||||
Unfollow
|
Unfollow
|
||||||
</button>
|
</button>
|
||||||
@@ -97,23 +97,23 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => followUser(pubkey)}
|
onClick={() => followUser(pubkey)}
|
||||||
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-zinc-900 text-sm font-medium hover:bg-fuchsia-500"
|
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-white/10 text-sm font-medium hover:bg-fuchsia-500"
|
||||||
>
|
>
|
||||||
Follow
|
Follow
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<Link
|
<Link
|
||||||
to={`/app/chats/${pubkey}`}
|
to={`/chats/${pubkey}`}
|
||||||
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-zinc-900 text-sm font-medium hover:bg-fuchsia-500"
|
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-white/10 text-sm font-medium hover:bg-fuchsia-500"
|
||||||
>
|
>
|
||||||
Message
|
Message
|
||||||
</Link>
|
</Link>
|
||||||
<span className="mx-2 inline-flex h-4 w-px bg-zinc-900" />
|
<span className="mx-2 inline-flex h-4 w-px bg-white/10" />
|
||||||
{account && account.pubkey === pubkey && <EditProfileModal />}
|
{account && account.pubkey === pubkey && <EditProfileModal />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-8">
|
<div className="flex flex-col gap-8">
|
||||||
<p className="mt-2 max-w-[500px] select-text break-words text-zinc-100">
|
<p className="mt-2 max-w-[500px] select-text break-words text-white">
|
||||||
{user?.about || user?.bio}
|
{user?.about || user?.bio}
|
||||||
</p>
|
</p>
|
||||||
<UserMetadata pubkey={pubkey} />
|
<UserMetadata pubkey={pubkey} />
|
||||||
|
|||||||
@@ -16,15 +16,14 @@ export function UserScreen() {
|
|||||||
const parentRef = useRef();
|
const parentRef = useRef();
|
||||||
|
|
||||||
const { pubkey } = useParams();
|
const { pubkey } = useParams();
|
||||||
const { fetcher, relayUrls } = useNDK();
|
const { ndk } = useNDK();
|
||||||
const { status, data } = useQuery(['user-feed', pubkey], async () => {
|
const { status, data } = useQuery(['user-feed', pubkey], async () => {
|
||||||
const events = await fetcher.fetchAllEvents(
|
const events = await ndk.fetchEvents({
|
||||||
relayUrls,
|
kinds: [1],
|
||||||
{ kinds: [1], authors: [pubkey] },
|
authors: [pubkey],
|
||||||
{ since: nHoursAgo(48) },
|
since: nHoursAgo(48),
|
||||||
{ sort: true }
|
});
|
||||||
);
|
return [...events] as unknown as LumeEvent[];
|
||||||
return events as unknown as LumeEvent[];
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const rowVirtualizer = useVirtualizer({
|
const rowVirtualizer = useVirtualizer({
|
||||||
@@ -36,30 +35,30 @@ export function UserScreen() {
|
|||||||
const itemsVirtualizer = rowVirtualizer.getVirtualItems();
|
const itemsVirtualizer = rowVirtualizer.getVirtualItems();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={parentRef} className="scrollbar-hide h-full w-full overflow-y-auto">
|
<div
|
||||||
<div
|
ref={parentRef}
|
||||||
data-tauri-drag-region
|
className="scrollbar-hide relative h-full w-full overflow-y-auto bg-white/10"
|
||||||
className="flex h-11 w-full items-center border-b border-zinc-900 px-3"
|
>
|
||||||
/>
|
<div data-tauri-drag-region className="absolute left-0 top-0 h-11 w-full" />
|
||||||
<UserProfile pubkey={pubkey} />
|
<UserProfile pubkey={pubkey} />
|
||||||
<div className="mt-8 h-full w-full border-t border-zinc-900">
|
<div className="mt-8 h-full w-full border-t border-white/5 px-1.5">
|
||||||
<div className="flex flex-col justify-start gap-1 px-3 pt-4 text-start">
|
<div className="flex flex-col justify-start gap-1 px-3 pt-4 text-start">
|
||||||
<p className="text-lg font-semibold leading-none text-zinc-200">Latest posts</p>
|
<p className="text-lg font-semibold leading-none text-white">Latest posts</p>
|
||||||
<span className="text-sm leading-none text-zinc-500">48 hours ago</span>
|
<span className="text-sm leading-none text-white/50">48 hours ago</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex h-full max-w-[400px] flex-col justify-between gap-1.5 pb-4 pt-1.5">
|
<div className="flex h-full max-w-[400px] flex-col justify-between gap-1.5 pb-4 pt-1.5">
|
||||||
{status === 'loading' ? (
|
{status === 'loading' ? (
|
||||||
<div className="px-3 py-1.5">
|
<div className="px-3 py-1.5">
|
||||||
<div className="shadow-input rounded-md bg-zinc-900 px-3 py-3 shadow-black/20">
|
<div className="shadow-input rounded-xl bg-white/10">
|
||||||
<NoteSkeleton />
|
<NoteSkeleton />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : itemsVirtualizer.length === 0 ? (
|
) : itemsVirtualizer.length === 0 ? (
|
||||||
<div className="px-3 py-1.5">
|
<div className="px-3 py-1.5">
|
||||||
<div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 py-6">
|
<div className="rounded-xl bg-white/10 px-3 py-6">
|
||||||
<div className="flex flex-col items-center gap-4">
|
<div className="flex flex-col items-center gap-4">
|
||||||
<p className="text-center text-sm text-zinc-300">
|
<p className="text-center text-sm font-medium text-zinc-300">
|
||||||
No new posts about this hashtag in 48 hours ago
|
No new posts in 48 hours ago
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,11 +15,11 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.markdown {
|
.markdown {
|
||||||
@apply prose prose-zinc max-w-none select-text hyphens-auto dark:prose-invert prose-p:mb-2 prose-p:mt-0 prose-p:break-words prose-p:[word-break:break-word] prose-p:last:mb-0 prose-a:break-words prose-a:break-all prose-a:font-normal prose-a:leading-tight prose-a:text-fuchsia-400 prose-a:after:content-['_↗'] hover:prose-a:text-fuchsia-500 prose-blockquote:m-0 prose-pre:whitespace-pre-wrap prose-pre:break-words prose-pre:break-all prose-ol:m-0 prose-ol:mb-1 prose-ul:mb-1 prose-li:leading-tight prose-img:mb-2 prose-img:mt-3 prose-hr:mx-0 prose-hr:my-2;
|
@apply prose prose-white max-w-none select-text hyphens-auto text-white prose-p:mb-2 prose-p:mt-0 prose-p:break-words prose-p:[word-break:break-word] prose-p:last:mb-0 prose-a:break-words prose-a:break-all prose-a:font-normal prose-a:leading-tight prose-a:after:content-['_↗'] hover:prose-a:text-fuchsia-500 prose-blockquote:m-0 prose-pre:whitespace-pre-wrap prose-pre:break-words prose-pre:break-all prose-ol:m-0 prose-ol:mb-1 prose-ul:mb-1 prose-li:leading-tight prose-img:mb-2 prose-img:mt-3 prose-hr:mx-0 prose-hr:my-2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror p.is-empty::before {
|
.ProseMirror p.is-empty::before {
|
||||||
@apply text-zinc-500;
|
@apply text-white/50;
|
||||||
content: attr(data-placeholder);
|
content: attr(data-placeholder);
|
||||||
float: left;
|
float: left;
|
||||||
height: 0;
|
height: 0;
|
||||||
|
|||||||
57
src/libs/ndk/cache.tsx
Normal file
57
src/libs/ndk/cache.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { NDKCacheAdapter } from '@nostr-dev-kit/ndk';
|
||||||
|
import { NDKEvent, NDKSubscription } from '@nostr-dev-kit/ndk';
|
||||||
|
import { Store } from '@tauri-apps/plugin-store';
|
||||||
|
|
||||||
|
export default class TauriAdapter implements NDKCacheAdapter {
|
||||||
|
public store: Store;
|
||||||
|
readonly locking: boolean;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.store = new Store('.ndkcache.dat');
|
||||||
|
this.locking = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async query(subscription: NDKSubscription): Promise<void> {
|
||||||
|
const { filter } = subscription;
|
||||||
|
|
||||||
|
if (filter.authors && filter.kinds) {
|
||||||
|
const promises = [];
|
||||||
|
|
||||||
|
for (const author of filter.authors) {
|
||||||
|
for (const kind of filter.kinds) {
|
||||||
|
const key = `${author}:${kind}`;
|
||||||
|
promises.push(this.store.get(key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await Promise.all(promises);
|
||||||
|
|
||||||
|
for (const result of results) {
|
||||||
|
if (result) {
|
||||||
|
const event = await this.store.get(result as string);
|
||||||
|
|
||||||
|
if (event) {
|
||||||
|
const ndkEvent = new NDKEvent(subscription.ndk, JSON.parse(event as string));
|
||||||
|
subscription.eventReceived(ndkEvent, undefined, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setEvent(event: NDKEvent): Promise<void> {
|
||||||
|
const nostrEvent = await event.toNostrEvent();
|
||||||
|
const key = `${nostrEvent.pubkey}:${nostrEvent.kind}`;
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
Promise.all([
|
||||||
|
this.store.set(event.id, JSON.stringify(nostrEvent)),
|
||||||
|
this.store.set(key, event.id),
|
||||||
|
]).then(() => resolve());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public save() {
|
||||||
|
return this.store.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,41 +1,84 @@
|
|||||||
// source: https://github.com/nostr-dev-kit/ndk-react/
|
// inspire by: https://github.com/nostr-dev-kit/ndk-react/
|
||||||
import NDK from '@nostr-dev-kit/ndk';
|
import NDK from '@nostr-dev-kit/ndk';
|
||||||
import { ndkAdapter } from '@nostr-fetch/adapter-ndk';
|
import { fetch } from '@tauri-apps/plugin-http';
|
||||||
import { NostrFetcher, normalizeRelayUrlSet } from 'nostr-fetch';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { getSetting } from '@libs/storage';
|
import TauriAdapter from '@libs/ndk/cache';
|
||||||
|
import { getExplicitRelayUrls } from '@libs/storage';
|
||||||
|
|
||||||
const setting = await getSetting('relays');
|
import { FULL_RELAYS } from '@stores/constants';
|
||||||
const relays = normalizeRelayUrlSet(JSON.parse(setting));
|
|
||||||
|
|
||||||
export const NDKInstance = () => {
|
export const NDKInstance = () => {
|
||||||
const [ndk, setNDK] = useState<NDK | undefined>(undefined);
|
const [ndk, setNDK] = useState<NDK | undefined>(undefined);
|
||||||
const [relayUrls, setRelayUrls] = useState<string[]>(relays);
|
const [relayUrls, setRelayUrls] = useState<string[]>([]);
|
||||||
const [fetcher, setFetcher] = useState<NostrFetcher>(undefined);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const cacheAdapter = useMemo(() => new TauriAdapter(), []);
|
||||||
loadNdk(relays);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function loadNdk(explicitRelayUrls: string[]) {
|
// TODO: fully support NIP-11
|
||||||
const ndkInstance = new NDK({ explicitRelayUrls });
|
async function verifyRelays(relays: string[]) {
|
||||||
|
const verifiedRelays: string[] = [];
|
||||||
|
|
||||||
try {
|
for (const relay of relays) {
|
||||||
await ndkInstance.connect();
|
let url: string;
|
||||||
} catch (error) {
|
|
||||||
console.error('ERROR loading NDK NDKInstance', error);
|
if (relay.startsWith('ws')) {
|
||||||
|
url = relay.replace('ws', 'http');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (relay.startsWith('wss')) {
|
||||||
|
url = relay.replace('wss', 'https');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { Accept: 'application/nostr+json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
console.log('relay information: ', data);
|
||||||
|
verifiedRelays.push(relay);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('fetch error', e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setNDK(ndkInstance);
|
return verifiedRelays;
|
||||||
setRelayUrls(explicitRelayUrls);
|
|
||||||
setFetcher(NostrFetcher.withCustomPool(ndkAdapter(ndkInstance)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function initNDK() {
|
||||||
|
let explicitRelayUrls: string[];
|
||||||
|
const explicitRelayUrlsFromDB = await getExplicitRelayUrls();
|
||||||
|
|
||||||
|
if (explicitRelayUrlsFromDB) {
|
||||||
|
explicitRelayUrls = await verifyRelays(explicitRelayUrlsFromDB);
|
||||||
|
} else {
|
||||||
|
explicitRelayUrls = await verifyRelays(FULL_RELAYS);
|
||||||
|
}
|
||||||
|
|
||||||
|
const instance = new NDK({ explicitRelayUrls, cacheAdapter });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await instance.connect(10000);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('NDK instance init failed: ', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
setNDK(instance);
|
||||||
|
setRelayUrls(explicitRelayUrls);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ndk) initNDK();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cacheAdapter.save();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ndk,
|
ndk,
|
||||||
relayUrls,
|
relayUrls,
|
||||||
fetcher,
|
|
||||||
loadNdk,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
// 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 { NostrFetcher } from 'nostr-fetch';
|
|
||||||
import { PropsWithChildren, createContext, useContext } from 'react';
|
import { PropsWithChildren, createContext, useContext } from 'react';
|
||||||
|
|
||||||
import { NDKInstance } from '@libs/ndk/instance';
|
import { NDKInstance } from '@libs/ndk/instance';
|
||||||
@@ -8,33 +7,26 @@ import { NDKInstance } from '@libs/ndk/instance';
|
|||||||
interface NDKContext {
|
interface NDKContext {
|
||||||
ndk: NDK;
|
ndk: NDK;
|
||||||
relayUrls: string[];
|
relayUrls: string[];
|
||||||
fetcher: NostrFetcher;
|
|
||||||
loadNdk: (_: string[]) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const NDKContext = createContext<NDKContext>({
|
const NDKContext = createContext<NDKContext>({
|
||||||
ndk: new NDK({}),
|
ndk: new NDK({}),
|
||||||
relayUrls: [],
|
relayUrls: [],
|
||||||
fetcher: undefined,
|
|
||||||
loadNdk: undefined,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const NDKProvider = ({ children }: PropsWithChildren<object>) => {
|
const NDKProvider = ({ children }: PropsWithChildren<object>) => {
|
||||||
const { ndk, relayUrls, fetcher, loadNdk } = NDKInstance();
|
const { ndk, relayUrls } = NDKInstance();
|
||||||
|
|
||||||
if (ndk)
|
return (
|
||||||
return (
|
<NDKContext.Provider
|
||||||
<NDKContext.Provider
|
value={{
|
||||||
value={{
|
ndk,
|
||||||
ndk,
|
relayUrls,
|
||||||
relayUrls,
|
}}
|
||||||
fetcher,
|
>
|
||||||
loadNdk,
|
{children}
|
||||||
}}
|
</NDKContext.Provider>
|
||||||
>
|
);
|
||||||
{children}
|
|
||||||
</NDKContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const useNDK = () => {
|
const useNDK = () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { FetchOptions, ResponseType, fetch } from '@tauri-apps/api/http';
|
import { fetch } from '@tauri-apps/plugin-http';
|
||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
|
|
||||||
import { OPENGRAPH } from '@stores/constants';
|
import { OPENGRAPH } from '@stores/constants';
|
||||||
@@ -19,7 +19,13 @@ interface IPreFetchedResource {
|
|||||||
imagesPropertyType?: string;
|
imagesPropertyType?: string;
|
||||||
proxyUrl?: string;
|
proxyUrl?: string;
|
||||||
url: string;
|
url: string;
|
||||||
data: any;
|
data: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function throwOnLoopback(address: string) {
|
||||||
|
if (OPENGRAPH.REGEX_LOOPBACK.test(address)) {
|
||||||
|
throw new Error('SSRF request detected, trying to query host');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function metaTag(doc: cheerio.CheerioAPI, type: string, attr: string) {
|
function metaTag(doc: cheerio.CheerioAPI, type: string, attr: string) {
|
||||||
@@ -28,42 +34,42 @@ function metaTag(doc: cheerio.CheerioAPI, type: string, attr: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function metaTagContent(doc: cheerio.CheerioAPI, type: string, attr: string) {
|
function metaTagContent(doc: cheerio.CheerioAPI, type: string, attr: string) {
|
||||||
return doc(`meta[${attr}='${type}']`).attr('content');
|
return doc(`meta[${attr}='${type}']`).attr(`content`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTitle(doc: cheerio.CheerioAPI) {
|
function getTitle(doc: cheerio.CheerioAPI) {
|
||||||
let title =
|
let title =
|
||||||
metaTagContent(doc, 'og:title', 'property') ||
|
metaTagContent(doc, `og:title`, `property`) ||
|
||||||
metaTagContent(doc, 'og:title', 'name');
|
metaTagContent(doc, `og:title`, `name`);
|
||||||
if (!title) {
|
if (!title) {
|
||||||
title = doc('title').text();
|
title = doc(`title`).text();
|
||||||
}
|
}
|
||||||
return title;
|
return title;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSiteName(doc: cheerio.CheerioAPI) {
|
function getSiteName(doc: cheerio.CheerioAPI) {
|
||||||
const siteName =
|
const siteName =
|
||||||
metaTagContent(doc, 'og:site_name', 'property') ||
|
metaTagContent(doc, `og:site_name`, `property`) ||
|
||||||
metaTagContent(doc, 'og:site_name', 'name');
|
metaTagContent(doc, `og:site_name`, `name`);
|
||||||
return siteName;
|
return siteName;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDescription(doc: cheerio.CheerioAPI) {
|
function getDescription(doc: cheerio.CheerioAPI) {
|
||||||
const description =
|
const description =
|
||||||
metaTagContent(doc, 'description', 'name') ||
|
metaTagContent(doc, `description`, `name`) ||
|
||||||
metaTagContent(doc, 'Description', 'name') ||
|
metaTagContent(doc, `Description`, `name`) ||
|
||||||
metaTagContent(doc, 'og:description', 'property');
|
metaTagContent(doc, `og:description`, `property`);
|
||||||
return description;
|
return description;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMediaType(doc: cheerio.CheerioAPI) {
|
function getMediaType(doc: cheerio.CheerioAPI) {
|
||||||
const node = metaTag(doc, 'medium', 'name');
|
const node = metaTag(doc, `medium`, `name`);
|
||||||
if (node) {
|
if (node) {
|
||||||
const content = node.attr('content');
|
const content = node.attr(`content`);
|
||||||
return content === 'image' ? 'photo' : content;
|
return content === `image` ? `photo` : content;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
metaTagContent(doc, 'og:type', 'property') || metaTagContent(doc, 'og:type', 'name')
|
metaTagContent(doc, `og:type`, `property`) || metaTagContent(doc, `og:type`, `name`)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,14 +83,14 @@ function getImages(
|
|||||||
let src: string | undefined;
|
let src: string | undefined;
|
||||||
let dic: Record<string, boolean> = {};
|
let dic: Record<string, boolean> = {};
|
||||||
|
|
||||||
const imagePropertyType = imagesPropertyType ?? 'og';
|
const imagePropertyType = imagesPropertyType ?? `og`;
|
||||||
nodes =
|
nodes =
|
||||||
metaTag(doc, `${imagePropertyType}:image`, 'property') ||
|
metaTag(doc, `${imagePropertyType}:image`, `property`) ||
|
||||||
metaTag(doc, `${imagePropertyType}:image`, 'name');
|
metaTag(doc, `${imagePropertyType}:image`, `name`);
|
||||||
|
|
||||||
if (nodes) {
|
if (nodes) {
|
||||||
nodes.each((_: number, node: cheerio.Element) => {
|
nodes.each((_: number, node: cheerio.Element) => {
|
||||||
if (node.type === 'tag') {
|
if (node.type === `tag`) {
|
||||||
src = node.attribs.content;
|
src = node.attribs.content;
|
||||||
if (src) {
|
if (src) {
|
||||||
src = new URL(src, rootUrl).href;
|
src = new URL(src, rootUrl).href;
|
||||||
@@ -95,18 +101,18 @@ function getImages(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (images.length <= 0 && !imagesPropertyType) {
|
if (images.length <= 0 && !imagesPropertyType) {
|
||||||
src = doc('link[rel=image_src]').attr('href');
|
src = doc(`link[rel=image_src]`).attr(`href`);
|
||||||
if (src) {
|
if (src) {
|
||||||
src = new URL(src, rootUrl).href;
|
src = new URL(src, rootUrl).href;
|
||||||
images = [src];
|
images = [src];
|
||||||
} else {
|
} else {
|
||||||
nodes = doc('img');
|
nodes = doc(`img`);
|
||||||
|
|
||||||
if (nodes?.length) {
|
if (nodes?.length) {
|
||||||
dic = {};
|
dic = {};
|
||||||
images = [];
|
images = [];
|
||||||
nodes.each((_: number, node: cheerio.Element) => {
|
nodes.each((_: number, node: cheerio.Element) => {
|
||||||
if (node.type === 'tag') src = node.attribs.src;
|
if (node.type === `tag`) src = node.attribs.src;
|
||||||
if (src && !dic[src]) {
|
if (src && !dic[src]) {
|
||||||
dic[src] = true;
|
dic[src] = true;
|
||||||
// width = node.attribs.width;
|
// width = node.attribs.width;
|
||||||
@@ -135,32 +141,32 @@ function getVideos(doc: cheerio.CheerioAPI) {
|
|||||||
let videoObj;
|
let videoObj;
|
||||||
let index;
|
let index;
|
||||||
|
|
||||||
const nodes = metaTag(doc, 'og:video', 'property') || metaTag(doc, 'og:video', 'name');
|
const nodes = metaTag(doc, `og:video`, `property`) || metaTag(doc, `og:video`, `name`);
|
||||||
|
|
||||||
if (nodes?.length) {
|
if (nodes?.length) {
|
||||||
nodeTypes =
|
nodeTypes =
|
||||||
metaTag(doc, 'og:video:type', 'property') || metaTag(doc, 'og:video:type', 'name');
|
metaTag(doc, `og:video:type`, `property`) || metaTag(doc, `og:video:type`, `name`);
|
||||||
nodeSecureUrls =
|
nodeSecureUrls =
|
||||||
metaTag(doc, 'og:video:secure_url', 'property') ||
|
metaTag(doc, `og:video:secure_url`, `property`) ||
|
||||||
metaTag(doc, 'og:video:secure_url', 'name');
|
metaTag(doc, `og:video:secure_url`, `name`);
|
||||||
width =
|
width =
|
||||||
metaTagContent(doc, 'og:video:width', 'property') ||
|
metaTagContent(doc, `og:video:width`, `property`) ||
|
||||||
metaTagContent(doc, 'og:video:width', 'name');
|
metaTagContent(doc, `og:video:width`, `name`);
|
||||||
height =
|
height =
|
||||||
metaTagContent(doc, 'og:video:height', 'property') ||
|
metaTagContent(doc, `og:video:height`, `property`) ||
|
||||||
metaTagContent(doc, 'og:video:height', 'name');
|
metaTagContent(doc, `og:video:height`, `name`);
|
||||||
|
|
||||||
for (index = 0; index < nodes.length; index += 1) {
|
for (index = 0; index < nodes.length; index += 1) {
|
||||||
const node = nodes[index];
|
const node = nodes[index];
|
||||||
if (node.type === 'tag') video = node.attribs.content;
|
if (node.type === `tag`) video = node.attribs.content;
|
||||||
|
|
||||||
nodeType = nodeTypes?.[index];
|
nodeType = nodeTypes?.[index];
|
||||||
if (nodeType?.type === 'tag') {
|
if (nodeType?.type === `tag`) {
|
||||||
videoType = nodeType ? nodeType.attribs.content : null;
|
videoType = nodeType ? nodeType.attribs.content : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
nodeSecureUrl = nodeSecureUrls?.[index];
|
nodeSecureUrl = nodeSecureUrls?.[index];
|
||||||
if (nodeSecureUrl?.type === 'tag') {
|
if (nodeSecureUrl?.type === `tag`) {
|
||||||
videoSecureUrl = nodeSecureUrl ? nodeSecureUrl.attribs.content : null;
|
videoSecureUrl = nodeSecureUrl ? nodeSecureUrl.attribs.content : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +177,7 @@ function getVideos(doc: cheerio.CheerioAPI) {
|
|||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
};
|
};
|
||||||
if (videoType && videoType.indexOf('video/') === 0) {
|
if (videoType && videoType.indexOf(`video/`) === 0) {
|
||||||
videos.splice(0, 0, videoObj);
|
videos.splice(0, 0, videoObj);
|
||||||
} else {
|
} else {
|
||||||
videos.push(videoObj);
|
videos.push(videoObj);
|
||||||
@@ -193,7 +199,7 @@ function getFavicons(doc: cheerio.CheerioAPI, rootUrl: string) {
|
|||||||
let nodes: cheerio.Cheerio<cheerio.Element> | never[] = [];
|
let nodes: cheerio.Cheerio<cheerio.Element> | never[] = [];
|
||||||
let src: string | undefined;
|
let src: string | undefined;
|
||||||
|
|
||||||
const relSelectors = ['rel=icon', `rel="shortcut icon"`, 'rel=apple-touch-icon'];
|
const relSelectors = [`rel=icon`, `rel="shortcut icon"`, `rel=apple-touch-icon`];
|
||||||
|
|
||||||
relSelectors.forEach((relSelector) => {
|
relSelectors.forEach((relSelector) => {
|
||||||
// look for all icon tags
|
// look for all icon tags
|
||||||
@@ -202,9 +208,9 @@ function getFavicons(doc: cheerio.CheerioAPI, rootUrl: string) {
|
|||||||
// collect all images from icon tags
|
// collect all images from icon tags
|
||||||
if (nodes.length) {
|
if (nodes.length) {
|
||||||
nodes.each((_: number, node: cheerio.Element) => {
|
nodes.each((_: number, node: cheerio.Element) => {
|
||||||
if (node.type === 'tag') src = node.attribs.href;
|
if (node.type === `tag`) src = node.attribs.href;
|
||||||
if (src) {
|
if (src) {
|
||||||
src = new URL(rootUrl).href;
|
src = new URL(src, rootUrl).href;
|
||||||
images.push(src);
|
images.push(src);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -222,7 +228,7 @@ function getFavicons(doc: cheerio.CheerioAPI, rootUrl: string) {
|
|||||||
function parseImageResponse(url: string, contentType: string) {
|
function parseImageResponse(url: string, contentType: string) {
|
||||||
return {
|
return {
|
||||||
url,
|
url,
|
||||||
mediaType: 'image',
|
mediaType: `image`,
|
||||||
contentType,
|
contentType,
|
||||||
favicons: [getDefaultFavicon(url)],
|
favicons: [getDefaultFavicon(url)],
|
||||||
};
|
};
|
||||||
@@ -231,7 +237,7 @@ function parseImageResponse(url: string, contentType: string) {
|
|||||||
function parseAudioResponse(url: string, contentType: string) {
|
function parseAudioResponse(url: string, contentType: string) {
|
||||||
return {
|
return {
|
||||||
url,
|
url,
|
||||||
mediaType: 'audio',
|
mediaType: `audio`,
|
||||||
contentType,
|
contentType,
|
||||||
favicons: [getDefaultFavicon(url)],
|
favicons: [getDefaultFavicon(url)],
|
||||||
};
|
};
|
||||||
@@ -240,7 +246,7 @@ function parseAudioResponse(url: string, contentType: string) {
|
|||||||
function parseVideoResponse(url: string, contentType: string) {
|
function parseVideoResponse(url: string, contentType: string) {
|
||||||
return {
|
return {
|
||||||
url,
|
url,
|
||||||
mediaType: 'video',
|
mediaType: `video`,
|
||||||
contentType,
|
contentType,
|
||||||
favicons: [getDefaultFavicon(url)],
|
favicons: [getDefaultFavicon(url)],
|
||||||
};
|
};
|
||||||
@@ -249,7 +255,7 @@ function parseVideoResponse(url: string, contentType: string) {
|
|||||||
function parseApplicationResponse(url: string, contentType: string) {
|
function parseApplicationResponse(url: string, contentType: string) {
|
||||||
return {
|
return {
|
||||||
url,
|
url,
|
||||||
mediaType: 'application',
|
mediaType: `application`,
|
||||||
contentType,
|
contentType,
|
||||||
favicons: [getDefaultFavicon(url)],
|
favicons: [getDefaultFavicon(url)],
|
||||||
};
|
};
|
||||||
@@ -268,7 +274,7 @@ function parseTextResponse(
|
|||||||
title: getTitle(doc),
|
title: getTitle(doc),
|
||||||
siteName: getSiteName(doc),
|
siteName: getSiteName(doc),
|
||||||
description: getDescription(doc),
|
description: getDescription(doc),
|
||||||
mediaType: getMediaType(doc) || 'website',
|
mediaType: getMediaType(doc) || `website`,
|
||||||
contentType,
|
contentType,
|
||||||
images: getImages(doc, url, options.imagesPropertyType),
|
images: getImages(doc, url, options.imagesPropertyType),
|
||||||
videos: getVideos(doc),
|
videos: getVideos(doc),
|
||||||
@@ -287,11 +293,11 @@ function parseUnknownResponse(
|
|||||||
|
|
||||||
function parseResponse(response: IPreFetchedResource, options?: ILinkPreviewOptions) {
|
function parseResponse(response: IPreFetchedResource, options?: ILinkPreviewOptions) {
|
||||||
try {
|
try {
|
||||||
let contentType = response.headers['content-type'];
|
let contentType = response.headers[`content-type`];
|
||||||
// console.warn(`original content type`, contentType);
|
// console.warn(`original content type`, contentType);
|
||||||
if (contentType?.indexOf(';')) {
|
if (contentType?.indexOf(`;`)) {
|
||||||
// eslint-disable-next-line prefer-destructuring
|
// eslint-disable-next-line prefer-destructuring
|
||||||
contentType = contentType.split(';')[0];
|
contentType = contentType.split(`;`)[0];
|
||||||
// console.warn(`splitting content type`, contentType);
|
// console.warn(`splitting content type`, contentType);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,20 +336,117 @@ function parseResponse(response: IPreFetchedResource, options?: ILinkPreviewOpti
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getLinkPreview(text: string) {
|
/**
|
||||||
const fetchUrl = text;
|
* Parses the text, extracts the first link it finds and does a HTTP request
|
||||||
const options: FetchOptions = {
|
* to fetch the website content, afterwards it tries to parse the internal HTML
|
||||||
method: 'GET',
|
* and extract the information via meta tags
|
||||||
timeout: 5,
|
* @param text string, text to be parsed
|
||||||
responseType: ResponseType.Text,
|
* @param options ILinkPreviewOptions
|
||||||
};
|
*/
|
||||||
|
export async function getLinkPreview(text: string, options?: ILinkPreviewOptions) {
|
||||||
let response = await fetch(fetchUrl, options);
|
if (!text || typeof text !== `string`) {
|
||||||
|
throw new Error(`link-preview-js did not receive a valid url or text`);
|
||||||
if (response.status > 300 && response.status < 309) {
|
|
||||||
const forwardedUrl = response.headers.location || '';
|
|
||||||
response = await fetch(forwardedUrl, options);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return parseResponse(response);
|
const detectedUrl = text
|
||||||
|
.replace(/\n/g, ` `)
|
||||||
|
.split(` `)
|
||||||
|
.find((token) => OPENGRAPH.REGEX_VALID_URL.test(token));
|
||||||
|
|
||||||
|
if (!detectedUrl) {
|
||||||
|
throw new Error(`link-preview-js did not receive a valid a url or text`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.followRedirects === `manual` && !options?.handleRedirects) {
|
||||||
|
throw new Error(
|
||||||
|
`link-preview-js followRedirects is set to manual, but no handleRedirects function was provided`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.resolveDNSHost) {
|
||||||
|
const resolvedUrl = await options.resolveDNSHost(detectedUrl);
|
||||||
|
|
||||||
|
throwOnLoopback(resolvedUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = options?.timeout ?? 3000; // 3 second timeout default
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutCounter = setTimeout(() => controller.abort(), timeout);
|
||||||
|
|
||||||
|
const fetchOptions = {
|
||||||
|
headers: options?.headers ?? {},
|
||||||
|
redirect: options?.followRedirects ?? `error`,
|
||||||
|
signal: controller.signal,
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchUrl = options?.proxyUrl ? options.proxyUrl.concat(detectedUrl) : detectedUrl;
|
||||||
|
|
||||||
|
// Seems like fetchOptions type definition is out of date
|
||||||
|
// https://github.com/node-fetch/node-fetch/issues/741
|
||||||
|
let response = await fetch(fetchUrl, fetchOptions as any).catch((e) => {
|
||||||
|
if (e.name === `AbortError`) {
|
||||||
|
throw new Error(`Request timeout`);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimeout(timeoutCounter);
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
response.status > 300 &&
|
||||||
|
response.status < 309 &&
|
||||||
|
fetchOptions.redirect === `manual` &&
|
||||||
|
options?.handleRedirects
|
||||||
|
) {
|
||||||
|
const forwardedUrl = response.headers.get(`location`) || ``;
|
||||||
|
|
||||||
|
if (!options.handleRedirects(fetchUrl, forwardedUrl)) {
|
||||||
|
throw new Error(`link-preview-js could not handle redirect`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.resolveDNSHost) {
|
||||||
|
const resolvedUrl = await options.resolveDNSHost(forwardedUrl);
|
||||||
|
|
||||||
|
throwOnLoopback(resolvedUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await fetch(forwardedUrl, fetchOptions as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimeout(timeoutCounter);
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
response.headers.forEach((header, key) => {
|
||||||
|
headers[key] = header;
|
||||||
|
});
|
||||||
|
|
||||||
|
const normalizedResponse: IPreFetchedResource = {
|
||||||
|
url: options?.proxyUrl ? response.url.replace(options.proxyUrl, ``) : response.url,
|
||||||
|
headers,
|
||||||
|
data: await response.text(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return parseResponse(normalizedResponse, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skip the library fetching the website for you, instead pass a response object
|
||||||
|
* from whatever source you get and use the internal parsing of the HTML to return
|
||||||
|
* the necessary information
|
||||||
|
* @param response Preview Response
|
||||||
|
* @param options IPreviewLinkOptions
|
||||||
|
*/
|
||||||
|
export async function getPreviewFromContent(
|
||||||
|
response: IPreFetchedResource,
|
||||||
|
options?: ILinkPreviewOptions
|
||||||
|
) {
|
||||||
|
if (!response || typeof response !== `object`) {
|
||||||
|
throw new Error(`link-preview-js did not receive a valid response object`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.url) {
|
||||||
|
throw new Error(`link-preview-js did not receive a valid response object`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseResponse(response, options);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
|
import Database from '@tauri-apps/plugin-sql';
|
||||||
import destr from 'destr';
|
import destr from 'destr';
|
||||||
import Database from 'tauri-plugin-sql-api';
|
|
||||||
|
|
||||||
import { parser } from '@utils/parser';
|
import { parser } from '@utils/parser';
|
||||||
import { getParentID } from '@utils/transform';
|
import { getParentID } from '@utils/transform';
|
||||||
import { Account, Block, Chats, LumeEvent, Profile, Settings } from '@utils/types';
|
import {
|
||||||
|
Account,
|
||||||
|
Chats,
|
||||||
|
LumeEvent,
|
||||||
|
Profile,
|
||||||
|
Relays,
|
||||||
|
Settings,
|
||||||
|
Widget,
|
||||||
|
} from '@utils/types';
|
||||||
|
|
||||||
let db: null | Database = null;
|
let db: null | Database = null;
|
||||||
|
|
||||||
@@ -24,6 +32,12 @@ export async function getActiveAccount() {
|
|||||||
'SELECT * FROM accounts WHERE is_active = 1;'
|
'SELECT * FROM accounts WHERE is_active = 1;'
|
||||||
);
|
);
|
||||||
if (result.length > 0) {
|
if (result.length > 0) {
|
||||||
|
result[0]['follows'] = result[0].follows
|
||||||
|
? JSON.parse(result[0].follows as unknown as string)
|
||||||
|
: null;
|
||||||
|
result[0]['network'] = result[0].network
|
||||||
|
? JSON.parse(result[0].network as unknown as string)
|
||||||
|
: null;
|
||||||
return result[0];
|
return result[0];
|
||||||
} else {
|
} else {
|
||||||
return null;
|
return null;
|
||||||
@@ -52,7 +66,7 @@ export async function createAccount(
|
|||||||
[npub, pubkey, 'privkey is stored in secure storage', follows || '', is_active || 0]
|
[npub, pubkey, 'privkey is stored in secure storage', follows || '', is_active || 0]
|
||||||
);
|
);
|
||||||
if (res) {
|
if (res) {
|
||||||
await createBlock(
|
await createWidget(
|
||||||
0,
|
0,
|
||||||
'Have fun together!',
|
'Have fun together!',
|
||||||
'https://void.cat/d/N5KUHEQCVg7SywXUPiJ7yq.jpg'
|
'https://void.cat/d/N5KUHEQCVg7SywXUPiJ7yq.jpg'
|
||||||
@@ -63,15 +77,12 @@ export async function createAccount(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// update account
|
// update account
|
||||||
export async function updateAccount(
|
export async function updateAccount(column: string, value: string | string[]) {
|
||||||
column: string,
|
|
||||||
value: string | string[],
|
|
||||||
pubkey: string
|
|
||||||
) {
|
|
||||||
const db = await connect();
|
const db = await connect();
|
||||||
return await db.execute(`UPDATE accounts SET ${column} = ? WHERE pubkey = ?;`, [
|
const account = await getActiveAccount();
|
||||||
|
return await db.execute(`UPDATE accounts SET ${column} = ? WHERE id = ?;`, [
|
||||||
value,
|
value,
|
||||||
pubkey,
|
account.id,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,8 +316,6 @@ export async function getChannelUsers(channel_id: string) {
|
|||||||
export async function getChats() {
|
export async function getChats() {
|
||||||
const db = await connect();
|
const db = await connect();
|
||||||
const account = await getActiveAccount();
|
const account = await getActiveAccount();
|
||||||
const follows =
|
|
||||||
typeof account.follows === 'string' ? JSON.parse(account.follows) : account.follows;
|
|
||||||
|
|
||||||
const chats: { follows: Array<Chats> | null; unknowns: Array<Chats> | null } = {
|
const chats: { follows: Array<Chats> | null; unknowns: Array<Chats> | null } = {
|
||||||
follows: [],
|
follows: [],
|
||||||
@@ -321,7 +330,7 @@ export async function getChats() {
|
|||||||
result = result.sort((a, b) => a.new_messages - b.new_messages);
|
result = result.sort((a, b) => a.new_messages - b.new_messages);
|
||||||
|
|
||||||
chats.follows = result.filter((el) => {
|
chats.follows = result.filter((el) => {
|
||||||
return follows.some((i) => {
|
return account.follows.some((i) => {
|
||||||
return i === el.sender_pubkey;
|
return i === el.sender_pubkey;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -408,34 +417,43 @@ export async function updateLastLogin(value: number) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// get all blocks
|
// get all widgets
|
||||||
export async function getBlocks() {
|
export async function getWidgets() {
|
||||||
const db = await connect();
|
const db = await connect();
|
||||||
const account = await getActiveAccount();
|
const account = await getActiveAccount();
|
||||||
const result: Array<Block> = await db.select(
|
const result: Array<Widget> = await db.select(
|
||||||
`SELECT * FROM blocks WHERE account_id = "${account.id}" ORDER BY created_at DESC;`
|
`SELECT * FROM widgets WHERE account_id = "${account.id}" ORDER BY created_at DESC;`
|
||||||
);
|
);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// create block
|
// create block
|
||||||
export async function createBlock(
|
export async function createWidget(
|
||||||
kind: number,
|
kind: number,
|
||||||
title: string,
|
title: string,
|
||||||
content: string | string[]
|
content: string | string[]
|
||||||
) {
|
) {
|
||||||
const db = await connect();
|
const db = await connect();
|
||||||
const activeAccount = await getActiveAccount();
|
const activeAccount = await getActiveAccount();
|
||||||
return await db.execute(
|
const insert = await db.execute(
|
||||||
'INSERT OR IGNORE INTO blocks (account_id, kind, title, content) VALUES (?, ?, ?, ?);',
|
'INSERT OR IGNORE INTO widgets (account_id, kind, title, content) VALUES (?, ?, ?, ?);',
|
||||||
[activeAccount.id, kind, title, content]
|
[activeAccount.id, kind, title, content]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (insert) {
|
||||||
|
const record: Widget = await db.select(
|
||||||
|
'SELECT * FROM widgets ORDER BY id DESC LIMIT 1;'
|
||||||
|
);
|
||||||
|
return record[0];
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove block
|
// remove block
|
||||||
export async function removeBlock(id: string) {
|
export async function removeWidget(id: string) {
|
||||||
const db = await connect();
|
const db = await connect();
|
||||||
return await db.execute(`DELETE FROM blocks WHERE id = "${id}";`);
|
return await db.execute(`DELETE FROM widgets WHERE id = "${id}";`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// logout
|
// logout
|
||||||
@@ -444,8 +462,7 @@ export async function removeAll() {
|
|||||||
await db.execute(`UPDATE settings SET value = "0" WHERE key = "last_login";`);
|
await db.execute(`UPDATE settings SET value = "0" WHERE key = "last_login";`);
|
||||||
await db.execute('DELETE FROM replies;');
|
await db.execute('DELETE FROM replies;');
|
||||||
await db.execute('DELETE FROM notes;');
|
await db.execute('DELETE FROM notes;');
|
||||||
await db.execute('DELETE FROM blacklist;');
|
await db.execute('DELETE FROM widgets;');
|
||||||
await db.execute('DELETE FROM blocks;');
|
|
||||||
await db.execute('DELETE FROM chats;');
|
await db.execute('DELETE FROM chats;');
|
||||||
await db.execute('DELETE FROM accounts;');
|
await db.execute('DELETE FROM accounts;');
|
||||||
return true;
|
return true;
|
||||||
@@ -497,3 +514,44 @@ export async function removePrivkey() {
|
|||||||
`UPDATE accounts SET privkey = "privkey is stored in secure storage" WHERE id = "${activeAccount.id}";`
|
`UPDATE accounts SET privkey = "privkey is stored in secure storage" WHERE id = "${activeAccount.id}";`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// get relays
|
||||||
|
export async function getRelays() {
|
||||||
|
const db = await connect();
|
||||||
|
const activeAccount = await getActiveAccount();
|
||||||
|
return (await db.select(
|
||||||
|
`SELECT * FROM relays WHERE account_id = "${activeAccount.id}";`
|
||||||
|
)) as Relays[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// get relays
|
||||||
|
export async function getExplicitRelayUrls() {
|
||||||
|
const db = await connect();
|
||||||
|
const activeAccount = await getActiveAccount();
|
||||||
|
|
||||||
|
if (!activeAccount) return null;
|
||||||
|
|
||||||
|
const result: Relays[] = await db.select(
|
||||||
|
`SELECT * FROM relays WHERE account_id = "${activeAccount.id}";`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.length > 0) return result.map((el) => el.relay);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// create relay
|
||||||
|
export async function createRelay(relay: string, purpose?: string) {
|
||||||
|
const db = await connect();
|
||||||
|
const activeAccount = await getActiveAccount();
|
||||||
|
return await db.execute(
|
||||||
|
'INSERT OR IGNORE INTO relays (account_id, relay, purpose) VALUES (?, ?, ?);',
|
||||||
|
[activeAccount.id, relay, purpose || '']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove relay
|
||||||
|
export async function removeRelay(relay: string) {
|
||||||
|
const db = await connect();
|
||||||
|
return await db.execute(`DELETE FROM relays WHERE relay = "${relay}";`);
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user