Merge pull request #67 from luminous-devs/main

v1.2.0
This commit is contained in:
Ren Amamiya
2023-08-13 11:19:51 +07:00
committed by GitHub
174 changed files with 5950 additions and 5358 deletions

3
.eslintignore Normal file
View File

@@ -0,0 +1,3 @@
/**/node_modules/*
node_modules/
dist/

View File

@@ -4,7 +4,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lume</title>
</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>
<script type="module" src="/src/main.tsx"></script>
</body>

View File

@@ -1,7 +1,7 @@
{
"name": "lume",
"private": true,
"version": "1.1.1",
"version": "1.2.0",
"scripts": {
"dev": "vite",
"build": "vite build",
@@ -17,16 +17,34 @@
"**/*.{ts, tsx, css, md, html, json}": "prettier --cache --write"
},
"dependencies": {
"@headlessui/react": "^1.7.15",
"@nostr-dev-kit/ndk": "^0.7.7",
"@ctrl/magnet-link": "^3.1.2",
"@headlessui/react": "^1.7.16",
"@nostr-dev-kit/ndk": "^0.8.11",
"@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-popover": "^1.0.6",
"@radix-ui/react-tooltip": "^1.0.6",
"@tanstack/react-query": "^4.32.0",
"@tanstack/react-query-devtools": "^4.32.0",
"@tanstack/react-query": "^4.32.6",
"@tanstack/react-query-devtools": "^4.32.6",
"@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-mention": "^2.0.4",
"@tiptap/extension-placeholder": "^2.0.4",
@@ -34,55 +52,52 @@
"@tiptap/react": "^2.0.4",
"@tiptap/starter-kit": "^2.0.4",
"@tiptap/suggestion": "^2.0.4",
"@void-cat/api": "^1.0.7",
"cheerio": "1.0.0-rc.12",
"dayjs": "^1.11.9",
"destr": "^1.2.2",
"framer-motion": "^10.13.1",
"get-urls": "^11.0.0",
"html-to-text": "^9.0.5",
"immer": "^10.0.2",
"light-bolt11-decoder": "^3.0.0",
"lru-cache": "^10.0.1",
"nostr-fetch": "^0.12.2",
"nostr-tools": "^1.13.1",
"nostr-tools": "^1.14.0",
"qrcode.react": "^3.1.0",
"react": "^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-markdown": "^8.0.7",
"react-player": "^2.12.0",
"react-router-dom": "^6.14.2",
"react-router-dom": "^6.15.0",
"react-string-replace": "^1.1.1",
"react-virtuoso": "^4.4.2",
"react-virtuoso": "^4.5.0",
"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",
"zustand": "^4.3.9"
"zustand": "^4.4.1"
},
"devDependencies": {
"@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",
"@types/html-to-text": "^9.0.1",
"@types/node": "^18.17.1",
"@types/react": "^18.2.17",
"@types/node": "^18.17.5",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",
"@types/youtube-player": "^5.5.7",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"@vitejs/plugin-react-swc": "^3.3.2",
"autoprefixer": "^10.4.14",
"clsx": "^2.0.0",
"cross-env": "^7.0.3",
"csstype": "^3.1.2",
"encoding": "^0.1.13",
"eslint": "^8.45.0",
"eslint-config-prettier": "^8.8.0",
"eslint": "^8.47.0",
"eslint-config-prettier": "^8.10.0",
"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",
"husky": "^8.0.3",
"lint-staged": "^13.2.3",
@@ -90,9 +105,10 @@
"prettier": "^2.8.8",
"prettier-plugin-tailwindcss": "^0.3.0",
"prop-types": "^15.8.1",
"tailwind-merge": "^1.14.0",
"tailwindcss": "^3.3.3",
"typescript": "^4.9.5",
"vite": "^4.4.7",
"vite": "^4.4.9",
"vite-plugin-top-level-await": "^1.3.1",
"vite-tsconfig-paths": "^4.2.0"
}

1810
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

BIN
public/clapping_hands.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
public/face_with_tongue.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 986 KiB

BIN
public/lume.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

1463
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,46 +1,42 @@
[package]
name = "lume"
version = "1.1.1"
version = "1.2.0"
description = "nostr client"
authors = ["Ren Amamiya"]
license = ""
repository = ""
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
[build-dependencies]
tauri-build = { version = "1.2", features = [] }
tauri-build = { version = "2.0.0-alpha.6", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
tauri = { version = "1.2", features = [
"fs-remove-file",
"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",
tauri = { version = "2.0.0-alpha.10", features = [
"macos-private-api",
"system-tray",
"updater",
"window-close",
"window-start-dragging",
] }
tauri-plugin-single-instance = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
tauri-plugin-autostart = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
tauri-plugin-stronghold = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
tauri-plugin-upload = { 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 = "v2" }
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 = "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 = [
"sqlite",
] }
@@ -49,13 +45,9 @@ rand = "0.8.5"
[dependencies.tauri-plugin-sql]
git = "https://github.com/tauri-apps/plugins-workspace"
branch = "v1"
branch = "v2"
features = ["sqlite"]
[target.'cfg(target_os = "macos")'.dependencies]
objc = "0.2.7"
cocoa = "0.24.1"
[features]
# 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

View File

@@ -0,0 +1,2 @@
-- Add migration script here
ALTER TABLE accounts ADD network JSON;

View 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)
);

View File

@@ -0,0 +1,3 @@
-- Add migration script here
ALTER TABLE blocks
RENAME TO widgets;

View File

@@ -3,19 +3,11 @@
windows_subsystem = "windows"
)]
#[cfg(target_os = "macos")]
#[macro_use]
extern crate objc;
// use rand::distributions::{Alphanumeric, DistString};
use tauri::{Manager, WindowEvent};
use tauri::Manager;
use tauri_plugin_autostart::MacosLauncher;
use tauri_plugin_sql::{Migration, MigrationKind};
#[cfg(target_os = "macos")]
use window_ext::WindowExt;
#[cfg(target_os = "macos")]
mod window_ext;
use window_vibrancy::{apply_mica, apply_vibrancy, NSVisualEffectMaterial};
#[derive(Clone, serde::Serialize)]
struct Payload {
@@ -23,31 +15,18 @@ struct Payload {
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() {
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(
tauri_plugin_sql::Builder::default()
.add_migrations(
@@ -119,6 +98,24 @@ fn main() {
sql: include_str!("../migrations/20230725010250_update_default_relays.sql"),
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(),
@@ -156,7 +153,33 @@ fn main() {
.emit_all("single-instance", Payload { args: argv, cwd })
.unwrap();
}))
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_upload::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_app::init())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_window::init())
.plugin(tauri_plugin_store::Builder::default().build())
.plugin(tauri_plugin_shell::init())
.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!())
.expect("error while running tauri application");
}

View File

@@ -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);
}
}
}
}

View File

@@ -1,148 +1,125 @@
{
"build": {
"beforeDevCommand": "pnpm dev",
"beforeBuildCommand": "pnpm build",
"devPath": "http://localhost:3000",
"distDir": "../dist",
"withGlobalTauri": true
},
"package": {
"productName": "Lume",
"version": "1.1.1"
},
"tauri": {
"allowlist": {
"all": false,
"app": {
"all": false
},
"os": {
"all": true
},
"http": {
"all": true,
"request": true,
"scope": ["http://**", "https://**"]
},
"fs": {
"all": false,
"readFile": true,
"readDir": true,
"writeFile": true,
"removeFile": true,
"scope": [
"$APPDATA/*",
"$DATA/*",
"$LOCALDATA/*",
"$DESKTOP/*",
"$DOCUMENT/*",
"$DOWNLOAD/*",
"$HOME/*",
"$PICTURE/*",
"$PUBLIC/*",
"$VIDEO/*"
]
},
"path": {
"all": true
},
"shell": {
"all": false,
"open": true
},
"clipboard": {
"all": false,
"writeText": true,
"readText": true
},
"dialog": {
"all": false,
"open": true
},
"notification": {
"all": true
},
"window": {
"startDragging": true,
"close": true,
"create": true
},
"process": {
"all": false,
"exit": false,
"relaunch": true,
"relaunchDangerousAllowSymlinkMacos": false
}
},
"bundle": {
"active": true,
"category": "SocialNetworking",
"copyright": "",
"appimage": {
"bundleMediaFramework": true
},
"deb": {
"depends": []
},
"externalBin": [],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"identifier": "com.lume.nu",
"longDescription": "",
"macOS": {
"entitlements": null,
"exceptionDomain": "",
"frameworks": [],
"providerShortName": null,
"signingIdentity": null
},
"resources": [],
"shortDescription": "",
"targets": "all",
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": ""
}
},
"security": {
"csp": "upgrade-insecure-requests"
},
"updater": {
"active": true,
"dialog": true,
"endpoints": [
"https://lus.reya3772.workers.dev/v1/{{target}}/{{arch}}/{{current_version}}",
"https://lus.reya3772.workers.dev/{{target}}/{{current_version}}"
],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEU4RjAzODFBREQ4MkM3RTEKUldUaHg0TGRHamp3NkI5bnhoOEVjanlHWFNzQ2Q3NDhubFFLUmJpSHJ1L2FqNnB3alF1Y2R3U3gK",
"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
}
]
}
"build": {
"beforeBuildCommand": "pnpm build",
"beforeDevCommand": "pnpm dev",
"devPath": "http://localhost:3000",
"distDir": "../dist",
"withGlobalTauri": true
},
"package": {
"productName": "Lume",
"version": "1.2.0"
},
"plugins": {
"fs": {
"scope": [
"$APPDATA/*",
"$DATA/*",
"$LOCALDATA/*",
"$DESKTOP/*",
"$DOCUMENT/*",
"$DOWNLOAD/*",
"$HOME/*",
"$PICTURE/*",
"$PUBLIC/*",
"$VIDEO/*"
]
},
"http": {
"scope": [
"http://**/",
"https://**/"
]
},
"shell": {
"open": true
},
"updater": {
"endpoints": [
"https://lus.reya3772.workers.dev/v1/{{target}}/{{arch}}/{{current_version}}",
"https://lus.reya3772.workers.dev/{{target}}/{{current_version}}"
]
}
},
"tauri": {
"bundle": {
"active": true,
"appimage": {
"bundleMediaFramework": true
},
"category": "SocialNetworking",
"copyright": "",
"deb": {
"depends": []
},
"externalBin": [],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"identifier": "com.lume.nu",
"longDescription": "",
"macOS": {
"entitlements": null,
"exceptionDomain": "",
"frameworks": [],
"providerShortName": null,
"signingIdentity": null
},
"resources": [],
"shortDescription": "",
"targets": "all",
"updater": {
"active": true,
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEU4RjAzODFBREQ4MkM3RTEKUldUaHg0TGRHamp3NkI5bnhoOEVjanlHWFNzQ2Q3NDhubFFLUmJpSHJ1L2FqNnB3alF1Y2R3U3gK",
"windows": {
"installMode": "passive"
}
},
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": ""
}
},
"security": {
"csp": "upgrade-insecure-requests"
},
"systemTray": {
"iconAsTemplate": true,
"iconPath": "icons/icon.png"
},
"windows": [
{
"width": 1080,
"height": 800,
"minWidth": 1080,
"minHeight": 800,
"resizable": true,
"theme": "Dark",
"title": "Lume",
"titleBarStyle": "Overlay",
"transparent": true,
"center": true,
"fullscreen": false,
"hiddenTitle": true,
"visible": false
},
{
"width": 400,
"height": 500,
"decorations": true,
"hiddenTitle": true,
"center": true,
"resizable": false,
"titleBarStyle": "Overlay",
"label": "splashscreen",
"url": "splashscreen"
}
],
"macOSPrivateApi": true
}
}

View File

@@ -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 { 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 { 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 { 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 { NoteScreen } from '@app/note';
import { Root } from '@app/root';
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 { getActiveAccount } from '@libs/storage';
import { AppLayout } from '@shared/appLayout';
import { AuthLayout } from '@shared/authLayout';
import { Protected } from '@shared/protected';
import { LoaderIcon } from '@shared/icons';
import { SettingsLayout } from '@shared/settingsLayout';
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([
{
path: '/',
element: (
<Protected>
<Root />
</Protected>
),
element: <AppLayout />,
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',
element: <AuthLayout />,
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',
element: <AuthImportScreen />,
children: [
{ path: '', element: <ImportStep1Screen /> },
{ path: 'step-2', element: <ImportStep2Screen /> },
{ path: 'step-3', element: <ImportStep3Screen /> },
{
path: '',
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',
element: <AuthCreateScreen />,
children: [
{ path: '', element: <CreateStep1Screen /> },
{ path: 'step-2', element: <CreateStep2Screen /> },
{ path: 'step-3', element: <CreateStep3Screen /> },
{ path: 'step-4', element: <CreateStep4Screen /> },
{ path: 'step-5', element: <CreateStep5Screen /> },
{
path: '',
async lazy() {
const { CreateStep1Screen } = await import('@app/auth/create/step-1');
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: 'reset', element: <ResetScreen /> },
],
},
{
path: '/app',
element: (
<Protected>
<AppLayout />
</Protected>
),
children: [
{ path: 'space', element: <SpaceScreen /> },
{ path: 'trending', element: <TrendingScreen /> },
{ path: 'note/:id', element: <NoteScreen /> },
{ path: 'users/:pubkey', element: <UserScreen /> },
{ path: 'chats/:pubkey', element: <ChatScreen /> },
{ path: 'channel/:id', element: <ChannelScreen /> },
{
path: 'onboarding',
element: <OnboardingScreen />,
children: [
{
path: '',
async lazy() {
const { OnboardStep1Screen } = await import('@app/auth/onboarding/step-1');
return { Component: OnboardStep1Screen };
},
},
{
path: 'step-2',
async lazy() {
const { OnboardStep2Screen } = await import('@app/auth/onboarding/step-2');
return { Component: OnboardStep2Screen };
},
},
{
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',
element: (
<Protected>
<SettingsLayout />
</Protected>
),
element: <SettingsLayout />,
children: [
{ path: 'general', element: <GeneralSettingsScreen /> },
{ path: 'shortcuts', element: <ShortcutsSettingsScreen /> },
{ path: 'account', element: <AccountSettingsScreen /> },
{
path: 'general',
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 (
<RouterProvider
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 }}
/>
);

View File

@@ -3,7 +3,7 @@ import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
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 }) {
const { status, user } = useProfile(pubkey, fallback);
@@ -11,10 +11,10 @@ export function User({ pubkey, fallback }: { pubkey: string; fallback?: string }
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-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">
<span className="h-4 w-1/2 animate-pulse rounded bg-zinc-800" />
<span className="h-3 w-1/3 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-white/10" />
</div>
</div>
);
@@ -31,11 +31,11 @@ export function User({ pubkey, fallback }: { pubkey: string; fallback?: string }
/>
</div>
<div className="flex w-full flex-1 flex-col items-start text-start">
<span className="truncate font-medium leading-tight text-zinc-100">
{user?.name || user?.displayName || user?.display_name}
<span className="truncate font-medium leading-tight text-white">
{user?.name || user?.display_name || user?.nip05}
</span>
<span className="max-w-[15rem] truncate text-base leading-tight text-zinc-400">
{user?.nip05?.toLowerCase() || shortenKey(pubkey)}
<span className="max-w-[15rem] truncate text-base leading-tight text-white/50">
{user?.nip05?.toLowerCase() || displayNpub(pubkey, 16)}
</span>
</div>
</div>

View 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>
);
}

View File

@@ -1,6 +1,19 @@
import { useEffect } from 'react';
import { Outlet } from 'react-router-dom';
import { useOnboarding } from '@stores/onboarding';
import { useStronghold } from '@stores/stronghold';
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 (
<div className="flex h-full w-full items-center justify-center">
<Outlet />

View File

@@ -1,13 +1,14 @@
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 { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { createAccount } from '@libs/storage';
import { Button } from '@shared/button';
import { EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
import { useOnboarding } from '@stores/onboarding';
import { useStronghold } from '@stores/stronghold';
@@ -16,7 +17,9 @@ export function CreateStep1Screen() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const setPrivkey = useStronghold((state) => state.setPrivkey);
const setTempPrivkey = useOnboarding((state) => state.setTempPrivkey);
const setPubkey = useOnboarding((state) => state.setPubkey);
const setStep = useOnboarding((state) => state.setStep);
const [privkeyInput, setPrivkeyInput] = useState('password');
const [loading, setLoading] = useState(false);
@@ -37,13 +40,9 @@ export function CreateStep1Screen() {
};
const download = async () => {
await writeTextFile(
'lume-keys.txt',
`Public key: ${pubkey}\nPrivate key: ${privkey}`,
{
dir: BaseDirectory.Download,
}
);
await writeTextFile('lume-keys.txt', `Public key: ${npub}\nPrivate key: ${nsec}`, {
dir: BaseDirectory.Download,
});
setDownloaded(true);
};
@@ -64,8 +63,9 @@ export function CreateStep1Screen() {
const submit = () => {
setLoading(true);
setPubkey(pubkey);
setPrivkey(privkey);
setTempPrivkey(privkey); // only use if user close app and reopen it
setPubkey(pubkey);
account.mutate({
npub,
@@ -78,50 +78,47 @@ export function CreateStep1Screen() {
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 (
<div className="mx-auto w-full max-w-md">
<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 className="flex flex-col gap-4">
<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
readOnly
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 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">
<input
readOnly
type={privkeyInput}
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
type="button"
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' ? (
<EyeOffIcon
width={20}
height={20}
className="text-zinc-500 group-hover:text-zinc-100"
/>
<EyeOffIcon className="h-4 w-4 text-white/50 group-hover:text-white" />
) : (
<EyeOnIcon
width={20}
height={20}
className="text-zinc-500 group-hover:text-zinc-100"
/>
<EyeOnIcon className="h-4 w-4 text-white/50 group-hover:text-white" />
)}
</button>
</div>
<div className="mt-2 text-sm text-zinc-500">
<div className="mt-2 text-sm text-white/50">
<p>
Your private key is your password. If you lose this key, you will lose
access to your account! Copy it and keep it in a safe place. There is no way
@@ -130,15 +127,29 @@ export function CreateStep1Screen() {
</div>
</div>
<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 ? (
<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 ? (
<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()}>
Download

View File

@@ -1,8 +1,9 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Resolver, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
import { useOnboarding } from '@stores/onboarding';
import { useStronghold } from '@stores/stronghold';
@@ -29,13 +30,13 @@ const resolver: Resolver<FormValues> = async (values) => {
export function CreateStep2Screen() {
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 [loading, setLoading] = useState(false);
const privkey = useStronghold((state) => state.privkey);
const pubkey = useOnboarding((state) => state.pubkey);
const { save } = useSecureStorage();
// 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 (
<div className="mx-auto w-full max-w-md">
<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
</h1>
</div>
@@ -85,29 +91,21 @@ export function CreateStep2Screen() {
<input
{...register('password', { required: true })}
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
type="button"
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' ? (
<EyeOffIcon
width={20}
height={20}
className="text-zinc-500 group-hover:text-zinc-100"
/>
<EyeOffIcon className="h-4 w-4 text-white/50 group-hover:text-white" />
) : (
<EyeOnIcon
width={20}
height={20}
className="text-zinc-500 group-hover:text-zinc-100"
/>
<EyeOnIcon className="h-4 w-4 text-white/50 group-hover:text-white" />
)}
</button>
</div>
<div className="text-sm text-zinc-500">
<div className="text-sm text-white/50">
<p>
Password is use to secure your key store in local machine, when you move
to other clients, you just need to copy your private key as nsec or
@@ -122,12 +120,20 @@ export function CreateStep2Screen() {
<button
type="submit"
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 ? (
<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>
</div>

View File

@@ -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 { useNavigate } from 'react-router-dom';
import { AvatarUploader } from '@shared/avatarUploader';
import { BannerUploader } from '@shared/bannerUploader';
import { LoaderIcon } from '@shared/icons';
import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useOnboarding } from '@stores/onboarding';
import { useNostr } from '@utils/hooks/useNostr';
export function CreateStep3Screen() {
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 [banner, setBanner] = useState('');
const [loading, setLoading] = useState(false);
const { publish } = useNostr();
const {
register,
handleSubmit,
formState: { isDirty, isValid },
} = useForm();
const onSubmit = (data: { name: string; about: string }) => {
const onSubmit = async (data: { name: string; about: string; website: string }) => {
setLoading(true);
try {
const profile = {
...data,
username: data.name,
name: data.name,
display_name: data.name,
bio: data.about,
website: data.website,
};
createProfile(profile);
// redirect to next step
setTimeout(() => navigate('/auth/create/step-4', { replace: true }), 1200);
} catch {
console.log('error');
const event = await publish({
content: JSON.stringify(profile),
kind: 0,
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 (
<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 profile</h1>
<h1 className="text-xl font-semibold text-white">Create your profile</h1>
</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">
<input
type={'hidden'}
{...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"
/>
<input type={'hidden'} {...register('picture')} value={picture} />
<input type={'hidden'} {...register('banner')} value={banner} />
<div className="relative">
<div className="relative h-44 w-full bg-zinc-800">
<div className="relative h-44 w-full bg-white/10">
<Image
src={banner}
fallback="https://void.cat/d/QY1myro5tkHVs2nY7dy74b.jpg"
@@ -79,7 +90,7 @@ export function CreateStep3Screen() {
src={picture}
fallback={DEFAULT_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">
<AvatarUploader setPicture={setPicture} />
@@ -91,7 +102,7 @@ export function CreateStep3Screen() {
<div className="flex flex-col gap-1">
<label
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 *
</label>
@@ -102,26 +113,26 @@ export function CreateStep3Screen() {
minLength: 4,
})}
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 className="flex flex-col gap-1">
<label
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
</label>
<textarea
{...register('about')}
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 className="flex flex-col gap-1">
<label
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
</label>
@@ -131,18 +142,26 @@ export function CreateStep3Screen() {
required: 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>
<button
type="submit"
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 ? (
<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>
</div>

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -1,6 +1,19 @@
import { useEffect } from 'react';
import { Outlet } from 'react-router-dom';
import { useOnboarding } from '@stores/onboarding';
import { useStronghold } from '@stores/stronghold';
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 (
<div className="flex h-full w-full items-center justify-center">
<Outlet />

View File

@@ -1,12 +1,13 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { getPublicKey, nip19 } from 'nostr-tools';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Resolver, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { createAccount } from '@libs/storage';
import { LoaderIcon } from '@shared/icons';
import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
import { useOnboarding } from '@stores/onboarding';
import { useStronghold } from '@stores/stronghold';
@@ -33,7 +34,9 @@ export function ImportStep1Screen() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const setPrivkey = useStronghold((state) => state.setPrivkey);
const setTempPubkey = useOnboarding((state) => state.setTempPrivkey);
const setPubkey = useOnboarding((state) => state.setPubkey);
const setStep = useOnboarding((state) => state.setStep);
const [loading, setLoading] = useState(false);
@@ -71,10 +74,9 @@ export function ImportStep1Screen() {
const pubkey = getPublicKey(privkey);
const npub = nip19.npubEncode(pubkey);
// use for onboarding process only
setPubkey(pubkey);
// add stronghold state
setPrivkey(privkey);
setTempPubkey(privkey); // only use if user close app and reopen it
setPubkey(pubkey);
// add account to local database
account.mutate({
@@ -90,25 +92,30 @@ export function ImportStep1Screen() {
} catch (error) {
setError('privkey', {
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 (
<div className="mx-auto w-full max-w-md">
<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 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">
<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
{...register('privkey', { required: true, minLength: 32 })}
type={'password'}
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">
{errors.privkey && <p>{errors.privkey.message}</p>}
@@ -118,12 +125,20 @@ export function ImportStep1Screen() {
<button
type="submit"
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 ? (
<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>
</div>

View File

@@ -1,8 +1,9 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Resolver, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
import { useOnboarding } from '@stores/onboarding';
import { useStronghold } from '@stores/stronghold';
@@ -29,13 +30,13 @@ const resolver: Resolver<FormValues> = async (values) => {
export function ImportStep2Screen() {
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 [loading, setLoading] = useState(false);
const privkey = useStronghold((state) => state.privkey);
const pubkey = useOnboarding((state) => state.pubkey);
const { save } = useSecureStorage();
// 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 (
<div className="mx-auto w-full max-w-md">
<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
</h1>
</div>
@@ -85,35 +91,25 @@ export function ImportStep2Screen() {
<input
{...register('password', { required: true })}
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
type="button"
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' ? (
<EyeOffIcon
width={20}
height={20}
className="text-zinc-500 group-hover:text-zinc-100"
/>
<EyeOffIcon className="h-4 w-4 text-white/50 group-hover:text-white" />
) : (
<EyeOnIcon
width={20}
height={20}
className="text-zinc-500 group-hover:text-zinc-100"
/>
<EyeOnIcon className="h-4 w-4 text-white/50 group-hover:text-white" />
)}
</button>
</div>
<div className="text-sm text-zinc-500">
<p>
Password is use to unlock app and secure your key store in local machine.
When you move to other clients, you just need to copy your private key as
nsec or hexstring
</p>
</div>
<p className="text-sm text-white/50">
Password is use to unlock app and secure your key store in local machine.
When you move to other clients, you just need to copy your private key as
nsec or hexstring
</p>
<span className="text-sm text-red-400">
{errors.password && <p>{errors.password.message}</p>}
</span>
@@ -122,12 +118,20 @@ export function ImportStep2Screen() {
<button
type="submit"
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 ? (
<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>
</div>

View File

@@ -1,85 +1,92 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { User } from '@app/auth/components/user';
import { useNDK } from '@libs/ndk/provider';
import { updateAccount } from '@libs/storage';
import { updateLastLogin } from '@libs/storage';
import { Button } from '@shared/button';
import { LoaderIcon } from '@shared/icons';
import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
import { useOnboarding } from '@stores/onboarding';
import { useAccount } from '@utils/hooks/useAccount';
import { setToArray } from '@utils/transform';
import { useNostr } from '@utils/hooks/useNostr';
export function ImportStep3Screen() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const setStep = useOnboarding((state) => state.setStep);
const [loading, setLoading] = useState(false);
const { ndk } = useNDK();
const { status, account } = useAccount();
const update = useMutation({
mutationFn: (follows: string[]) => {
return updateAccount('follows', follows, account.pubkey);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['currentAccount'] });
},
});
const { fetchNotes, fetchChats } = useNostr();
const submit = async () => {
try {
// show loading indicator
setLoading(true);
const user = ndk.getUser({ hexpubkey: account.pubkey });
const follows = await user.follows();
const now = Math.floor(Date.now() / 1000);
await fetchNotes();
await fetchChats();
await updateLastLogin(now);
// follows as list
const followsList = setToArray(follows);
queryClient.invalidateQueries(['currentAccount']);
// update
update.mutate([...followsList, account.pubkey]);
// redirect to next step
setTimeout(() => navigate('/auth/onboarding', { replace: true }), 1200);
} catch {
console.log('error');
navigate('/auth/onboarding/step-2', { replace: true });
} catch (e) {
console.log('error: ', e);
setLoading(false);
}
};
useEffect(() => {
// save current step, if user close app and reopen it
setStep('/auth/import/step-3');
}, []);
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold">
{loading ? 'Creating...' : 'Continue with'}
{loading ? 'Prefetching data...' : 'Continue with'}
</h1>
</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' ? (
<div className="w-full">
<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 className="mb-1 h-4 w-16 animate-pulse rounded bg-zinc-800" />
<div className="h-3 w-36 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-white/10" />
</div>
</div>
</div>
) : (
<div className="flex flex-col gap-3">
<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 ? (
<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>

View File

@@ -92,7 +92,7 @@ export function MigrateScreen() {
<div className="flex h-full w-full items-center justify-center">
<div className="mx-auto w-full max-w-md">
<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
</h1>
</div>
@@ -100,15 +100,15 @@ export function MigrateScreen() {
<div className="flex flex-col gap-4">
<div>
<div className="mt-1">
<p className="text-sm text-zinc-400">
<p className="text-sm text-white/50">
You&apos;re using old Lume version which store your private key as
plaintext in database, this is huge security risk.
</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
private key in secure storage.
</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
easy and fast!
</p>
@@ -124,7 +124,7 @@ export function MigrateScreen() {
{...register('password', { required: true })}
type={passwordInput}
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
type="button"
@@ -135,13 +135,13 @@ export function MigrateScreen() {
<EyeOffIcon
width={20}
height={20}
className="text-zinc-500 group-hover:text-zinc-100"
className="text-white/50 group-hover:text-white"
/>
) : (
<EyeOnIcon
width={20}
height={20}
className="text-zinc-500 group-hover:text-zinc-100"
className="text-white/50 group-hover:text-white"
/>
)}
</button>
@@ -154,10 +154,10 @@ export function MigrateScreen() {
<button
type="submit"
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 ? (
<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 →'
)}

View File

@@ -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&apos;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&apos;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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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&apos;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&apos;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>
);
}

View File

@@ -102,74 +102,64 @@ export function ResetScreen() {
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-zinc-100">Reset unlock password</h1>
<div className="mb-6 text-center">
<h1 className="text-2xl font-semibold text-white">Reset unlock password</h1>
</div>
<div className="flex flex-col gap-4">
<form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label htmlFor="privkey" className="font-medium text-zinc-200">
Private key
</label>
<div className="relative">
<input
{...register('privkey', { required: true })}
type="text"
placeholder="nsec..."
className="relative w-full rounded-lg bg-zinc-800 px-3.5 py-3 text-zinc-100 !outline-none placeholder:text-zinc-400"
/>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label htmlFor="privkey" className="font-medium text-white/50">
Private key
</label>
<div className="relative">
<input
{...register('privkey', { required: true })}
type="text"
placeholder="nsec..."
className="relative h-12 w-full rounded-lg bg-white/10 px-3.5 py-1 text-white !outline-none placeholder:text-white/10"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="password" className="font-medium text-zinc-200">
Set a new password to protect your key
</label>
<div className="relative">
<input
{...register('password', { required: true })}
type={passwordInput}
placeholder="min. 4 characters"
className="relative w-full rounded-lg bg-zinc-800 py-3 pl-3.5 pr-11 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 items-center justify-center">
</div>
<div className="flex flex-col gap-1">
<label htmlFor="password" className="font-medium text-white/50">
Set a new password to protect your key
</label>
<div className="relative">
<input
{...register('password', { required: true })}
type={passwordInput}
placeholder="min. 4 characters"
className="relative h-12 w-full rounded-lg bg-white/10 px-3.5 py-1 text-white !outline-none placeholder:text-white/10"
/>
<button
type="submit"
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"
type="button"
onClick={() => showPassword()}
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-white/10"
>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
{passwordInput === 'password' ? (
<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>
</div>
</form>
</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
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>
);

View File

@@ -81,65 +81,53 @@ export function UnlockScreen() {
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-zinc-100">
Enter password to unlock
</h1>
<div className="mb-6 text-center">
<h1 className="text-2xl font-semibold text-white">Enter password to unlock</h1>
</div>
<div className="flex flex-col gap-4">
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<div className="relative">
<input
{...register('password', { required: true })}
type={passwordInput}
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">
<form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col gap-3">
<div className="flex flex-col gap-1">
<div className="relative">
<input
{...register('password', { required: true })}
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"
/>
<button
type="submit"
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"
type="button"
onClick={() => showPassword()}
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-white/10"
>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
{passwordInput === 'password' ? (
<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>
<Link
to="/auth/reset"
className="inline-flex h-12 items-center justify-center text-center text-sm text-zinc-400"
>
Reset password
</Link>
</div>
</form>
</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
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>
);

View File

@@ -1,29 +1,41 @@
import { LogicalSize, appWindow } from '@tauri-apps/plugin-window';
import { useEffect } from 'react';
import { Link } from 'react-router-dom';
import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
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 (
<div className="grid h-full w-full grid-cols-12 gap-4 px-4 py-4">
<div className="col-span-5 flex flex-col rounded-xl border-t border-zinc-800/50 bg-zinc-900">
<div className="flex h-full w-full flex-col justify-center gap-2 px-4 py-4">
<h1 className="text-4xl font-bold leading-none text-transparent text-zinc-700">
Preserve your <span className="text-fuchsia-300">freedom</span>
</h1>
<h2 className="text-4xl font-bold leading-none text-transparent text-zinc-700">
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>
<div className="flex h-screen w-full flex-col justify-between bg-white/10">
<div className="flex flex-col gap-10 pt-16">
<div className="flex flex-col gap-2 text-center">
<h1 className="text-3xl font-medium text-white">Welcome to Lume</h1>
<h3 className="mx-auto w-2/3 text-white/50">
Let&apos;s get you up and connecting with all peoples around the world on
Nostr
</h3>
</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
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>Login with private key</span>
@@ -31,24 +43,15 @@ export function WelcomeScreen() {
</Link>
<Link
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
</Link>
</div>
</div>
<div
className="col-span-5 rounded-xl bg-zinc-900 bg-cover bg-center"
style={{
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 className="flex flex-1 items-end justify-center pb-10">
<img src="/lume.png" alt="lume" className="h-auto w-1/3" />
</div>
</div>
);
}

View File

@@ -18,7 +18,7 @@ export function ChannelBlackList({ blacklist }: { blacklist: any }) {
<MuteIcon
width={16}
height={16}
className="text-zinc-400 group-hover:text-zinc-100"
className="text-white/50 group-hover:text-white"
/>
</Popover.Button>
<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">
Your muted list
</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,
muted list will loaded again
</p>

View File

@@ -93,7 +93,7 @@ export function ChannelCreateModal() {
// close modal
setIsOpen(false);
// redirect to channel page
navigate(`/app/channel/${event.id}`);
navigate(`/channel/${event.id}`);
}, 1000);
} catch (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"
>
<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>
<h5 className="font-medium text-zinc-400">Create channel</h5>
<h5 className="font-medium text-white/50">Create channel</h5>
</div>
</button>
<Transition appear show={isOpen} as={Fragment}>
@@ -147,7 +147,7 @@ export function ChannelCreateModal() {
<div className="flex items-center justify-between">
<Dialog.Title
as="h3"
className="text-lg font-semibold leading-none text-zinc-100"
className="text-lg font-semibold leading-none text-white"
>
Create channel
</Dialog.Title>
@@ -159,7 +159,7 @@ export function ChannelCreateModal() {
<CancelIcon width={20} height={20} className="text-zinc-300" />
</button>
</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
stop you or deceive what to speech
</Dialog.Description>
@@ -174,10 +174,10 @@ export function ChannelCreateModal() {
type={'hidden'}
{...register('picture')}
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">
<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
</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">
@@ -195,7 +195,7 @@ export function ChannelCreateModal() {
<div className="flex flex-col gap-1">
<label
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 *
</label>
@@ -206,28 +206,28 @@ export function ChannelCreateModal() {
minLength: 4,
})}
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 className="flex flex-col gap-1">
<label
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
</label>
<textarea
{...register('about')}
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 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">
<span className="font-semibold leading-none text-zinc-100">
<span className="font-semibold leading-none text-white">
Encrypted
</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
send message
</p>
@@ -248,10 +248,10 @@ export function ChannelCreateModal() {
<button
type="submit"
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 ? (
<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 →'
)}

View File

@@ -7,17 +7,17 @@ export function ChannelsListItem({ data }: { data: any }) {
const channel = useChannelProfile(data.event_id);
return (
<NavLink
to={`/app/channel/${data.event_id}`}
to={`/channel/${data.event_id}`}
preventScrollReset={true}
className={({ isActive }) =>
twMerge(
'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">
<span className="text-xs text-zinc-100">#</span>
<span className="text-xs text-white">#</span>
</div>
<div className="inline-flex w-full items-center justify-between">
<h5 className="truncate font-medium text-zinc-200">{channel?.name}</h5>

View File

@@ -74,7 +74,7 @@ export function ChannelMessageForm({ channelID }: { channelID: string }) {
<div className="flex w-full flex-col">
<UserReply pubkey={replyTo.pubkey} />
<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>
<button
@@ -82,7 +82,7 @@ export function ChannelMessageForm({ channelID }: { channelID: string }) {
onClick={() => stopReply()}
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>
</div>
</div>
@@ -95,10 +95,10 @@ export function ChannelMessageForm({ channelID }: { channelID: string }) {
placeholder="Message"
className={`relative ${
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="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} />
<button
type="button"

View File

@@ -99,7 +99,7 @@ export function MessageHideButton({ id }: { id: string }) {
<CancelIcon width={20} height={20} className="text-zinc-300" />
</button>
</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.
</Dialog.Description>
</div>
@@ -109,14 +109,14 @@ export function MessageHideButton({ id }: { id: string }) {
<button
type="button"
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
</button>
<button
type="button"
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
</button>

View File

@@ -19,7 +19,7 @@ export function ChannelMessageItem({ data }: { data: LumeEvent }) {
<div className="flex flex-col">
<User pubkey={data.pubkey} time={data.created_at} isChat={true} />
<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}
</p>
{Array.isArray(content.images) && content.images.length ? (

View File

@@ -99,7 +99,7 @@ export function MessageMuteButton({ pubkey }: { pubkey: string }) {
<CancelIcon width={20} height={20} className="text-zinc-300" />
</button>
</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.
</Dialog.Description>
</div>
@@ -109,14 +109,14 @@ export function MessageMuteButton({ pubkey }: { pubkey: string }) {
<button
type="button"
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
</button>
<button
type="button"
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
</button>

View File

@@ -13,7 +13,7 @@ export function UserReply({ pubkey }: { pubkey: string }) {
{isError || isLoading ? (
<>
<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"
/>
</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)}
</span>
</>

View File

@@ -12,7 +12,7 @@ export function ChannelMetadata({ id }: { id: string }) {
const noteID = id ? nip19.noteEncode(id) : null;
const copyNoteID = async () => {
const { writeText } = await import('@tauri-apps/api/clipboard');
const { writeText } = await import('@tauri-apps/plugin-clipboard-manager');
if (noteID) {
await writeText(noteID);
}
@@ -32,10 +32,10 @@ export function ChannelMetadata({ id }: { id: string }) {
<div className="inline-flex items-center gap-1">
<h5 className="text-lg font-semibold leading-none">{metadata?.name}</h5>
<button type="button" onClick={() => copyNoteID()}>
<CopyIcon width={14} height={14} className="text-zinc-400" />
<CopyIcon width={14} height={14} className="text-white/50" />
</button>
</div>
<p className="leading-tight text-zinc-400">
<p className="leading-tight text-white/50">
{metadata?.about || (noteID && `${noteID.substring(0, 24)}...`)}
</p>
</div>

View File

@@ -51,10 +51,10 @@ export function MutedItem({ data }: { data: any }) {
/>
</div>
<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'}
</span>
<span className="text-base leading-none text-zinc-400">
<span className="text-base leading-none text-white/50">
{shortenKey(data.content)}
</span>
</div>
@@ -64,7 +64,7 @@ export function MutedItem({ data }: { data: any }) {
<button
type="button"
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
</button>
@@ -72,7 +72,7 @@ export function MutedItem({ data }: { data: any }) {
<button
type="button"
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
</button>

View File

@@ -23,7 +23,7 @@ const Header = (
<div className="w-full border-t border-zinc-800" />
</div>
<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', {
weekday: 'long',
year: 'numeric',
@@ -40,7 +40,7 @@ const Empty = (
<h3 className="text-base font-semibold leading-none text-white">
Nothing to see here yet
</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.
</p>
</div>
@@ -102,7 +102,7 @@ export function ChannelScreen() {
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">Public Channel</h3>
<h3 className="font-semibold text-white">Public Channel</h3>
</div>
<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">

View File

@@ -6,48 +6,45 @@ import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
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);
if (status === 'loading') {
return (
<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="h-2.5 w-2/3 animate-pulse rounded bg-zinc-800" />
<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-white/10" />
<div className="h-2.5 w-2/3 animate-pulse rounded bg-white/10" />
</div>
);
}
return (
<NavLink
to={`/app/chats/${data.sender_pubkey}`}
to={`/chats/${data.sender_pubkey}`}
preventScrollReset={true}
className={({ isActive }) =>
twMerge(
'inline-flex h-9 items-center gap-2.5 rounded-md px-2.5',
isActive ? 'bg-zinc-900/50 text-zinc-100' : ''
'inline-flex h-9 items-center gap-2.5 rounded-md px-2',
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
src={user?.picture || user?.image}
fallback={DEFAULT_AVATAR}
alt={data.sender_pubkey}
className="h-6 w-6 rounded object-cover"
/>
</div>
<div className="inline-flex w-full items-center justify-between">
<div className="inline-flex items-baseline gap-1">
<h5 className="max-w-[10rem] truncate font-medium text-zinc-200">
{user?.nip05 ||
user?.name ||
user?.displayName ||
shortenKey(data.sender_pubkey)}
</h5>
</div>
<Image
src={user?.picture || user?.image}
fallback={DEFAULT_AVATAR}
alt={data.sender_pubkey}
className="h-6 w-6 shrink-0 rounded object-cover"
/>
<div className="inline-flex w-full flex-1 items-center justify-between">
<h5 className="max-w-[10rem] truncate">
{user?.nip05 ||
user?.name ||
user?.display_name ||
displayNpub(data.sender_pubkey, 16)}
</h5>
<div className="flex items-center">
{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">

View File

@@ -23,7 +23,7 @@ export function ChatsList() {
const renderItem = useCallback(
(item: Chats) => {
if (account.pubkey !== item.sender_pubkey) {
if (account?.pubkey !== item.sender_pubkey) {
return <ChatsListItem key={item.sender_pubkey} data={item} />;
}
},
@@ -34,12 +34,12 @@ export function ChatsList() {
return (
<div className="flex flex-col">
<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="h-3 w-full animate-pulse rounded-sm 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-white/10" />
</div>
<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="h-3 w-full animate-pulse rounded-sm 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-white/10" />
</div>
</div>
);
@@ -50,20 +50,20 @@ export function ChatsList() {
{account ? (
<ChatsListSelfItem data={account} />
) : (
<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="h-3 w-full animate-pulse rounded-sm bg-zinc-800" />
<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-white/10" />
<div className="h-3 w-full animate-pulse rounded-sm bg-white/10" />
</div>
)}
{chats.follows.map((item) => renderItem(item))}
{chats.unknowns.length > 0 && <UnknownsModal data={chats.unknowns} />}
<NewMessageModal />
{isFetching && (
<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="h-3 w-full animate-pulse rounded-sm bg-zinc-800" />
<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-white/10" />
<div className="h-3 w-full animate-pulse rounded-sm bg-white/10" />
</div>
)}
{chats.unknowns.length > 0 && <UnknownsModal data={chats.unknowns} />}
<NewMessageModal />
</div>
);
}

View File

@@ -4,7 +4,7 @@ import { useCallback, useState } from 'react';
import { EnterIcon } from '@shared/icons';
import { MediaUploader } from '@shared/mediaUploader';
import { usePublish } from '@utils/hooks/usePublish';
import { useNostr } from '@utils/hooks/useNostr';
export function ChatMessageForm({
receiverPubkey,
@@ -14,7 +14,7 @@ export function ChatMessageForm({
userPubkey: string;
userPrivkey: string;
}) {
const { publish } = usePublish();
const { publish } = useNostr();
const [value, setValue] = useState('');
const encryptMessage = useCallback(async () => {
@@ -51,10 +51,10 @@ export function ChatMessageForm({
onKeyDown={handleEnterPress}
spellCheck={false}
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="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} />
<button
type="button"

View File

@@ -1,9 +1,6 @@
import { useDecryptMessage } from '@app/chats/hooks/useDecryptMessage';
import { MentionNote } from '@shared/notes/mentions/note';
import { ImagePreview } from '@shared/notes/preview/image';
import { LinkPreview } from '@shared/notes/preview/link';
import { VideoPreview } from '@shared/notes/preview/video';
import { NoteContent } from '@shared/notes';
import { User } from '@shared/user';
import { parser } from '@utils/parser';
@@ -22,22 +19,15 @@ export function ChatMessageItem({
if (decryptedContent) {
data['content'] = decryptedContent;
}
// parse the note content
const content = parser(data);
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">
<User pubkey={data.sender_pubkey} time={data.created_at} isChat={true} />
<div className="-mt-[20px] pl-[49px]">
<p className="select-text whitespace-pre-line break-words text-base text-zinc-100">
{content.parsed}
<p className="select-text whitespace-pre-line break-words text-base text-white">
{data.content}
</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>

View File

@@ -1,5 +1,5 @@
import { Dialog, Transition } from '@headlessui/react';
import { Fragment, useState } from 'react';
import * as Dialog from '@radix-ui/react-dialog';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { User } from '@app/auth/components/user';
@@ -10,114 +10,77 @@ import { useAccount } from '@utils/hooks/useAccount';
export function NewMessageModal() {
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false);
const [open, setOpen] = useState(false);
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) => {
closeModal();
navigate(`/app/chats/${pubkey}`);
setOpen(false);
navigate(`/chats/${pubkey}`);
};
return (
<>
<button
type="button"
onClick={() => openModal()}
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">
<PlusIcon className="h-3 w-3 text-zinc-200" />
</div>
<div>
<h5 className="font-medium text-zinc-400">New chat</h5>
</div>
</button>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" 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-lg 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"
>
New chat
</Dialog.Title>
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger asChild>
<button
type="button"
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 bg-white/10">
<PlusIcon className="h-3 w-3 text-white" />
</div>
<div>
<h5 className="text-white/50">New chat</h5>
</div>
</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">
New chat
</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-none text-white/50">
All messages will be encrypted, but anyone can see who you chat
</Dialog.Description>
</div>
</div>
<div className="flex h-[500px] flex-col overflow-y-auto overflow-x-hidden pb-2 pt-2">
{status === 'loading' ? (
<div className="inline-flex items-center justify-center px-4 py-3">
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
</div>
) : (
account?.follows?.map((follow) => (
<div
key={follow}
className="group flex items-center justify-between px-4 py-2 hover:bg-white/10"
>
<User pubkey={follow} />
<div>
<button
type="button"
onClick={closeModal}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-800"
onClick={() => openChat(follow)}
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>
</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 className="flex h-[500px] flex-col overflow-y-auto overflow-x-hidden pb-5">
{status === 'loading' ? (
<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>
</Transition>
</>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -6,46 +6,42 @@ import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
import { shortenKey } from '@utils/shortenKey';
import { displayNpub } from '@utils/shortenKey';
export function ChatsListSelfItem({ data }: { data: { pubkey: string } }) {
const { status, user } = useProfile(data.pubkey);
if (status === 'loading') {
return (
<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>
<div className="h-2.5 w-full animate-pulse truncate rounded bg-zinc-800 text-base font-medium" />
</div>
<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-white/10" />
<div className="h-2.5 w-2/3 animate-pulse rounded bg-white/10" />
</div>
);
}
return (
<NavLink
to={`/app/chats/${data.pubkey}`}
to={`/chats/${data.pubkey}`}
preventScrollReset={true}
className={({ isActive }) =>
twMerge(
'inline-flex h-9 items-center gap-2.5 rounded-md px-2.5',
isActive ? 'bg-zinc-900/50 text-zinc-100' : ''
'inline-flex h-9 items-center gap-2.5 rounded-md px-2',
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
src={user?.picture || user?.image}
fallback={DEFAULT_AVATAR}
alt={data.pubkey}
className="h-6 w-6 rounded bg-white object-cover"
/>
</div>
<Image
src={user?.picture || user?.image}
fallback={DEFAULT_AVATAR}
alt={data.pubkey}
className="h-6 w-6 shrink-0 rounded bg-white object-cover"
/>
<div className="inline-flex items-baseline gap-1">
<h5 className="max-w-[9rem] truncate font-medium text-zinc-200">
{user?.nip05 || user?.name || shortenKey(data.pubkey)}
<h5 className="max-w-[10rem] truncate">
{user?.nip05 || user?.name || displayNpub(data.pubkey, 16)}
</h5>
<span className="text-zinc-500">(you)</span>
<span className="text-white/50">(you)</span>
</div>
</NavLink>
);

View File

@@ -5,7 +5,7 @@ import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
import { shortenKey } from '@utils/shortenKey';
import { displayNpub } from '@utils/shortenKey';
export function ChatSidebar({ pubkey }: { pubkey: string }) {
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-1">
<h3 className="text-lg font-semibold leading-none">
{user?.displayName || user?.name}
{user?.display_name || user?.name}
</h3>
<h5 className="leading-none text-zinc-400">
{user?.nip05 || shortenKey(pubkey)}
<h5 className="leading-none text-white/50">
{user?.nip05 || displayNpub(pubkey, 16)}
</h5>
</div>
<div>
<p className="leading-tight">{user?.bio || user?.about}</p>
<Link
to={`/app/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"
to={`/users/${pubkey}`}
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
</Link>

View File

@@ -1,117 +1,81 @@
import { Dialog, Transition } from '@headlessui/react';
import { Fragment, useState } from 'react';
import * as Dialog from '@radix-ui/react-dialog';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { User } from '@app/auth/components/user';
import { CancelIcon, StrangersIcon } from '@shared/icons';
import { CancelIcon, PlusIcon } from '@shared/icons';
import { compactNumber } from '@utils/number';
import { Chats } from '@utils/types';
export function UnknownsModal({ data }: { data: Chats[] }) {
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false);
const closeModal = () => {
setIsOpen(false);
};
const openModal = () => {
setIsOpen(true);
};
const [open, setOpen] = useState(false);
const openChat = (pubkey: string) => {
closeModal();
navigate(`/app/chats/${pubkey}`);
setOpen(false);
navigate(`/chats/${pubkey}`);
};
return (
<>
<button
type="button"
onClick={() => openModal()}
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">
<StrangersIcon className="h-3 w-3 text-zinc-200" />
</div>
<div>
<h5 className="font-medium text-zinc-400">
{compactNumber.format(data.length)} unknowns
</h5>
</div>
</button>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" 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-lg 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"
>
{data.length} unknowns
</Dialog.Title>
<button
type="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>
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger asChild>
<button
type="button"
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 bg-white/10">
<PlusIcon className="h-3 w-3 text-white" />
</div>
<div>
<h5 className="text-white/50">
{compactNumber.format(data.length)} unknowns
</h5>
</div>
</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">
{data.length} unknowns
</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-none text-white/50">
All messages from people you not follow
</Dialog.Description>
</div>
</div>
<div className="flex h-[500px] flex-col overflow-y-auto overflow-x-hidden pb-2 pt-2">
{data.map((user) => (
<div
key={user.sender_pubkey}
className="group flex items-center justify-between px-4 py-2 hover:bg-white/10"
>
<User pubkey={user.sender_pubkey} />
<div>
<button
type="button"
onClick={() => openChat(user.sender_pubkey)}
className="hidden w-max rounded bg-white/10 px-3 py-1 text-sm font-medium hover:bg-fuchsia-500 group-hover:inline-flex"
>
Chat
</button>
</div>
</div>
<div className="flex h-[500px] flex-col overflow-y-auto overflow-x-hidden pb-5">
{data.map((user) => (
<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>
</Transition>
</>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -1,7 +1,9 @@
import { nip04 } from 'nostr-tools';
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);
useEffect(() => {

View File

@@ -14,6 +14,7 @@ import { createChat, getChatMessages } from '@libs/storage';
import { useStronghold } from '@stores/stronghold';
import { useAccount } from '@utils/hooks/useAccount';
import { Chats } from '@utils/types';
export function ChatScreen() {
const queryClient = useQueryClient();
@@ -34,7 +35,7 @@ export function ChatScreen() {
const userPrivkey = useStronghold((state) => state.privkey);
const itemContent: any = useCallback(
const itemContent = useCallback(
(index: string | number) => {
return (
<ChatMessageItem
@@ -55,7 +56,7 @@ export function ChatScreen() {
);
const chat = useMutation({
mutationFn: (data: any) => {
mutationFn: (data: Chats) => {
return createChat(
data.id,
data.receiver_pubkey,
@@ -100,16 +101,10 @@ export function ChatScreen() {
}, [pubkey]);
return (
<div className="grid h-full w-full grid-cols-3">
<div className="col-span-2 flex flex-col justify-between border-r border-zinc-900">
<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="grid h-full w-full grid-cols-3 bg-white/10">
<div className="col-span-2 border-r border-white/5">
<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">
{status === 'loading' ? (
<p>Loading...</p>
@@ -131,7 +126,7 @@ export function ChatScreen() {
/>
)}
</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
receiverPubkey={pubkey}
userPubkey={account.pubkey}
@@ -141,11 +136,7 @@ export function ChatScreen() {
</div>
</div>
</div>
<div className="col-span-1">
<div
data-tauri-drag-region
className="inline-flex h-11 w-full shrink-0 items-center justify-center border-b border-zinc-900"
/>
<div className="col-span-1 pt-3">
<ChatSidebar pubkey={pubkey} />
</div>
</div>
@@ -155,7 +146,7 @@ export function ChatScreen() {
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">
<h3 className="mb-2 text-4xl">🙌</h3>
<p className="leading-none text-zinc-400">
<p className="leading-none text-white/50">
You two didn&apos;t talk yet, let&apos;s send first message
</p>
</div>

51
src/app/events/index.tsx Normal file
View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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&apos;t close app.
</p>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -23,36 +23,36 @@ export function AccountSettingsScreen() {
return (
<div className="h-full w-full px-3 pt-12">
<div className="flex flex-col gap-2">
<h1 className="text-lg font-semibold text-zinc-100">Account</h1>
<h1 className="text-lg font-semibold text-white">Account</h1>
<div className="">
{status === 'loading' ? (
<p>Loading...</p>
) : (
<div className="flex flex-col gap-4">
<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
</label>
<input
readOnly
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 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
</label>
<input
readOnly
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 className="flex flex-col gap-1">
<label
htmlFor="privkey"
className="text-base font-semibold text-zinc-400"
className="text-base font-semibold text-white/50"
>
Private Key
</label>
@@ -61,7 +61,7 @@ export function AccountSettingsScreen() {
readOnly
type={type}
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
type="button"
@@ -72,13 +72,13 @@ export function AccountSettingsScreen() {
<EyeOffIcon
width={20}
height={20}
className="text-zinc-500 group-hover:text-zinc-100"
className="text-white/50 group-hover:text-white"
/>
) : (
<EyeOnIcon
width={20}
height={20}
className="text-zinc-500 group-hover:text-zinc-100"
className="text-white/50 group-hover:text-white"
/>
)}
</button>

View File

@@ -1,7 +1,7 @@
import { Switch } from '@headlessui/react';
import { disable, enable, isEnabled } from '@tauri-apps/plugin-autostart';
import { useEffect, useState } from 'react';
import { twMerge } from 'tailwind-merge';
import { disable, enable, isEnabled } from 'tauri-plugin-autostart-api';
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="flex flex-col gap-1">
<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>
<Switch
checked={enabled}

View File

@@ -20,7 +20,7 @@ export function CacheTimeSetting() {
<span className="font-medium leading-none text-zinc-200">
Cache time (milliseconds)
</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
</span>
</div>
@@ -37,7 +37,7 @@ export function CacheTimeSetting() {
onClick={() => update()}
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>
</div>
</div>

View File

@@ -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';
const appVersion = await getVersion();
export function VersionSetting() {
const [version, setVersion] = useState<string>('');
useEffect(() => {
async function checkVersion() {
const appVersion = await getVersion();
setVersion(appVersion);
}
checkVersion();
}, []);
return (
<div className="inline-flex items-center justify-between px-5 py-4">
<div className="flex flex-col gap-1">
<span className="font-medium leading-none text-zinc-200">Version</span>
<span className="text-sm leading-none text-zinc-400">
<span className="text-sm leading-none text-white/50">
You&apos;re using latest version
</span>
</div>
<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
type="button"
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>
</div>
</div>

View File

@@ -6,9 +6,9 @@ export function GeneralSettingsScreen() {
return (
<div className="h-full w-full px-3 pt-12">
<div className="flex flex-col gap-2">
<h1 className="text-lg font-semibold text-zinc-100">General</h1>
<div className="w-full rounded-xl border-t border-zinc-800/50 bg-zinc-900">
<div className="flex h-full w-full flex-col divide-y divide-zinc-800">
<h1 className="text-lg font-semibold text-white">General</h1>
<div className="w-full rounded-xl bg-white/10">
<div className="flex h-full w-full flex-col divide-y divide-white/5">
<AutoStartSetting />
<CacheTimeSetting />
<VersionSetting />

View File

@@ -4,81 +4,79 @@ export function ShortcutsSettingsScreen() {
return (
<div className="h-full w-full px-3 pt-12">
<div className="flex flex-col gap-2">
<h1 className="text-lg font-semibold text-zinc-100">Shortcuts</h1>
<div className="w-full rounded-xl border-t border-zinc-800/50 bg-zinc-900">
<div className="flex h-full w-full flex-col divide-y divide-zinc-800">
<h1 className="text-lg font-semibold text-white">Shortcuts</h1>
<div className="w-full rounded-xl bg-white/10">
<div className="flex h-full w-full flex-col divide-y divide-white/5">
<div className="inline-flex items-center justify-between px-5 py-4">
<div className="flex flex-col gap-1">
<span className="font-medium leading-none text-zinc-200">
Open composer
</span>
<span className="font-medium leading-none text-white">Open composer</span>
</div>
<div className="flex items-center gap-2">
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800">
<CommandIcon width={12} height={12} className="text-zinc-500" />
<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/50" />
</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">
<span className="text-sm leading-none text-zinc-500">N</span>
<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/50">N</span>
</div>
</div>
</div>
<div className="inline-flex items-center justify-between px-5 py-4">
<div className="flex flex-col gap-1">
<span className="font-medium leading-none text-zinc-200">
<span className="font-medium leading-none text-white">
Add image block
</span>
</div>
<div className="flex items-center gap-2">
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800">
<CommandIcon width={12} height={12} className="text-zinc-500" />
<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/50" />
</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">
<span className="text-sm leading-none text-zinc-500">I</span>
<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/50">I</span>
</div>
</div>
</div>
<div className="inline-flex items-center justify-between px-5 py-4">
<div className="flex flex-col gap-1">
<span className="font-medium leading-none text-zinc-200">
<span className="font-medium leading-none text-white">
Add newsfeed block
</span>
</div>
<div className="flex items-center gap-2">
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800">
<CommandIcon width={12} height={12} className="text-zinc-500" />
<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/50" />
</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">
<span className="text-sm leading-none text-zinc-500">F</span>
<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/50">F</span>
</div>
</div>
</div>
<div className="inline-flex items-center justify-between px-5 py-4">
<div className="flex flex-col gap-1">
<span className="font-medium leading-none text-zinc-200">
<span className="font-medium leading-none text-white">
Open personal page
</span>
</div>
<div className="flex items-center gap-2">
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800">
<CommandIcon width={12} height={12} className="text-zinc-500" />
<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/50" />
</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">
<span className="text-sm leading-none text-zinc-500">P</span>
<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/50">P</span>
</div>
</div>
</div>
<div className="inline-flex items-center justify-between px-5 py-4">
<div className="flex flex-col gap-1">
<span className="font-medium leading-none text-zinc-200">
<span className="font-medium leading-none text-white">
Open notification
</span>
</div>
<div className="flex items-center gap-2">
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-700/50 bg-zinc-800">
<CommandIcon width={12} height={12} className="text-zinc-500" />
<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/50" />
</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">
<span className="text-sm leading-none text-zinc-500">B</span>
<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/50">B</span>
</div>
</div>
</div>

View File

@@ -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>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -9,11 +9,11 @@ import { NoteKindUnsupport } from '@shared/notes/kinds/unsupport';
import { NoteSkeleton } from '@shared/notes/skeleton';
import { TitleBar } from '@shared/titleBar';
import { Block, LumeEvent } from '@utils/types';
import { LumeEvent, Widget } from '@utils/types';
const ITEM_PER_PAGE = 10;
export function FeedBlock({ params }: { params: Block }) {
export function FeedBlock({ params }: { params: Widget }) {
const { status, data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
queryKey: ['newsfeed', params.content],
@@ -34,6 +34,7 @@ export function FeedBlock({ params }: { params: Block }) {
});
const itemsVirtualizer = rowVirtualizer.getVirtualItems();
const totalSize = rowVirtualizer.getTotalSize();
useEffect(() => {
const [lastItem] = [...rowVirtualizer.getVirtualItems()].reverse();
@@ -113,24 +114,20 @@ export function FeedBlock({ params }: { params: Block }) {
);
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} />
<div
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' }}
>
<div ref={parentRef} className="scrollbar-hide h-full overflow-y-auto pb-20">
{status === 'loading' ? (
<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 />
</div>
</div>
) : itemsVirtualizer.length === 0 ? (
<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">
<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
</p>
</div>
@@ -140,7 +137,7 @@ export function FeedBlock({ params }: { params: Block }) {
<div
className="relative w-full"
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
height: `${totalSize}px`,
}}
>
<div
@@ -157,7 +154,7 @@ export function FeedBlock({ params }: { params: Block }) {
)}
{isFetchingNextPage && (
<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 />
</div>
</div>

View File

@@ -8,17 +8,17 @@ import { NoteKind_1, NoteSkeleton } from '@shared/notes';
import { TitleBar } from '@shared/titleBar';
import { nHoursAgo } from '@utils/date';
import { Block, LumeEvent } from '@utils/types';
import { LumeEvent, Widget } from '@utils/types';
export function HashtagBlock({ params }: { params: Block }) {
const { relayUrls, fetcher } = useNDK();
export function HashtagBlock({ params }: { params: Widget }) {
const { ndk } = useNDK();
const { status, data } = useQuery(['hashtag', params.content], async () => {
const events = (await fetcher.fetchAllEvents(
relayUrls,
{ kinds: [1], '#t': [params.content] },
{ since: nHoursAgo(48) }
)) as unknown as LumeEvent[];
return events;
const events = await ndk.fetchEvents({
kinds: [1],
'#t': [params.content],
since: nHoursAgo(24),
});
return [...events] as unknown as LumeEvent[];
});
const parentRef = useRef();
@@ -29,27 +29,24 @@ export function HashtagBlock({ params }: { params: Block }) {
});
const itemsVirtualizer = rowVirtualizer.getVirtualItems();
const totalSize = rowVirtualizer.getTotalSize();
return (
<div className="w-[400px] shrink-0 border-r border-zinc-900">
<TitleBar id={params.id} title={params.title + ' in 48 hours ago'} />
<div
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' }}
>
<div className="relative w-[400px] shrink-0 bg-white/10">
<TitleBar id={params.id} title={params.title + ' in 24 hours ago'} />
<div ref={parentRef} className="scrollbar-hide h-full overflow-y-auto pb-20">
{status === 'loading' ? (
<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 />
</div>
</div>
) : itemsVirtualizer.length === 0 ? (
<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">
<p className="text-center text-sm text-zinc-300">
No new posts about this hashtag in 48 hours ago
<p className="text-center text-sm font-medium text-white">
No new posts about this hashtag in 24 hours ago
</p>
</div>
</div>
@@ -58,7 +55,7 @@ export function HashtagBlock({ params }: { params: Block }) {
<div
className="relative w-full"
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
height: `${totalSize}px`,
}}
>
<div

View File

@@ -2,22 +2,22 @@ import { CancelIcon } from '@shared/icons';
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useWidgets } from '@stores/widgets';
import { useBlock } from '@utils/hooks/useBlock';
import { Block } from '@utils/types';
import { Widget } from '@utils/types';
export function ImageBlock({ params }: { params: Block }) {
const { remove } = useBlock();
export function ImageBlock({ params }: { params: Widget }) {
const remove = useWidgets((state) => state.removeWidget);
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="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">
<h3 className="font-medium text-white drop-shadow-lg">{params.title}</h3>
<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"
>
<CancelIcon width={16} height={16} className="text-white" />
@@ -28,7 +28,7 @@ export function ImageBlock({ params }: { params: Block }) {
src={params.content}
fallback={DEFAULT_AVATAR}
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>

View File

@@ -16,7 +16,7 @@ import { LumeEvent } from '@utils/types';
const ITEM_PER_PAGE = 10;
export function FollowingBlock() {
export function NetworkBlock() {
// subscribe for live update
useNewsfeed();
@@ -40,9 +40,10 @@ export function FollowingBlock() {
});
const itemsVirtualizer = rowVirtualizer.getVirtualItems();
const totalSize = rowVirtualizer.getTotalSize();
useEffect(() => {
const [lastItem] = [...rowVirtualizer.getVirtualItems()].reverse();
const [lastItem] = [...itemsVirtualizer].reverse();
if (!lastItem) {
return;
@@ -51,7 +52,7 @@ export function FollowingBlock() {
if (lastItem.index >= notes.length - 1 && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [notes.length, fetchNextPage, rowVirtualizer.getVirtualItems()]);
}, [notes.length, fetchNextPage, itemsVirtualizer]);
const renderItem = useCallback(
(index: string | number) => {
@@ -125,30 +126,26 @@ export function FollowingBlock() {
);
return (
<div className="relative w-[400px] shrink-0 border-r border-zinc-900">
<TitleBar title="Your Circle" />
<div
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' }}
>
<div className="relative w-[400px] shrink-0 bg-white/10">
<TitleBar title="Network" />
<div ref={parentRef} className="scrollbar-hide h-full overflow-y-auto pb-20">
{status === 'loading' ? (
<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 />
</div>
</div>
) : itemsVirtualizer.length === 0 ? (
<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">
<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
<br />
Follow more people to have more fun.
</p>
<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"
>
Trending
@@ -160,7 +157,7 @@ export function FollowingBlock() {
<div
className="relative w-full"
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
height: `${totalSize}px`,
}}
>
<div
@@ -177,7 +174,7 @@ export function FollowingBlock() {
)}
{isFetchingNextPage && (
<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 />
</div>
</div>

View File

@@ -12,9 +12,9 @@ import { TitleBar } from '@shared/titleBar';
import { useAccount } from '@utils/hooks/useAccount';
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 { account } = useAccount();
@@ -22,31 +22,35 @@ export function ThreadBlock({ params }: { params: Block }) {
// useLiveThread(params.content);
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} />
<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' ? (
<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 />
</div>
</div>
) : (
<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} />
<div className="mt-2">
<NoteContent content={data.content} />
</div>
<div>
<NoteActions id={data.id} pubkey={data.pubkey} noOpenThread={true} />
<NoteStats id={data.id} />
<NoteActions
id={params.content}
pubkey={data.pubkey}
noOpenThread={true}
/>
<NoteStats id={params.content} />
</div>
</div>
</div>
)}
<div className="px-3">
<NoteReplyForm id={params.content} pubkey={account.pubkey} />
{account && <NoteReplyForm id={params.content} pubkey={account.pubkey} />}
<RepliesList id={params.content} />
</div>
</div>

View File

@@ -9,20 +9,19 @@ import { TitleBar } from '@shared/titleBar';
import { UserProfile } from '@shared/userProfile';
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 { fetcher, relayUrls } = useNDK();
const { ndk } = useNDK();
const { status, data } = useQuery(['user-feed', params.content], async () => {
const events = await fetcher.fetchAllEvents(
relayUrls,
{ kinds: [1], authors: [params.content] },
{ since: nHoursAgo(48) },
{ sort: true }
);
return events as unknown as LumeEvent[];
const events = await ndk.fetchEvents({
kinds: [1],
authors: [params.content],
since: nHoursAgo(48),
});
return [...events] as unknown as LumeEvent[];
});
const rowVirtualizer = useVirtualizer({
@@ -35,32 +34,27 @@ export function UserBlock({ params }: { params: Block }) {
const itemsVirtualizer = rowVirtualizer.getVirtualItems();
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} />
<div
ref={parentRef}
className="scrollbar-hide flex h-full flex-1 flex-col gap-1.5 overflow-y-auto pt-1.5"
>
<div ref={parentRef} className="scrollbar-hide h-full overflow-y-auto pb-20">
<div className="px-3 pt-1.5">
<UserProfile pubkey={params.content} />
</div>
<div>
<h3 className="mt-2 px-3 text-lg font-semibold text-zinc-300">
Latest activities
</h3>
<h3 className="mt-4 px-3 text-lg font-semibold text-white">Latest postrs</h3>
<div className="flex h-full w-full flex-col justify-between gap-1.5 pb-10">
{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">
<div className="rounded-md bg-white/10 px-3 py-3">
<NoteSkeleton />
</div>
</div>
) : itemsVirtualizer.length === 0 ? (
<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">
<p className="text-center text-sm text-zinc-300">
No new posts about this hashtag in 48 hours ago
<p className="text-center text-sm text-white">
No new posts from this user in 48 hours ago
</p>
</div>
</div>

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -4,8 +4,6 @@ import { useEffect, useRef } from 'react';
import { useNDK } from '@libs/ndk/provider';
import { createNote } from '@libs/storage';
import { useNote } from '@stores/note';
import { useAccount } from '@utils/hooks/useAccount';
export function useNewsfeed() {
@@ -15,15 +13,11 @@ export function useNewsfeed() {
const { ndk } = useNDK();
const { status, account } = useAccount();
const toggleHasNewNote = useNote((state) => state.toggleHasNewNote);
useEffect(() => {
if (status === 'success' && account) {
const follows = account ? JSON.parse(account.follows as string) : [];
const filter: NDKFilter = {
kinds: [1, 6],
authors: follows,
authors: account.follows,
since: now.current,
};
@@ -39,8 +33,6 @@ export function useNewsfeed() {
event.content,
event.created_at
);
// notify user about created note
toggleHasNewNote(true);
});
}

View File

@@ -1,93 +1,71 @@
import { useQuery } from '@tanstack/react-query';
import { useCallback } from 'react';
import { useCallback, useEffect } from 'react';
import { AddBlock } from '@app/space/components/add';
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 { ImageBlock } from '@app/space/components/blocks/image';
import { NetworkBlock } from '@app/space/components/blocks/network';
import { ThreadBlock } from '@app/space/components/blocks/thread';
import { UserBlock } from '@app/space/components/blocks/user';
import { getBlocks } from '@libs/storage';
import { FeedModal } from '@app/space/components/modals/feed';
import { HashtagModal } from '@app/space/components/modals/hashtag';
import { ImageModal } from '@app/space/components/modals/image';
import { LoaderIcon } from '@shared/icons';
import { Block } from '@utils/types';
import { useWidgets } from '@stores/widgets';
import { Widget } from '@utils/types';
export function SpaceScreen() {
const {
status,
data: blocks,
isFetching,
} = useQuery(
['blocks'],
async () => {
return await getBlocks();
},
{
staleTime: Infinity,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
}
);
const [widgets, fetchWidgets] = useWidgets((state) => [
state.widgets,
state.fetchWidgets,
]);
const renderBlock = useCallback(
(block: Block) => {
switch (block.kind) {
const renderItem = useCallback(
(widget: Widget) => {
switch (widget.kind) {
case 0:
return <ImageBlock key={block.id} params={block} />;
return <ImageBlock key={widget.id} params={widget} />;
case 1:
return <FeedBlock key={block.id} params={block} />;
return <FeedBlock key={widget.id} params={widget} />;
case 2:
return <ThreadBlock key={block.id} params={block} />;
return <ThreadBlock key={widget.id} params={widget} />;
case 3:
return <HashtagBlock key={block.id} params={block} />;
return <HashtagBlock key={widget.id} params={widget} />;
case 5:
return <UserBlock key={block.id} params={block} />;
return <UserBlock key={widget.id} params={widget} />;
default:
break;
}
},
[blocks]
[widgets]
);
return (
<div className="scrollbar-hide flex h-full w-full flex-nowrap overflow-x-auto overflow-y-hidden">
<FollowingBlock />
{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"
/>
useEffect(() => {
fetchWidgets();
}, [fetchWidgets]);
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">
<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>
) : (
blocks.map((block: Block) => renderBlock(block))
widgets.map((widget) => renderItem(widget))
)}
{isFetching && (
<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"
/>
<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 className="flex w-[350px] shrink-0 flex-col">
<div className="inline-flex h-full w-full flex-col items-center justify-center gap-1">
<FeedModal />
<ImageModal />
<HashtagModal />
</div>
</div>
<div className="w-[350px] shrink-0" />
<div className="w-[250px] shrink-0" />
</div>
);
}

91
src/app/splash.tsx Normal file
View 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&apos;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>
);
}

View File

@@ -1,4 +1,5 @@
import { useQuery } from '@tanstack/react-query';
import { fetch } from '@tauri-apps/plugin-http';
import { useEffect, useState } from 'react';
import { FollowIcon, LoaderIcon, UnfollowIcon } from '@shared/icons';
@@ -11,14 +12,23 @@ import { compactNumber } from '@utils/number';
import { shortenKey } from '@utils/shortenKey';
export function Profile({ data }: { data: any }) {
const { status, data: userStats } = useQuery(['user-stats', data.pubkey], async () => {
const res = await fetch(`https://api.nostr.band/v0/stats/profile/${data.pubkey}`);
return res.json();
});
const { status: socialStatus, userFollows, follow, unfollow } = useSocial();
const { status, data: userStats } = useQuery(
['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 profile = embedProfile;
const { status: socialStatus, userFollows, follow, unfollow } = useSocial();
const [followed, setFollowed] = useState(false);
@@ -50,29 +60,28 @@ export function Profile({ data }: { data: any }) {
}
}, [status]);
if (!profile)
if (!profile) {
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&apos;t fetch profile</p>
</div>
);
}
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="inline-flex items-center gap-2">
<div className="h-11 w-11 shrink-0">
<Image
src={profile.picture}
fallback={DEFAULT_AVATAR}
className="h-11 w-11 rounded-lg object-cover"
/>
</div>
<Image
src={profile.picture}
fallback={DEFAULT_AVATAR}
className="h-11 w-11 shrink-0 rounded-lg object-cover"
/>
<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}
</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)}
</p>
</div>
@@ -81,15 +90,15 @@ export function Profile({ data }: { data: any }) {
{socialStatus === 'loading' ? (
<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>
) : followed ? (
<button
type="button"
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" />
</button>
@@ -97,7 +106,7 @@ export function Profile({ data }: { data: any }) {
<button
type="button"
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" />
</button>
@@ -105,7 +114,7 @@ export function Profile({ data }: { data: any }) {
</div>
</div>
<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}
</p>
</div>
@@ -115,26 +124,26 @@ export function Profile({ data }: { data: any }) {
) : (
<div className="flex w-full items-center gap-8">
<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}
</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 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}
</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 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
? compactNumber.format(
userStats.stats[data.pubkey].zaps_received.msats / 1000
)
: 0}
</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>
)}

View File

@@ -1,33 +1,52 @@
import { useQuery } from '@tanstack/react-query';
import { fetch } from '@tauri-apps/plugin-http';
import { NoteKind_1 } from '@shared/notes';
import { NoteSkeleton } from '@shared/notes/skeleton';
import { TitleBar } from '@shared/titleBar';
import { LumeEvent } from '@utils/types';
interface Response {
notes: Array<{ event: LumeEvent }>;
}
export function TrendingNotes() {
const { status, data, error } = useQuery(['trending-notes'], async () => {
const res = await fetch('https://api.nostr.band/v0/trending/notes');
if (!res.ok) {
throw new Error('Error');
const { status, data, error } = useQuery(
['trending-notes'],
async () => {
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 (
<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" />
<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>}
{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">
<div className="rounded-xl bg-white/10 px-3 py-3">
<NoteSkeleton />
</div>
</div>
) : (
<div className="relative flex w-full flex-col pt-1.5">
{data.notes.map((item) => (
<NoteKind_1 key={item.id} event={item.event} skipMetadata={true} />
<div className="relative flex w-full flex-col">
{data.map((item) => (
<NoteKind_1 key={item.event.id} event={item.event} skipMetadata={true} />
))}
</div>
)}

View File

@@ -1,33 +1,50 @@
import { useQuery } from '@tanstack/react-query';
import { fetch } from '@tauri-apps/plugin-http';
import { Profile } from '@app/trending/components/profile';
import { NoteSkeleton } from '@shared/notes/skeleton';
import { TitleBar } from '@shared/titleBar';
interface Response {
profiles: Array<{ pubkey: string }>;
}
export function TrendingProfiles() {
const { status, data, error } = useQuery(['trending-profiles'], async () => {
const res = await fetch('https://api.nostr.band/v0/trending/profiles');
if (!res.ok) {
throw new Error('Error');
const { status, data, error } = useQuery(
['trending-profiles'],
async () => {
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 (
<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" />
<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>}
{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">
<div className="rounded-xl bg-white/10 px-3 py-3">
<NoteSkeleton />
</div>
</div>
) : (
<div className="relative flex w-full flex-col gap-3 px-3 pt-3">
{data.profiles.map((item) => (
<div className="relative flex w-full flex-col gap-3 px-3 pt-1.5">
{data?.map((item) => (
<Profile key={item.pubkey} data={item} />
))}
</div>

View File

@@ -3,7 +3,7 @@ import { TrendingProfiles } from '@app/trending/components/trendingProfiles';
export function TrendingScreen() {
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 />
<TrendingNotes />
</div>

View File

@@ -12,15 +12,14 @@ import { LumeEvent } from '@utils/types';
export function UserFeed({ pubkey }: { pubkey: string }) {
const parentRef = useRef();
const { fetcher, relayUrls } = useNDK();
const { ndk } = useNDK();
const { status, data } = useQuery(['user-feed', pubkey], async () => {
const events = await fetcher.fetchAllEvents(
relayUrls,
{ kinds: [1], authors: [pubkey] },
{ since: nHoursAgo(48) },
{ sort: true }
);
return events as unknown as LumeEvent[];
const events = await ndk.fetchEvents({
kinds: [1],
authors: [pubkey],
since: nHoursAgo(48),
});
return [...events] as unknown as LumeEvent[];
});
const rowVirtualizer = useVirtualizer({

View File

@@ -18,32 +18,32 @@ export function UserMetadata({ pubkey }: { pubkey: string }) {
return (
<div className="flex w-full items-center gap-10">
<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}
</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 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}
</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 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
? compactNumber.format(data.stats[pubkey].zaps_received.msats / 1000)
: 0}
</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 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
? compactNumber.format(data.stats[pubkey].zaps_sent.msats / 1000)
: 0}
</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>
);

View File

@@ -52,7 +52,7 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
return (
<>
<div className="h-56 w-full bg-zinc-100">
<div className="h-56 w-full bg-white">
<Image
src={user?.banner}
fallback="https://void.cat/d/QY1myro5tkHVs2nY7dy74b.jpg"
@@ -65,7 +65,7 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
src={user?.picture || user?.image}
fallback={DEFAULT_AVATAR}
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="flex items-center gap-16">
@@ -73,7 +73,7 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
<h5 className="text-lg font-semibold leading-none">
{user?.displayName || user?.name || 'No name'}
</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)}
</span>
</div>
@@ -81,7 +81,7 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
{status === 'loading' ? (
<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...
</button>
@@ -89,7 +89,7 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
<button
type="button"
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
</button>
@@ -97,23 +97,23 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
<button
type="button"
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
</button>
)}
<Link
to={`/app/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"
to={`/chats/${pubkey}`}
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
</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 />}
</div>
</div>
<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}
</p>
<UserMetadata pubkey={pubkey} />

View File

@@ -16,15 +16,14 @@ export function UserScreen() {
const parentRef = useRef();
const { pubkey } = useParams();
const { fetcher, relayUrls } = useNDK();
const { ndk } = useNDK();
const { status, data } = useQuery(['user-feed', pubkey], async () => {
const events = await fetcher.fetchAllEvents(
relayUrls,
{ kinds: [1], authors: [pubkey] },
{ since: nHoursAgo(48) },
{ sort: true }
);
return events as unknown as LumeEvent[];
const events = await ndk.fetchEvents({
kinds: [1],
authors: [pubkey],
since: nHoursAgo(48),
});
return [...events] as unknown as LumeEvent[];
});
const rowVirtualizer = useVirtualizer({
@@ -36,30 +35,30 @@ export function UserScreen() {
const itemsVirtualizer = rowVirtualizer.getVirtualItems();
return (
<div ref={parentRef} className="scrollbar-hide h-full w-full overflow-y-auto">
<div
data-tauri-drag-region
className="flex h-11 w-full items-center border-b border-zinc-900 px-3"
/>
<div
ref={parentRef}
className="scrollbar-hide relative h-full w-full overflow-y-auto bg-white/10"
>
<div data-tauri-drag-region className="absolute left-0 top-0 h-11 w-full" />
<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">
<p className="text-lg font-semibold leading-none text-zinc-200">Latest posts</p>
<span className="text-sm leading-none text-zinc-500">48 hours ago</span>
<p className="text-lg font-semibold leading-none text-white">Latest posts</p>
<span className="text-sm leading-none text-white/50">48 hours ago</span>
</div>
<div className="flex h-full max-w-[400px] flex-col justify-between gap-1.5 pb-4 pt-1.5">
{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">
<div className="shadow-input rounded-xl bg-white/10">
<NoteSkeleton />
</div>
</div>
) : itemsVirtualizer.length === 0 ? (
<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">
<p className="text-center text-sm text-zinc-300">
No new posts about this hashtag in 48 hours ago
<p className="text-center text-sm font-medium text-zinc-300">
No new posts in 48 hours ago
</p>
</div>
</div>

View File

@@ -15,11 +15,11 @@ button {
}
.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 {
@apply text-zinc-500;
@apply text-white/50;
content: attr(data-placeholder);
float: left;
height: 0;

57
src/libs/ndk/cache.tsx Normal file
View 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();
}
}

View File

@@ -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 { ndkAdapter } from '@nostr-fetch/adapter-ndk';
import { NostrFetcher, normalizeRelayUrlSet } from 'nostr-fetch';
import { useEffect, useState } from 'react';
import { fetch } from '@tauri-apps/plugin-http';
import { useEffect, useMemo, useState } from 'react';
import { getSetting } from '@libs/storage';
import TauriAdapter from '@libs/ndk/cache';
import { getExplicitRelayUrls } from '@libs/storage';
const setting = await getSetting('relays');
const relays = normalizeRelayUrlSet(JSON.parse(setting));
import { FULL_RELAYS } from '@stores/constants';
export const NDKInstance = () => {
const [ndk, setNDK] = useState<NDK | undefined>(undefined);
const [relayUrls, setRelayUrls] = useState<string[]>(relays);
const [fetcher, setFetcher] = useState<NostrFetcher>(undefined);
const [relayUrls, setRelayUrls] = useState<string[]>([]);
useEffect(() => {
loadNdk(relays);
}, []);
const cacheAdapter = useMemo(() => new TauriAdapter(), []);
async function loadNdk(explicitRelayUrls: string[]) {
const ndkInstance = new NDK({ explicitRelayUrls });
// TODO: fully support NIP-11
async function verifyRelays(relays: string[]) {
const verifiedRelays: string[] = [];
try {
await ndkInstance.connect();
} catch (error) {
console.error('ERROR loading NDK NDKInstance', error);
for (const relay of relays) {
let url: string;
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);
setRelayUrls(explicitRelayUrls);
setFetcher(NostrFetcher.withCustomPool(ndkAdapter(ndkInstance)));
return verifiedRelays;
}
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 {
ndk,
relayUrls,
fetcher,
loadNdk,
};
};

View File

@@ -1,6 +1,5 @@
// source: https://github.com/nostr-dev-kit/ndk-react/
import NDK from '@nostr-dev-kit/ndk';
import { NostrFetcher } from 'nostr-fetch';
import { PropsWithChildren, createContext, useContext } from 'react';
import { NDKInstance } from '@libs/ndk/instance';
@@ -8,33 +7,26 @@ import { NDKInstance } from '@libs/ndk/instance';
interface NDKContext {
ndk: NDK;
relayUrls: string[];
fetcher: NostrFetcher;
loadNdk: (_: string[]) => void;
}
const NDKContext = createContext<NDKContext>({
ndk: new NDK({}),
relayUrls: [],
fetcher: undefined,
loadNdk: undefined,
});
const NDKProvider = ({ children }: PropsWithChildren<object>) => {
const { ndk, relayUrls, fetcher, loadNdk } = NDKInstance();
const { ndk, relayUrls } = NDKInstance();
if (ndk)
return (
<NDKContext.Provider
value={{
ndk,
relayUrls,
fetcher,
loadNdk,
}}
>
{children}
</NDKContext.Provider>
);
return (
<NDKContext.Provider
value={{
ndk,
relayUrls,
}}
>
{children}
</NDKContext.Provider>
);
};
const useNDK = () => {

View File

@@ -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 { OPENGRAPH } from '@stores/constants';
@@ -19,7 +19,13 @@ interface IPreFetchedResource {
imagesPropertyType?: string;
proxyUrl?: 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) {
@@ -28,42 +34,42 @@ function metaTag(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) {
let title =
metaTagContent(doc, 'og:title', 'property') ||
metaTagContent(doc, 'og:title', 'name');
metaTagContent(doc, `og:title`, `property`) ||
metaTagContent(doc, `og:title`, `name`);
if (!title) {
title = doc('title').text();
title = doc(`title`).text();
}
return title;
}
function getSiteName(doc: cheerio.CheerioAPI) {
const siteName =
metaTagContent(doc, 'og:site_name', 'property') ||
metaTagContent(doc, 'og:site_name', 'name');
metaTagContent(doc, `og:site_name`, `property`) ||
metaTagContent(doc, `og:site_name`, `name`);
return siteName;
}
function getDescription(doc: cheerio.CheerioAPI) {
const description =
metaTagContent(doc, 'description', 'name') ||
metaTagContent(doc, 'Description', 'name') ||
metaTagContent(doc, 'og:description', 'property');
metaTagContent(doc, `description`, `name`) ||
metaTagContent(doc, `Description`, `name`) ||
metaTagContent(doc, `og:description`, `property`);
return description;
}
function getMediaType(doc: cheerio.CheerioAPI) {
const node = metaTag(doc, 'medium', 'name');
const node = metaTag(doc, `medium`, `name`);
if (node) {
const content = node.attr('content');
return content === 'image' ? 'photo' : content;
const content = node.attr(`content`);
return content === `image` ? `photo` : content;
}
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 dic: Record<string, boolean> = {};
const imagePropertyType = imagesPropertyType ?? 'og';
const imagePropertyType = imagesPropertyType ?? `og`;
nodes =
metaTag(doc, `${imagePropertyType}:image`, 'property') ||
metaTag(doc, `${imagePropertyType}:image`, 'name');
metaTag(doc, `${imagePropertyType}:image`, `property`) ||
metaTag(doc, `${imagePropertyType}:image`, `name`);
if (nodes) {
nodes.each((_: number, node: cheerio.Element) => {
if (node.type === 'tag') {
if (node.type === `tag`) {
src = node.attribs.content;
if (src) {
src = new URL(src, rootUrl).href;
@@ -95,18 +101,18 @@ function getImages(
}
if (images.length <= 0 && !imagesPropertyType) {
src = doc('link[rel=image_src]').attr('href');
src = doc(`link[rel=image_src]`).attr(`href`);
if (src) {
src = new URL(src, rootUrl).href;
images = [src];
} else {
nodes = doc('img');
nodes = doc(`img`);
if (nodes?.length) {
dic = {};
images = [];
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]) {
dic[src] = true;
// width = node.attribs.width;
@@ -135,32 +141,32 @@ function getVideos(doc: cheerio.CheerioAPI) {
let videoObj;
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) {
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 =
metaTag(doc, 'og:video:secure_url', 'property') ||
metaTag(doc, 'og:video:secure_url', 'name');
metaTag(doc, `og:video:secure_url`, `property`) ||
metaTag(doc, `og:video:secure_url`, `name`);
width =
metaTagContent(doc, 'og:video:width', 'property') ||
metaTagContent(doc, 'og:video:width', 'name');
metaTagContent(doc, `og:video:width`, `property`) ||
metaTagContent(doc, `og:video:width`, `name`);
height =
metaTagContent(doc, 'og:video:height', 'property') ||
metaTagContent(doc, 'og:video:height', 'name');
metaTagContent(doc, `og:video:height`, `property`) ||
metaTagContent(doc, `og:video:height`, `name`);
for (index = 0; index < nodes.length; index += 1) {
const node = nodes[index];
if (node.type === 'tag') video = node.attribs.content;
if (node.type === `tag`) video = node.attribs.content;
nodeType = nodeTypes?.[index];
if (nodeType?.type === 'tag') {
if (nodeType?.type === `tag`) {
videoType = nodeType ? nodeType.attribs.content : null;
}
nodeSecureUrl = nodeSecureUrls?.[index];
if (nodeSecureUrl?.type === 'tag') {
if (nodeSecureUrl?.type === `tag`) {
videoSecureUrl = nodeSecureUrl ? nodeSecureUrl.attribs.content : null;
}
@@ -171,7 +177,7 @@ function getVideos(doc: cheerio.CheerioAPI) {
width,
height,
};
if (videoType && videoType.indexOf('video/') === 0) {
if (videoType && videoType.indexOf(`video/`) === 0) {
videos.splice(0, 0, videoObj);
} else {
videos.push(videoObj);
@@ -193,7 +199,7 @@ function getFavicons(doc: cheerio.CheerioAPI, rootUrl: string) {
let nodes: cheerio.Cheerio<cheerio.Element> | never[] = [];
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) => {
// look for all icon tags
@@ -202,9 +208,9 @@ function getFavicons(doc: cheerio.CheerioAPI, rootUrl: string) {
// collect all images from icon tags
if (nodes.length) {
nodes.each((_: number, node: cheerio.Element) => {
if (node.type === 'tag') src = node.attribs.href;
if (node.type === `tag`) src = node.attribs.href;
if (src) {
src = new URL(rootUrl).href;
src = new URL(src, rootUrl).href;
images.push(src);
}
});
@@ -222,7 +228,7 @@ function getFavicons(doc: cheerio.CheerioAPI, rootUrl: string) {
function parseImageResponse(url: string, contentType: string) {
return {
url,
mediaType: 'image',
mediaType: `image`,
contentType,
favicons: [getDefaultFavicon(url)],
};
@@ -231,7 +237,7 @@ function parseImageResponse(url: string, contentType: string) {
function parseAudioResponse(url: string, contentType: string) {
return {
url,
mediaType: 'audio',
mediaType: `audio`,
contentType,
favicons: [getDefaultFavicon(url)],
};
@@ -240,7 +246,7 @@ function parseAudioResponse(url: string, contentType: string) {
function parseVideoResponse(url: string, contentType: string) {
return {
url,
mediaType: 'video',
mediaType: `video`,
contentType,
favicons: [getDefaultFavicon(url)],
};
@@ -249,7 +255,7 @@ function parseVideoResponse(url: string, contentType: string) {
function parseApplicationResponse(url: string, contentType: string) {
return {
url,
mediaType: 'application',
mediaType: `application`,
contentType,
favicons: [getDefaultFavicon(url)],
};
@@ -268,7 +274,7 @@ function parseTextResponse(
title: getTitle(doc),
siteName: getSiteName(doc),
description: getDescription(doc),
mediaType: getMediaType(doc) || 'website',
mediaType: getMediaType(doc) || `website`,
contentType,
images: getImages(doc, url, options.imagesPropertyType),
videos: getVideos(doc),
@@ -287,11 +293,11 @@ function parseUnknownResponse(
function parseResponse(response: IPreFetchedResource, options?: ILinkPreviewOptions) {
try {
let contentType = response.headers['content-type'];
let contentType = response.headers[`content-type`];
// console.warn(`original content type`, contentType);
if (contentType?.indexOf(';')) {
if (contentType?.indexOf(`;`)) {
// eslint-disable-next-line prefer-destructuring
contentType = contentType.split(';')[0];
contentType = contentType.split(`;`)[0];
// 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;
const options: FetchOptions = {
method: 'GET',
timeout: 5,
responseType: ResponseType.Text,
};
let response = await fetch(fetchUrl, options);
if (response.status > 300 && response.status < 309) {
const forwardedUrl = response.headers.location || '';
response = await fetch(forwardedUrl, options);
/**
* Parses the text, extracts the first link it finds and does a HTTP request
* to fetch the website content, afterwards it tries to parse the internal HTML
* and extract the information via meta tags
* @param text string, text to be parsed
* @param options ILinkPreviewOptions
*/
export async function getLinkPreview(text: string, options?: ILinkPreviewOptions) {
if (!text || typeof text !== `string`) {
throw new Error(`link-preview-js did not receive a valid url or text`);
}
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);
}

View File

@@ -1,9 +1,17 @@
import Database from '@tauri-apps/plugin-sql';
import destr from 'destr';
import Database from 'tauri-plugin-sql-api';
import { parser } from '@utils/parser';
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;
@@ -24,6 +32,12 @@ export async function getActiveAccount() {
'SELECT * FROM accounts WHERE is_active = 1;'
);
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];
} else {
return null;
@@ -52,7 +66,7 @@ export async function createAccount(
[npub, pubkey, 'privkey is stored in secure storage', follows || '', is_active || 0]
);
if (res) {
await createBlock(
await createWidget(
0,
'Have fun together!',
'https://void.cat/d/N5KUHEQCVg7SywXUPiJ7yq.jpg'
@@ -63,15 +77,12 @@ export async function createAccount(
}
// update account
export async function updateAccount(
column: string,
value: string | string[],
pubkey: string
) {
export async function updateAccount(column: string, value: string | string[]) {
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,
pubkey,
account.id,
]);
}
@@ -305,8 +316,6 @@ export async function getChannelUsers(channel_id: string) {
export async function getChats() {
const db = await connect();
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 } = {
follows: [],
@@ -321,7 +330,7 @@ export async function getChats() {
result = result.sort((a, b) => a.new_messages - b.new_messages);
chats.follows = result.filter((el) => {
return follows.some((i) => {
return account.follows.some((i) => {
return i === el.sender_pubkey;
});
});
@@ -408,34 +417,43 @@ export async function updateLastLogin(value: number) {
);
}
// get all blocks
export async function getBlocks() {
// get all widgets
export async function getWidgets() {
const db = await connect();
const account = await getActiveAccount();
const result: Array<Block> = await db.select(
`SELECT * FROM blocks WHERE account_id = "${account.id}" ORDER BY created_at DESC;`
const result: Array<Widget> = await db.select(
`SELECT * FROM widgets WHERE account_id = "${account.id}" ORDER BY created_at DESC;`
);
return result;
}
// create block
export async function createBlock(
export async function createWidget(
kind: number,
title: string,
content: string | string[]
) {
const db = await connect();
const activeAccount = await getActiveAccount();
return await db.execute(
'INSERT OR IGNORE INTO blocks (account_id, kind, title, content) VALUES (?, ?, ?, ?);',
const insert = await db.execute(
'INSERT OR IGNORE INTO widgets (account_id, kind, title, content) VALUES (?, ?, ?, ?);',
[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
export async function removeBlock(id: string) {
export async function removeWidget(id: string) {
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
@@ -444,8 +462,7 @@ export async function removeAll() {
await db.execute(`UPDATE settings SET value = "0" WHERE key = "last_login";`);
await db.execute('DELETE FROM replies;');
await db.execute('DELETE FROM notes;');
await db.execute('DELETE FROM blacklist;');
await db.execute('DELETE FROM blocks;');
await db.execute('DELETE FROM widgets;');
await db.execute('DELETE FROM chats;');
await db.execute('DELETE FROM accounts;');
return true;
@@ -497,3 +514,44 @@ export async function removePrivkey() {
`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