Compare commits

..

5 Commits

Author SHA1 Message Date
Ren Amamiya
1041e1ccd4 Merge pull request #50 from luminous-devs/main
v1.0.1
2023-07-10 14:40:17 +07:00
Ren Amamiya
2eeb2c896d Merge pull request #45 from luminous-devs/main
rebuild with code signing
2023-07-07 12:24:08 +07:00
Ren Amamiya
caf8fb584a Merge pull request #33 from luminous-devs/main
v1.0.0
2023-07-06 08:18:01 +07:00
Ren Amamiya
58205713ab Merge pull request #32 from luminous-devs/main
test github action again
2023-07-05 17:40:35 +07:00
Ren Amamiya
33802d32f3 Merge pull request #31 from luminous-devs/main
test github action
2023-07-05 09:50:57 +07:00
312 changed files with 11497 additions and 14745 deletions

View File

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

View File

@@ -1,5 +1,8 @@
name: 'Publish'
on: workflow_dispatch
name: 'publish'
on:
push:
branches:
- release
env:
CARGO_INCREMENTAL: 0
@@ -14,10 +17,16 @@ jobs:
settings:
- platform: 'macos-latest'
args: '--target universal-apple-darwin'
- platform: 'ubuntu-22.04'
- platform: 'macos-latest'
args: '--target x86_64-apple-darwin'
- platform: 'macos-latest'
args: '--target aarch64-apple-darwin'
- platform: 'ubuntu-20.04'
args: ''
- platform: 'windows-latest'
args: '--target x86_64-pc-windows-msvc'
- platform: 'windows-latest'
args: '--target i686-pc-windows-msvc'
runs-on: ${{ matrix.settings.platform }}
steps:
- uses: actions/checkout@v3
@@ -29,14 +38,14 @@ jobs:
with:
targets: aarch64-apple-darwin
- name: install dependencies (ubuntu only)
if: matrix.settings.platform == 'ubuntu-22.04'
if: matrix.settings.platform == 'ubuntu-20.04'
run: |
sudo apt-get update
sudo apt-get install -y build-essential libssl-dev libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf
sudo apt-get install -y libgtk-3-dev webkit2gtk-4.0 libappindicator3-dev librsvg2-dev patchelf
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 8.x.x
version: 7.x.x
run_install: false
- name: Setup node and cache for package data
uses: actions/setup-node@v3
@@ -60,10 +69,9 @@ jobs:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
with:
tagName: v__VERSION__
tagName: v__VERSION__ # the action automatically replaces \_\_VERSION\_\_ with the app version
releaseName: 'App v__VERSION__'
releaseBody: 'See the assets to download this version and install.'
releaseDraft: true
prerelease: false
args: ${{ matrix.settings.args }}
includeDebug: true

View File

@@ -4,7 +4,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lume</title>
</head>
<body class="relative cursor-default select-none overflow-hidden font-sans antialiased h-screen w-screen text-white">
<body class="cursor-default select-none overflow-hidden font-sans antialiased h-screen w-screen dark:bg-black dark:text-zinc-100">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>

View File

@@ -1,8 +1,7 @@
{
"name": "lume",
"description": "the communication app",
"private": true,
"version": "1.2.4",
"version": "1.0.1",
"scripts": {
"dev": "vite",
"build": "vite build",
@@ -10,94 +9,76 @@
"add-migrate": "cd src-tauri/ && sqlx migrate add",
"prepare": "husky install",
"lint": "eslint ./src --fix",
"format": "prettier ./src --write",
"dep-update": "pnpm update && cd src-tauri/ && cargo update"
"format": "prettier ./src --write"
},
"lint-staged": {
"**/*.{ts, tsx}": "eslint --fix",
"**/*.{ts, tsx, css, md, html, json}": "prettier --cache --write"
},
"dependencies": {
"@ctrl/magnet-link": "^3.1.2",
"@getalby/sdk": "^2.4.0",
"@nostr-dev-kit/ndk": "^1.0.0",
"@nostr-fetch/adapter-ndk": "^0.12.2",
"@radix-ui/react-alert-dialog": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.0.5",
"@headlessui/react": "^1.7.15",
"@nostr-dev-kit/ndk": "^0.7.5",
"@radix-ui/react-popover": "^1.0.6",
"@radix-ui/react-tooltip": "^1.0.6",
"@tanstack/react-query": "^4.35.0",
"@tanstack/react-query": "^4.29.19",
"@tanstack/react-query-devtools": "^4.29.19",
"@tanstack/react-virtual": "3.0.0-beta.54",
"@tauri-apps/api": "^1.4.0",
"@tiptap/extension-image": "^2.1.8",
"@tiptap/extension-mention": "^2.1.8",
"@tiptap/extension-placeholder": "^2.1.8",
"@tiptap/pm": "^2.1.8",
"@tiptap/react": "^2.1.8",
"@tiptap/starter-kit": "^2.1.8",
"@tiptap/suggestion": "^2.1.8",
"@void-cat/api": "^1.0.7",
"cheerio": "1.0.0-rc.12",
"dayjs": "^1.11.9",
"destr": "^2.0.1",
"get-urls": "^12.1.0",
"html-to-text": "^9.0.5",
"destr": "^1.2.2",
"framer-motion": "^10.12.18",
"get-urls": "^11.0.0",
"immer": "^10.0.2",
"light-bolt11-decoder": "^3.0.0",
"lru-cache": "^10.0.1",
"minidenticons": "^4.2.0",
"nostr-fetch": "^0.13.0",
"nostr-tools": "^1.14.2",
"qrcode.react": "^3.1.0",
"nostr-tools": "^1.12.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.46.1",
"react-hook-form": "^7.45.1",
"react-hotkeys-hook": "^4.4.1",
"react-markdown": "^8.0.7",
"react-player": "^2.13.0",
"react-router-dom": "^6.15.0",
"react-textarea-autosize": "^8.5.3",
"react-virtuoso": "^4.5.1",
"remark-gfm": "^3.0.1",
"tauri-plugin-sql-api": "github:tauri-apps/tauri-plugin-sql#v1",
"tauri-plugin-store-api": "github:tauri-apps/tauri-plugin-store#v1",
"react-player": "^2.12.0",
"react-router-dom": "^6.14.1",
"react-string-replace": "^1.1.1",
"react-virtuoso": "^4.3.11",
"slate": "^0.94.1",
"slate-history": "^0.93.0",
"slate-react": "^0.94.2",
"tailwind-merge": "^1.13.2",
"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.4.1"
"zustand": "^4.3.9"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.10",
"@tailwindcss/typography": "^0.5.9",
"@tauri-apps/cli": "^1.4.0",
"@trivago/prettier-plugin-sort-imports": "^4.2.0",
"@types/html-to-text": "^9.0.1",
"@types/node": "^20.5.9",
"@types/react": "^18.2.21",
"@types/react-dom": "^18.2.7",
"@trivago/prettier-plugin-sort-imports": "^4.1.1",
"@types/node": "^18.16.19",
"@types/react": "^18.2.14",
"@types/react-dom": "^18.2.6",
"@types/youtube-player": "^5.5.7",
"@typescript-eslint/eslint-plugin": "^6.6.0",
"@typescript-eslint/parser": "^6.6.0",
"@typescript-eslint/eslint-plugin": "^5.61.0",
"@typescript-eslint/parser": "^5.61.0",
"@vitejs/plugin-react-swc": "^3.3.2",
"autoprefixer": "^10.4.15",
"clsx": "^2.0.0",
"autoprefixer": "^10.4.14",
"cross-env": "^7.0.3",
"csstype": "^3.1.2",
"encoding": "^0.1.13",
"eslint": "^8.48.0",
"eslint-config-prettier": "^9.0.0",
"eslint": "^8.44.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-simple-import-sort": "^10.0.0",
"husky": "^8.0.3",
"lint-staged": "^14.0.1",
"postcss": "^8.4.29",
"prettier": "^3.0.3",
"prettier-plugin-tailwindcss": "^0.5.4",
"lint-staged": "^13.2.3",
"postcss": "^8.4.25",
"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": "^5.2.2",
"vite": "^4.4.9",
"tailwindcss": "^3.3.2",
"typescript": "^4.9.5",
"vite": "^4.4.2",
"vite-plugin-top-level-await": "^1.3.1",
"vite-tsconfig-paths": "^4.2.0"
}
}

4717
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 986 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 341 KiB

1193
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,55 +1,37 @@
[package]
name = "lume"
version = "1.2.4"
description = "the communication app"
version = "1.0.1"
description = "nostr client"
authors = ["Ren Amamiya"]
license = "GPL-3.0"
repository = "https://github.com/luminous-devs/lume"
license = ""
repository = ""
edition = "2021"
rust-version = "1.66"
rust-version = "1.57"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies]
tauri-build = { version = "1.4", features = [] }
tauri-build = { version = "1.2", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
tauri = { version = "1.4", features = [
"fs-read-dir",
"fs-read-file",
"window-start-dragging",
"path-all",
"http-all",
"clipboard-write-text",
"os-all",
"notification-all",
"clipboard-read-text",
"window-set-resizable",
"window-set-size",
"shell-open",
"fs-write-file",
"app-all",
"fs-remove-file",
"window-center",
"dialog-all",
"http-multipart",
] }
tauri-plugin-sql = { git = "hhttps://github.com/tauri-apps/plugins-workspace", branch = "v1", features = [
"sqlite",
] }
tauri = { version = "1.2", features = [ "path-all", "fs-read-dir", "fs-read-file", "clipboard-read-text", "clipboard-write-text", "dialog-open", "http-all", "http-multipart", "notification-all", "os-all", "process-relaunch", "shell-open", "system-tray", "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-store = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
tauri-plugin-upload = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
window-vibrancy = { git = "https://github.com/tauri-apps/window-vibrancy", branch = "dev" }
sqlx-cli = { version = "0.7.0", default-features = false, features = [
"sqlite",
] }
sqlx-cli = {version = "0.7.0", default-features = false, features = ["sqlite"] }
rust-argon2 = "1.0"
webpage = { version = "1.6.0", features = ["serde"] }
rand = "0.8.5"
[dependencies.tauri-plugin-sql]
git = "https://github.com/tauri-apps/plugins-workspace"
branch = "v1"
features = ["sqlite"]
[target.'cfg(target_os = "macos")'.dependencies]
objc = "0.2.7"
cocoa = "0.24.1"
[features]
# by default Tauri runs in production mode

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 731 B

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 709 B

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 921 B

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 447 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 133 KiB

View File

@@ -1,6 +0,0 @@
-- Add migration script here
DROP TABLE IF EXISTS blacklist;
DROP TABLE IF EXISTS channel_messages;
DROP TABLE IF EXISTS channels;

View File

@@ -1,6 +0,0 @@
-- Add migration script here
UPDATE settings
SET
value = '["wss://relayable.org","wss://relay.damus.io","wss://relay.nostr.band/all","wss://nostr.mutinywallet.com"]'
WHERE
key = 'relays';

View File

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

View File

@@ -1,10 +0,0 @@
-- 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

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

View File

@@ -1,13 +0,0 @@
-- Add migration script here
CREATE TABLE
events (
id TEXT NOT NULL PRIMARY KEY,
account_id INTEGER NOT NULL,
event TEXT NOT NULL,
author TEXT NOT NULL,
kind NUMBER NOT NULL DEFAULt 1,
root_id TEXT,
reply_id TEXT,
created_at INTEGER NOT NULL,
FOREIGN KEY (account_id) REFERENCES accounts (id)
);

View File

@@ -1,8 +0,0 @@
-- Add migration script here
DROP TABLE IF EXISTS notes;
DROP TABLE IF EXISTS chats;
DROP TABLE IF EXISTS metadata;
DROP TABLE IF EXISTS replies;

View File

@@ -1,3 +0,0 @@
-- Add migration script here
ALTER TABLE accounts
ADD COLUMN last_login_at NUMBER NOT NULL DEFAULT 0;

View File

@@ -3,14 +3,19 @@
windows_subsystem = "windows"
)]
use tauri::Manager;
#[cfg(target_os = "macos")]
#[macro_use]
extern crate objc;
// use rand::distributions::{Alphanumeric, DistString};
use tauri::{Manager, WindowEvent};
use tauri_plugin_autostart::MacosLauncher;
use tauri_plugin_sql::{Migration, MigrationKind};
use webpage::{Webpage, WebpageOptions};
use std::time::Duration;
#[cfg(target_os = "macos")]
use window_vibrancy::{apply_vibrancy, NSVisualEffectMaterial};
use window_ext::WindowExt;
#[cfg(target_os = "macos")]
mod window_ext;
#[derive(Clone, serde::Serialize)]
struct Payload {
@@ -18,92 +23,31 @@ struct Payload {
cwd: String,
}
#[derive(serde::Serialize)]
struct OpenGraphResponse {
title: String,
description: String,
url: String,
image: String,
}
async fn fetch_opengraph(url: String) -> OpenGraphResponse {
let options = WebpageOptions {
allow_insecure: false,
max_redirections: 3,
timeout: Duration::from_secs(15),
useragent: "lume - desktop app".to_string(),
..Default::default()
};
let result = match Webpage::from_url(&url, options) {
Ok(webpage) => webpage,
Err(_) => {
return OpenGraphResponse {
title: "".to_string(),
description: "".to_string(),
url: "".to_string(),
image: "".to_string(),
}
}
};
let html = result.html;
return OpenGraphResponse {
title: html
.opengraph
.properties
.get("title")
.cloned()
.unwrap_or_default(),
description: html
.opengraph
.properties
.get("description")
.cloned()
.unwrap_or_default(),
url: html
.opengraph
.properties
.get("url")
.cloned()
.unwrap_or_default(),
image: html
.opengraph
.images
.get(0)
.and_then(|i| Some(i.url.clone()))
.unwrap_or_default(),
};
}
#[tauri::command]
async fn opengraph(url: String) -> OpenGraphResponse {
let result = fetch_opengraph(url).await;
return result;
}
#[tauri::command]
async fn close_splashscreen(window: tauri::Window) {
// Close splashscreen
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| {
let window = app.get_window("main").unwrap();
#[cfg(target_os = "macos")]
let main_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");
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(
@@ -163,54 +107,6 @@ fn main() {
sql: include_str!("../migrations/20230619082415_add_replies.sql"),
kind: MigrationKind::Up,
},
Migration {
version: 20230718072634,
description: "clean up",
sql: include_str!("../migrations/20230718072634_clean_up_old_tables.sql"),
kind: MigrationKind::Up,
},
Migration {
version: 20230725010250,
description: "update default relays",
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,
},
Migration {
version: 20230814083543,
description: "add events",
sql: include_str!("../migrations/20230814083543_add_events_table.sql"),
kind: MigrationKind::Up,
},
Migration {
version: 20230816090508,
description: "clean up tables",
sql: include_str!("../migrations/20230816090508_clean_up_tables.sql"),
kind: MigrationKind::Up,
},
Migration {
version: 20230817014932,
description: "add last login to account",
sql: include_str!("../migrations/20230817014932_add_last_login_time_to_account.sql"),
kind: MigrationKind::Up,
},
],
)
.build(),
@@ -226,6 +122,7 @@ fn main() {
..Default::default()
};
// let salt = Alphanumeric.sample_string(&mut rand::thread_rng(), 12);
let key = argon2::hash_raw(
password.as_ref(),
b"LUME_NEED_RUST_DEVELOPER_HELP_MAKE_SALT_RANDOM",
@@ -247,9 +144,6 @@ fn main() {
.emit_all("single-instance", Payload { args: argv, cwd })
.unwrap();
}))
.plugin(tauri_plugin_upload::init())
.plugin(tauri_plugin_store::Builder::default().build())
.invoke_handler(tauri::generate_handler![close_splashscreen, opengraph])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View File

@@ -0,0 +1,60 @@
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,131 +1,145 @@
{
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
"build": {
"beforeBuildCommand": "pnpm build",
"beforeDevCommand": "pnpm dev",
"devPath": "http://localhost:3000",
"distDir": "../dist",
"withGlobalTauri": true
},
"package": {
"productName": "Lume",
"version": "1.2.4"
},
"tauri": {
"allowlist": {
"app": {
"all": true,
"show": true,
"hide": true
},
"path": {
"all": true
},
"dialog": {
"all": true,
"ask": true,
"confirm": true,
"message": true,
"open": true,
"save": true
},
"fs": {
"all": false,
"removeFile": true,
"writeFile": true,
"readDir": true,
"readFile": true,
"scope": [
"$APPDATA/*",
"$DATA/*",
"$LOCALDATA/*",
"$DESKTOP/*",
"$DOCUMENT/*",
"$DOWNLOAD/*",
"$HOME/*",
"$PICTURE/*",
"$PUBLIC/*",
"$VIDEO/*"
]
},
"http": {
"all": true,
"scope": [
"http://**",
"https://**"
]
},
"shell": {
"all": false,
"open": true
},
"os": {
"all": true
},
"window": {
"all": false,
"center": true,
"setResizable": true,
"setSize": true,
"startDragging": true
},
"clipboard": {
"all": false,
"writeText": true,
"readText": true
},
"notification": {
"all": true
}
},
"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,
"minimumSystemVersion": "10.15.0"
},
"resources": [],
"shortDescription": "",
"targets": "all",
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": ""
}
},
"updater": {
"endpoints": [
"https://lus.reya3772.workers.dev/v1/{{target}}/{{arch}}/{{current_version}}",
"https://lus.reya3772.workers.dev/{{target}}/{{current_version}}"
]
},
"security": {
"csp": {
"content-security-policy": "upgrade-insecure-requests"
}
},
"macOSPrivateApi": true
}
"build": {
"beforeDevCommand": "pnpm dev",
"beforeBuildCommand": "pnpm build",
"devPath": "http://localhost:3000",
"distDir": "../dist",
"withGlobalTauri": true
},
"package": {
"productName": "Lume",
"version": "1.0.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,
"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
},
"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
}
]
}
}

View File

@@ -1,32 +0,0 @@
{
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
"tauri": {
"windows": [
{
"width": 400,
"height": 500,
"decorations": false,
"title": "Lume",
"center": true,
"resizable": false,
"label": "splashscreen",
"url": "splashscreen"
},
{
"width": 1080,
"height": 800,
"minWidth": 1080,
"minHeight": 800,
"resizable": true,
"theme": "Dark",
"title": "Lume",
"transparent": false,
"center": true,
"fullscreen": false,
"hiddenTitle": true,
"visible": false,
"fileDropEnabled": true
}
]
}
}

View File

@@ -1,35 +0,0 @@
{
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
"tauri": {
"windows": [
{
"width": 400,
"height": 500,
"decorations": true,
"title": "Lume",
"titleBarStyle": "Overlay",
"hiddenTitle": true,
"center": true,
"resizable": false,
"label": "splashscreen",
"url": "splashscreen"
},
{
"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,
"fileDropEnabled": true
}
]
}
}

View File

@@ -1,32 +0,0 @@
{
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
"tauri": {
"windows": [
{
"width": 400,
"height": 500,
"decorations": false,
"title": "Lume",
"center": true,
"resizable": false,
"label": "splashscreen",
"url": "splashscreen"
},
{
"width": 1080,
"height": 800,
"minWidth": 1080,
"minHeight": 800,
"resizable": true,
"theme": "Dark",
"title": "Lume",
"transparent": false,
"center": true,
"fullscreen": false,
"hiddenTitle": true,
"visible": false,
"fileDropEnabled": true
}
]
}
}

View File

@@ -1,261 +1,105 @@
import { message } from '@tauri-apps/api/dialog';
import { RouterProvider, createBrowserRouter, redirect } from 'react-router-dom';
import { RouterProvider, createBrowserRouter } 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 { UnlockScreen } from '@app/auth/unlock';
import { WelcomeScreen } from '@app/auth/welcome';
import { ChannelScreen } from '@app/channel';
import { ChatScreen } from '@app/chat';
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/user';
import { Frame } from '@shared/frame';
import { LoaderIcon } from '@shared/icons';
import { AppLayout } from '@shared/layouts/app';
import { AuthLayout } from '@shared/layouts/auth';
import { NoteLayout } from '@shared/layouts/note';
import { SettingsLayout } from '@shared/layouts/settings';
import { checkActiveAccount } from '@utils/checkActiveAccount';
import { AppLayout } from '@shared/appLayout';
import { AuthLayout } from '@shared/authLayout';
import { Protected } from '@shared/protected';
import { SettingsLayout } from '@shared/settingsLayout';
import './index.css';
async function Loader() {
try {
const account = await checkActiveAccount();
const stronghold = sessionStorage.getItem('stronghold');
const privkey = JSON.parse(stronghold).state.privkey || null;
const onboarding = localStorage.getItem('onboarding');
const step = JSON.parse(onboarding).state.step || null;
if (!account) {
return redirect('/auth/welcome');
} else {
if (step) {
return redirect(step);
}
if (!privkey) {
return redirect('/auth/unlock');
}
}
return null;
} catch (e) {
await message(e, { title: 'An unexpected error has occurred', type: 'error' });
}
}
const router = createBrowserRouter([
{
path: '/',
element: <AppLayout />,
element: (
<Protected>
<Root />
</Protected>
),
errorElement: <ErrorScreen />,
loader: Loader,
children: [
{
path: '',
async lazy() {
const { SpaceScreen } = await import('@app/space');
return { Component: SpaceScreen };
},
},
{
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: 'notifications',
async lazy() {
const { NotificationScreen } = await import('@app/notifications');
return { Component: NotificationScreen };
},
},
],
},
{
path: '/notes',
element: <NoteLayout />,
errorElement: <ErrorScreen />,
children: [
{
path: 'text/:id',
async lazy() {
const { TextNoteScreen } = await import('@app/notes/text');
return { Component: TextNoteScreen };
},
},
{
path: 'article/:id',
async lazy() {
const { ArticleNoteScreen } = await import('@app/notes/article');
return { Component: ArticleNoteScreen };
},
},
],
},
{
path: '/splashscreen',
errorElement: <ErrorScreen />,
async lazy() {
const { SplashScreen } = await import('@app/splash');
return { Component: SplashScreen };
},
},
{
path: '/auth',
element: <AuthLayout />,
errorElement: <ErrorScreen />,
children: [
{
path: 'welcome',
async lazy() {
const { WelcomeScreen } = await import('@app/auth/welcome');
return { Component: WelcomeScreen };
},
},
{ path: 'welcome', element: <WelcomeScreen /> },
{ path: 'onboarding', element: <OnboardingScreen /> },
{
path: 'import',
element: <AuthImportScreen />,
errorElement: <ErrorScreen />,
children: [
{
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: '', element: <ImportStep1Screen /> },
{ path: 'step-2', element: <ImportStep2Screen /> },
{ path: 'step-3', element: <ImportStep3Screen /> },
],
},
{
path: 'create',
element: <AuthCreateScreen />,
errorElement: <ErrorScreen />,
children: [
{
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: '', element: <CreateStep1Screen /> },
{ path: 'step-2', element: <CreateStep2Screen /> },
{ path: 'step-3', element: <CreateStep3Screen /> },
{ path: 'step-4', element: <CreateStep4Screen /> },
{ path: 'step-5', element: <CreateStep5Screen /> },
],
},
{
path: 'onboarding',
element: <OnboardingScreen />,
errorElement: <ErrorScreen />,
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: 'hard-reset',
async lazy() {
const { HardResetScreen } = await import('@app/auth/hardReset');
return { Component: HardResetScreen };
},
},
{ path: 'unlock', element: <UnlockScreen /> },
{ path: 'migrate', element: <MigrateScreen /> },
],
},
{
path: '/app',
element: (
<Protected>
<AppLayout />
</Protected>
),
children: [
{ path: 'space', element: <SpaceScreen /> },
{ path: 'trending', element: <TrendingScreen /> },
{ path: 'note/:id', element: <NoteScreen /> },
{ path: 'user/:pubkey', element: <UserScreen /> },
{ path: 'chat/:pubkey', element: <ChatScreen /> },
{ path: 'channel/:id', element: <ChannelScreen /> },
],
},
{
path: '/settings',
element: <SettingsLayout />,
errorElement: <ErrorScreen />,
element: (
<Protected>
<SettingsLayout />
</Protected>
),
children: [
{
path: '',
async lazy() {
const { GeneralSettingsScreen } = await import('@app/settings/general');
return { Component: GeneralSettingsScreen };
},
},
{
path: 'backup',
async lazy() {
const { AccountSettingsScreen } = await import('@app/settings/account');
return { Component: AccountSettingsScreen };
},
},
{ path: 'general', element: <GeneralSettingsScreen /> },
{ path: 'shortcuts', element: <ShortcutsSettingsScreen /> },
{ path: 'account', element: <AccountSettingsScreen /> },
],
},
]);
@@ -264,11 +108,8 @@ export default function App() {
return (
<RouterProvider
router={router}
fallbackElement={
<Frame className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-6 w-6 animate-spin text-white" />
</Frame>
}
fallbackElement={<p>Loading..</p>}
future={{ v7_startTransition: true }}
/>
);
}

View File

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

View File

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

View File

@@ -1,19 +1,6 @@
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,29 +1,25 @@
import { BaseDirectory, writeTextFile } from '@tauri-apps/api/fs';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { generatePrivateKey, getPublicKey, nip19 } from 'nostr-tools';
import { useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider';
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';
export function CreateStep1Screen() {
const { db } = useStorage();
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 queryClient = useQueryClient();
const [setPubkey, setPrivkey] = useOnboarding((state) => [
state.setPubkey,
state.setPrivkey,
]);
const [privkeyInput, setPrivkeyInput] = useState('password');
const [loading, setLoading] = useState(false);
const [downloaded, setDownloaded] = useState(false);
const privkey = useMemo(() => generatePrivateKey(), []);
const pubkey = getPublicKey(privkey);
@@ -39,110 +35,90 @@ export function CreateStep1Screen() {
}
};
const download = async () => {
await writeTextFile(
`nostr_keys_${new Date().toISOString().slice(0, 10)}.txt`,
`Generated by Lume (lume.nu)\nPublic key: ${npub}\nPrivate key: ${nsec}`,
{
dir: BaseDirectory.Download,
}
);
setDownloaded(true);
};
const account = useMutation({
mutationFn: (data: {
npub: string;
pubkey: string;
follows: null | string[][];
is_active: number;
}) => {
return createAccount(data.npub, data.pubkey, null, 1);
},
onSuccess: (data) => {
queryClient.setQueryData(['currentAccount'], data);
},
});
const submit = () => {
setLoading(true);
// update state
setPrivkey(privkey);
setTempPrivkey(privkey); // only use if user close app and reopen it
setPubkey(pubkey);
setPrivkey(privkey);
// save to database
db.createAccount(npub, pubkey);
account.mutate({
npub,
pubkey,
follows: null,
is_active: 1,
});
// redirect to next step
navigate('/auth/create/step-2', { replace: true });
setTimeout(() => navigate('/auth/create/step-2', { replace: true }), 1200);
};
useEffect(() => {
// save current step, if user close app and reopen it
setStep('/auth/create');
}, []);
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-white">Save your access key!</h1>
<h1 className="text-xl font-semibold text-zinc-100">
Lume is auto-generated key for you
</h1>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<span className="text-base font-semibold text-white/50">Public Key</span>
<span className="text-base font-semibold text-zinc-400">Public Key</span>
<input
readOnly
value={npub}
className="relative h-11 w-full rounded-lg bg-white/10 px-3.5 py-1 text-white !outline-none backdrop-blur-xl placeholder:text-white/50"
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"
/>
</div>
<div className="flex flex-col gap-1">
<span className="text-base font-semibold text-white/50">Private Key</span>
<span className="text-base font-semibold text-zinc-400">Private Key</span>
<div className="relative">
<input
readOnly
type={privkeyInput}
value={nsec}
className="relative h-11 w-full rounded-lg bg-white/10 py-1 pl-3.5 pr-11 text-white !outline-none backdrop-blur-xl placeholder:text-white/50"
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={() => showPrivateKey()}
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 backdrop-blur-xl hover:bg-white/10"
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-zinc-700"
>
{privkeyInput === 'password' ? (
<EyeOffIcon className="h-4 w-4 text-white/50 group-hover:text-white" />
<EyeOffIcon
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" />
<EyeOnIcon
width={20}
height={20}
className="text-zinc-500 group-hover:text-zinc-100"
/>
)}
</button>
</div>
<div className="mt-2 text-sm text-white/50">
<p>
Your private key is your password. If you lose this key, you will lose
access to your account! Copy it and keep it in a safe place. There is no way
to reset your private key.
</p>
</div>
</div>
<div className="flex flex-col gap-2">
<button
type="button"
onClick={() => submit()}
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
>
{loading ? (
<>
<span className="w-5" />
<span>Creating...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
</>
) : (
<>
<span className="w-5" />
<span>I have saved my key, continue</span>
<ArrowRightCircleIcon className="h-5 w-5" />
</>
)}
</button>
{downloaded ? (
<span className="inline-flex h-11 w-full items-center justify-center text-sm text-white/50">
Saved in Download folder
</span>
<Button preset="large" onClick={() => submit()}>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
) : (
<Button preset="large-alt" onClick={() => download()}>
Download
</Button>
'Continue →'
)}
</div>
</Button>
</div>
</div>
);

View File

@@ -1,17 +1,14 @@
import { appConfigDir } from '@tauri-apps/api/path';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { Resolver, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { Stronghold } from 'tauri-plugin-stronghold-api';
import { useStorage } from '@libs/storage/provider';
import { EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
import { useOnboarding } from '@stores/onboarding';
import { useStronghold } from '@stores/stronghold';
import { useSecureStorage } from '@utils/hooks/useSecureStorage';
type FormValues = {
password: string;
};
@@ -32,14 +29,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 setPassword = useStronghold((state) => state.setPassword);
const [pubkey, privkey] = useOnboarding((state) => [state.pubkey, state.privkey]);
const [passwordInput, setPasswordInput] = useState('password');
const [loading, setLoading] = useState(false);
const { db } = useStorage();
const { save } = useSecureStorage();
// toggle private key
const showPassword = () => {
@@ -60,13 +56,11 @@ export function CreateStep2Screen() {
const onSubmit = async (data: { [x: string]: string }) => {
setLoading(true);
if (data.password.length > 3) {
const dir = await appConfigDir();
const stronghold = await Stronghold.load(`${dir}/lume.stronghold`, data.password);
if (!db.secureDB) db.secureDB = stronghold;
// add password to local state
setPassword(data.password);
// save privkey to secure storage
await db.secureSave(pubkey, privkey);
await save(pubkey, privkey, data.password);
// redirect to next step
navigate('/auth/create/step-3', { replace: true });
@@ -79,15 +73,10 @@ 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-white">
<h1 className="text-xl font-semibold text-zinc-100">
Set password to secure your key
</h1>
</div>
@@ -98,21 +87,29 @@ export function CreateStep2Screen() {
<input
{...register('password', { required: true })}
type={passwordInput}
className="relative h-11 w-full rounded-lg bg-white/10 px-3.5 py-1 text-center text-white !outline-none backdrop-blur-xl placeholder:text-white/50"
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 backdrop-blur-xl hover:bg-white/10"
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-zinc-700"
>
{passwordInput === 'password' ? (
<EyeOffIcon className="h-4 w-4 text-white/50 group-hover:text-white" />
<EyeOffIcon
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" />
<EyeOnIcon
width={20}
height={20}
className="text-zinc-500 group-hover:text-zinc-100"
/>
)}
</button>
</div>
<div className="text-sm text-white/50">
<div className="text-sm text-zinc-500">
<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
@@ -127,20 +124,12 @@ export function CreateStep2Screen() {
<button
type="submit"
disabled={!isDirty || !isValid}
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
className="inline-flex h-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"
>
{loading ? (
<>
<span className="w-5" />
<span>Creating...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
</>
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
) : (
<>
<span className="w-5" />
<span>Continue</span>
<ArrowRightCircleIcon className="h-5 w-5" />
</>
'Continue →'
)}
</button>
</div>

View File

@@ -1,84 +1,73 @@
import { NDKKind } from '@nostr-dev-kit/ndk';
import { useEffect, useState } from 'react';
import { 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 setStep = useOnboarding((state) => state.setStep);
const createProfile = useOnboarding((state) => state.createProfile);
const [loading, setLoading] = useState(false);
const [picture, setPicture] = useState('https://void.cat/d/5VKmKyuHyxrNMf9bWSVPih');
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 = async (data: { name: string; about: string; website: string }) => {
const onSubmit = (data: any) => {
setLoading(true);
try {
const profile = {
...data,
name: data.name,
username: data.name,
display_name: data.name,
bio: data.about,
website: data.website,
};
const event = await publish({
content: JSON.stringify(profile),
kind: NDKKind.Metadata,
tags: [],
});
if (event) {
navigate('/auth/onboarding', { replace: true });
}
} catch (e) {
console.log('error: ', e);
setLoading(false);
createProfile(profile);
// redirect to next step
setTimeout(() => navigate('/auth/create/step-4', { replace: true }), 1200);
} catch {
console.log('error');
}
};
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-white">Create your profile</h1>
<h1 className="text-xl font-semibold text-zinc-100">Create your profile</h1>
</div>
<div className="w-full overflow-hidden rounded-xl bg-white/10 backdrop-blur-xl">
<div className="w-full overflow-hidden rounded-xl border-t border-zinc-800/50 bg-zinc-900">
<form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col">
<input type={'hidden'} {...register('picture')} value={picture} />
<input type={'hidden'} {...register('banner')} value={banner} />
<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"
/>
<div className="relative">
<div className="relative h-44 w-full bg-white/10 backdrop-blur-xl">
{banner ? (
<Image
src={banner}
alt="user's banner"
className="h-full w-full object-cover"
/>
) : (
<div className="h-full w-full bg-black/50" />
)}
<div className="relative h-44 w-full bg-zinc-800">
<Image
src={banner}
fallback="https://void.cat/d/QY1myro5tkHVs2nY7dy74b.jpg"
alt="user's banner"
className="h-full w-full object-cover"
/>
<div className="absolute left-1/2 top-1/2 z-10 h-full w-full -translate-x-1/2 -translate-y-1/2 transform">
<BannerUploader setBanner={setBanner} />
</div>
@@ -87,8 +76,9 @@ export function CreateStep3Screen() {
<div className="relative z-10 -mt-7 h-14 w-14">
<Image
src={picture}
fallback={DEFAULT_AVATAR}
alt="user's avatar"
className="h-14 w-14 rounded-lg object-cover ring-2 ring-white/10"
className="h-14 w-14 rounded-lg object-cover ring-2 ring-zinc-900"
/>
<div className="absolute left-1/2 top-1/2 z-10 h-full w-full -translate-x-1/2 -translate-y-1/2 transform">
<AvatarUploader setPicture={setPicture} />
@@ -100,7 +90,7 @@ export function CreateStep3Screen() {
<div className="flex flex-col gap-1">
<label
htmlFor="name"
className="text-sm font-semibold uppercase tracking-wider text-white/50"
className="text-sm font-semibold uppercase tracking-wider text-zinc-400"
>
Name *
</label>
@@ -111,26 +101,26 @@ export function CreateStep3Screen() {
minLength: 4,
})}
spellCheck={false}
className="relative h-11 w-full rounded-lg bg-white/10 px-3 py-1 text-white !outline-none backdrop-blur-xl placeholder:text-white/50"
className="relative h-10 w-full rounded-lg bg-zinc-800 px-3 py-2 text-zinc-100 !outline-none placeholder:text-zinc-500"
/>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="about"
className="text-sm font-semibold uppercase tracking-wider text-white/50"
className="text-sm font-semibold uppercase tracking-wider text-zinc-400"
>
Bio
</label>
<textarea
{...register('about')}
spellCheck={false}
className="relative h-20 w-full resize-none rounded-lg bg-white/10 px-3 py-1 text-white !outline-none backdrop-blur-xl placeholder:text-white/50"
className="relative h-20 w-full resize-none rounded-lg bg-zinc-800 px-3 py-2 text-zinc-100 !outline-none placeholder:text-zinc-500"
/>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="website"
className="text-sm font-semibold uppercase tracking-wider text-white/50"
className="text-sm font-semibold uppercase tracking-wider text-zinc-400"
>
Website
</label>
@@ -140,26 +130,18 @@ export function CreateStep3Screen() {
required: false,
})}
spellCheck={false}
className="relative h-11 w-full rounded-lg bg-white/10 px-3 py-1 text-white !outline-none backdrop-blur-xl placeholder:text-white/50"
className="relative h-10 w-full rounded-lg bg-zinc-800 px-3 py-2 text-zinc-100 !outline-none placeholder:text-zinc-500"
/>
</div>
<button
type="submit"
disabled={!isDirty || !isValid}
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
className="inline-flex h-11 w-full items-center justify-center rounded-md bg-fuchsia-500 font-medium text-zinc-100 hover:bg-fuchsia-600"
>
{loading ? (
<>
<span className="w-5" />
<span>Creating...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
</>
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
) : (
<>
<span className="w-5" />
<span>Continue</span>
<ArrowRightCircleIcon className="h-5 w-5" />
</>
'Continue →'
)}
</button>
</div>

View File

@@ -0,0 +1,86 @@
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 publish = usePublish();
const profile = useOnboarding((state) => state.profile);
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

@@ -0,0 +1,224 @@
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 publish = usePublish();
const [loading, setLoading] = useState(false);
const [follows, setFollows] = useState([]);
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: any) => {
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) : [];
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,7 +0,0 @@
export function HardResetScreen() {
return (
<div>
<p>hard reset</p>
</div>
);
}

View File

@@ -1,19 +1,6 @@
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,15 +1,14 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { getPublicKey, nip19 } from 'nostr-tools';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { Resolver, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider';
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';
type FormValues = {
privkey: string;
@@ -30,15 +29,29 @@ const resolver: Resolver<FormValues> = async (values) => {
};
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 [setPubkey, setPrivkey] = useOnboarding((state) => [
state.setPubkey,
state.setPrivkey,
]);
const [loading, setLoading] = useState(false);
const { db } = useStorage();
const account = useMutation({
mutationFn: (data: {
npub: string;
pubkey: string;
follows: null | string[];
is_active: number | boolean;
}) => {
return createAccount(data.npub, data.pubkey, null, 1);
},
onSuccess: (data) => {
queryClient.setQueryData(['currentAccount'], data);
},
});
const {
register,
setError,
@@ -59,43 +72,43 @@ export function ImportStep1Screen() {
const pubkey = getPublicKey(privkey);
const npub = nip19.npubEncode(pubkey);
setPrivkey(privkey);
setTempPubkey(privkey); // only use if user close app and reopen it
// use for onboarding process only
setPubkey(pubkey);
setPrivkey(privkey);
// add account to local database
db.createAccount(npub, pubkey);
account.mutate({
npub,
pubkey,
follows: null,
is_active: 1,
});
// redirect to step 2
navigate('/auth/import/step-2', { replace: true });
setTimeout(() => navigate('/auth/import/step-2', { replace: true }), 1200);
}
} 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');
}, []);
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-white">Import your key</h1>
<h1 className="text-xl font-semibold text-zinc-100">Import your key</h1>
</div>
<div className="flex flex-col gap-4">
<form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col gap-3">
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<span className="text-base font-semibold text-white/50">Private key</span>
<span className="text-base font-semibold text-zinc-400">Private key</span>
<input
{...register('privkey', { required: true, minLength: 32 })}
type={'password'}
placeholder="nsec or hexstring"
className="relative h-11 w-full rounded-lg bg-white/10 px-3 py-1 text-white !outline-none backdrop-blur-xl placeholder:text-white/50"
className="relative w-full rounded-lg bg-zinc-800 px-3 py-3 text-zinc-100 !outline-none placeholder:text-zinc-500"
/>
<span className="text-sm text-red-400">
{errors.privkey && <p>{errors.privkey.message}</p>}
@@ -105,20 +118,12 @@ export function ImportStep1Screen() {
<button
type="submit"
disabled={!isDirty || !isValid}
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
className="inline-flex h-11 w-full items-center justify-center rounded-md bg-fuchsia-500 font-medium text-zinc-100 hover:bg-fuchsia-600"
>
{loading ? (
<>
<span className="w-5" />
<span>Creating...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
</>
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
) : (
<>
<span className="w-5" />
<span>Continue</span>
<ArrowRightCircleIcon className="h-5 w-5" />
</>
'Continue →'
)}
</button>
</div>

View File

@@ -1,17 +1,14 @@
import { appConfigDir } from '@tauri-apps/api/path';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { Resolver, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { Stronghold } from 'tauri-plugin-stronghold-api';
import { useStorage } from '@libs/storage/provider';
import { EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
import { useOnboarding } from '@stores/onboarding';
import { useStronghold } from '@stores/stronghold';
import { useSecureStorage } from '@utils/hooks/useSecureStorage';
type FormValues = {
password: string;
};
@@ -32,14 +29,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 setPassword = useStronghold((state) => state.setPassword);
const [passwordInput, setPasswordInput] = useState('password');
const [loading, setLoading] = useState(false);
const [pubkey, privkey] = useOnboarding((state) => [state.pubkey, state.privkey]);
const { db } = useStorage();
const { save } = useSecureStorage();
// toggle private key
const showPassword = () => {
@@ -60,13 +56,11 @@ export function ImportStep2Screen() {
const onSubmit = async (data: { [x: string]: string }) => {
setLoading(true);
if (data.password.length > 3) {
const dir = await appConfigDir();
const stronghold = await Stronghold.load(`${dir}/lume.stronghold`, data.password);
if (!db.secureDB) db.secureDB = stronghold;
// add password to local state
setPassword(data.password);
// save privkey to secure storage
await db.secureSave(pubkey, privkey);
await save(pubkey, privkey, data.password);
// redirect to next step
navigate('/auth/import/step-3', { replace: true });
@@ -79,15 +73,10 @@ 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-white">
<h1 className="text-xl font-semibold text-zinc-100">
Set password to secure your key
</h1>
</div>
@@ -98,25 +87,35 @@ export function ImportStep2Screen() {
<input
{...register('password', { required: true })}
type={passwordInput}
className="relative h-11 w-full rounded-lg bg-white/10 px-3.5 py-1 text-center text-white !outline-none backdrop-blur-xl placeholder:text-white/50"
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 backdrop-blur-xl hover:bg-white/10"
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-zinc-700"
>
{passwordInput === 'password' ? (
<EyeOffIcon className="h-4 w-4 text-white/50 group-hover:text-white" />
<EyeOffIcon
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" />
<EyeOnIcon
width={20}
height={20}
className="text-zinc-500 group-hover:text-zinc-100"
/>
)}
</button>
</div>
<p className="text-sm text-white/50">
Password is use to unlock app and secure your key store in local machine.
When you move to other clients, you just need to copy your private key as
nsec or hexstring
</p>
<div className="text-sm text-zinc-500">
<p>
Password is use to secure your key store in local machine, when you move
to other clients, you just need to copy your private key as nsec or
hexstring
</p>
</div>
<span className="text-sm text-red-400">
{errors.password && <p>{errors.password.message}</p>}
</span>
@@ -125,20 +124,12 @@ export function ImportStep2Screen() {
<button
type="submit"
disabled={!isDirty || !isValid}
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
className="inline-flex h-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"
>
{loading ? (
<>
<span className="w-5" />
<span>Creating...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
</>
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
) : (
<>
<span className="w-5" />
<span>Continue</span>
<ArrowRightCircleIcon className="h-5 w-5" />
</>
'Continue →'
)}
</button>
</div>

View File

@@ -1,82 +1,87 @@
import { useEffect, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { User } from '@app/auth/components/user';
import { useStorage } from '@libs/storage/provider';
import { useNDK } from '@libs/ndk/provider';
import { updateAccount } from '@libs/storage';
import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
import { Button } from '@shared/button';
import { LoaderIcon } from '@shared/icons';
import { useOnboarding } from '@stores/onboarding';
import { useNostr } from '@utils/hooks/useNostr';
import { useAccount } from '@utils/hooks/useAccount';
import { setToArray } from '@utils/transform';
export function ImportStep3Screen() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const setStep = useOnboarding((state) => state.setStep);
const [loading, setLoading] = useState(false);
const { db } = useStorage();
const { fetchUserData, prefetchEvents } = useNostr();
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 submit = async () => {
try {
// show loading indicator
setLoading(true);
// prefetch data
const user = await fetchUserData();
const data = await prefetchEvents();
const user = ndk.getUser({ hexpubkey: account.pubkey });
const follows = await user.follows();
// follows as list
const followsList = setToArray(follows);
// update
update.mutate([...followsList, account.pubkey]);
// redirect to next step
if (user.status === 'ok' && data.status === 'ok') {
navigate('/auth/onboarding/step-2', { replace: true });
} else {
console.log('error: ', data.message);
setLoading(false);
}
} catch (e) {
console.log('error: ', e);
setLoading(false);
setTimeout(() => navigate('/auth/onboarding', { replace: true }), 1200);
} catch {
console.log('error');
}
};
useEffect(() => {
// save current step, if user close app and reopen it
setStep('/auth/import/step-3');
}, []);
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold">
{loading ? 'Prefetching data...' : 'Continue with'}
{loading ? 'Creating...' : 'Continue with'}
</h1>
</div>
<div className="w-full rounded-xl bg-white/10 p-4 backdrop-blur-xl">
<div className="flex flex-col gap-3">
<User pubkey={db.account.pubkey} />
<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 ? (
<>
<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>Continue</span>
<ArrowRightCircleIcon className="h-5 w-5" />
</>
)}
</button>
</div>
<div className="w-full rounded-xl border-t border-zinc-800/50 bg-zinc-900 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>
<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>
</div>
</div>
) : (
<div className="flex flex-col gap-3">
<User pubkey={account.pubkey} />
<Button preset="large" onClick={() => submit()}>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
) : (
'Continue →'
)}
</Button>
</div>
)}
</div>
</div>
);

View File

@@ -1,16 +1,16 @@
import { useQueryClient } from '@tanstack/react-query';
import { appConfigDir } from '@tauri-apps/api/path';
import { useState } from 'react';
import { Resolver, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { Stronghold } from 'tauri-plugin-stronghold-api';
import { useStorage } from '@libs/storage/provider';
import { removePrivkey } from '@libs/storage';
import { EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
import { CheckCircleIcon, EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
import { useStronghold } from '@stores/stronghold';
import { useAccount } from '@utils/hooks/useAccount';
import { useSecureStorage } from '@utils/hooks/useSecureStorage';
type FormValues = {
password: string;
};
@@ -30,14 +30,16 @@ const resolver: Resolver<FormValues> = async (values) => {
};
export function MigrateScreen() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const setPrivkey = useStronghold((state) => state.setPrivkey);
const [passwordInput, setPasswordInput] = useState('password');
const [loading, setLoading] = useState(false);
const [passwordStep, setPasswordStep] = useState({ loading: false, done: false });
const [privkeyStep, setPrivkeyStep] = useState({ loading: false, done: false });
const { db } = useStorage();
const { account } = useAccount();
const { save } = useSecureStorage();
const setPassword = useStronghold((state) => state.setPassword);
// toggle private key
const showPassword = () => {
@@ -48,6 +50,16 @@ export function MigrateScreen() {
}
};
const clearPrivkey = async () => {
setPrivkeyStep((prev) => ({ ...prev, loading: true }));
const res = await removePrivkey();
if (res) {
setPrivkeyStep({ done: true, loading: false });
} else {
setPrivkeyStep((prev) => ({ ...prev, loading: false }));
}
};
const {
register,
setError,
@@ -56,34 +68,25 @@ export function MigrateScreen() {
} = useForm<FormValues>({ resolver });
const onSubmit = async (data: { [x: string]: string }) => {
setLoading(true);
setPasswordStep((prev) => ({ ...prev, loading: true }));
if (data.password.length > 3) {
// add password to local state
setPassword(data.password);
// load private in secure storage
try {
// save privkey to secure storage
const dir = await appConfigDir();
const stronghold = await Stronghold.load(`${dir}/lume.stronghold`, data.password);
if (!db.secureDB) db.secureDB = stronghold;
await db.secureSave(db.account.pubkey, db.account.privkey);
// add privkey to state
setPrivkey(db.account.privkey);
// remove privkey in db
await db.removePrivkey();
// clear cache
await queryClient.invalidateQueries(['account']);
await save(account.pubkey, account.privkey, data.password);
// redirect to home
navigate('/', { replace: true });
} catch {
setLoading(false);
setPasswordStep((prev) => ({ ...prev, loading: false }));
setError('password', {
type: 'custom',
message: 'Wrong password',
});
}
} else {
setLoading(false);
setPasswordStep((prev) => ({ ...prev, loading: false }));
setError('password', {
type: 'custom',
message: 'Password is required and must be greater than 3',
@@ -95,56 +98,84 @@ 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-white">
<h1 className="text-xl font-semibold text-zinc-100">
Upgrade security for your account
</h1>
</div>
<div className="w-full rounded-xl bg-white/10 px-3 py-3 backdrop-blur-xl">
<div className="w-full rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 py-3">
<div className="flex flex-col gap-4">
<div>
<h3 className="font-medium text-zinc-200">
Remove plaintext privkey store in local database
</h3>
<div className="mt-1">
<p className="text-sm text-white/50">
<p className="text-sm text-zinc-400">
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-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-white/50">
It is not possible to start the app without applying this step, it is
easy and fast!
</p>
<p className="mt-2 text-sm text-zinc-400">To secure your private key</p>
<ul className="mt-2 list-outside list-disc pl-5 text-sm text-zinc-400">
<li>Firstly, click button below to remove it from local database</li>
<li>
Then set password and Lume will put your private key in secure storage
</li>
</ul>
</div>
<div className="mt-3">
<div className="relative">
<input
readOnly
value={privkeyStep.done ? 'Nothing to see here' : account.privkey}
className="relative w-full rounded-lg bg-zinc-800 px-3 py-3 text-zinc-100 !outline-none placeholder:text-zinc-500"
/>
<div className="absolute left-0 top-0 flex h-full w-full items-center justify-center rounded-lg bg-black/10 backdrop-blur-sm">
{privkeyStep.done ? (
privkeyStep.loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-zinc-100" />
) : (
<CheckCircleIcon className="h-5 w-5 text-green-500" />
)
) : (
<button
type="button"
onClick={() => clearPrivkey()}
className="inline-flex w-max items-center justify-center rounded bg-fuchsia-500 px-2.5 py-1.5 text-sm hover:bg-fuchsia-600"
>
Click to remove
</button>
)}
</div>
</div>
</div>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="mb-0">
<div className="flex flex-col gap-1">
<span className="font-medium text-white">
Set a password to protect your key
<span className="font-medium text-zinc-200">
Set password to protect your key
</span>
<div className="relative">
<input
{...register('password', { required: true })}
type={passwordInput}
placeholder="min. 4 characters"
className="relative w-full rounded-lg bg-white/10 py-3 pl-3.5 pr-11 text-white !outline-none placeholder:text-white/50"
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-white/10"
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-white/50 group-hover:text-white"
className="text-zinc-500 group-hover:text-zinc-100"
/>
) : (
<EyeOnIcon
width={20}
height={20}
className="text-white/50 group-hover:text-white"
className="text-zinc-500 group-hover:text-zinc-100"
/>
)}
</button>
@@ -154,17 +185,19 @@ export function MigrateScreen() {
</span>
</div>
<div className="flex items-center justify-center">
<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-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-white" />
) : (
'Continue →'
)}
</button>
{privkeyStep.done && (
<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"
>
{passwordStep.loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
) : (
'Continue →'
)}
</button>
)}
</div>
</form>
</div>

102
src/app/auth/onboarding.tsx Normal file
View File

@@ -0,0 +1,102 @@
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 publish = usePublish();
const navigate = useNavigate();
const { status, account } = useAccount();
const [loading, setLoading] = useState(false);
const submit = async () => {
try {
setLoading(true);
// publish event
publish({
content:
'Running Lume, fighting for better future, join us here: 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-8 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 better future that we&apos;re fighting
</p>
<p className="text-sm text-zinc-300">
If Lume gets your attention, please help us spread via button below
</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, fighting for better future</p>
<p>
join us here:{' '}
<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>
</p>
</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>Publish</span>
<ArrowRightCircleIcon className="h-5 w-5" />
</>
)}
</button>
<Link
to="/"
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-lg px-6 text-sm font-medium text-zinc-200"
>
Skip for now
</Link>
</div>
</div>
</div>
);
}

View File

@@ -1,22 +0,0 @@
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

@@ -1,135 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { User } from '@app/auth/components/user';
import { useStorage } from '@libs/storage/provider';
import { ArrowRightCircleIcon, CheckCircleIcon, LoaderIcon } from '@shared/icons';
import { useOnboarding } from '@stores/onboarding';
import { useNostr } from '@utils/hooks/useNostr';
import { arrayToNIP02 } from '@utils/transform';
export function OnboardStep1Screen() {
const navigate = useNavigate();
const setStep = useOnboarding((state) => state.setStep);
const { publish, fetchUserData, prefetchEvents } = useNostr();
const { db } = useStorage();
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, db.account.pubkey]);
const event = await publish({ content: '', kind: 3, tags: tags });
// prefetch data
const user = await fetchUserData(follows);
const data = await prefetchEvents();
// redirect to next step
if (event && user.status === 'ok' && data.status === 'ok') {
navigate('/auth/onboarding/step-2', { replace: true });
} else {
setLoading(false);
console.log('error: ', data.message);
}
} catch (e) {
setLoading(false);
console.log('error: ', e);
}
};
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 py-2 backdrop-blur-xl">
{status === 'loading' ? (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-4 w-4 animate-spin text-white" />
</div>
) : (
data?.profiles.map(
(item: { pubkey: string; profile: { content: string } }) => (
<button
key={item.pubkey}
type="button"
onClick={() => toggleFollow(item.pubkey)}
className="inline-flex transform items-center justify-between px-4 py-2 hover:bg-white/20"
>
<User pubkey={item.pubkey} fallback={item.profile?.content} />
{follows.includes(item.pubkey) && (
<div>
<CheckCircleIcon className="h-4 w-4 text-green-400" />
</div>
)}
</button>
)
)
)}
</div>
<div className="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 backdrop-blur-xl hover:bg-white/10 focus:outline-none"
>
Skip, you can add later
</Link>
</div>
</div>
</div>
);
}

View File

@@ -1,127 +0,0 @@
import { message } from '@tauri-apps/api/dialog';
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider';
import { ArrowRightCircleIcon, CheckCircleIcon, LoaderIcon } from '@shared/icons';
import { useOnboarding } from '@stores/onboarding';
import { WidgetKinds } from '@stores/widgets';
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 { db } = useStorage();
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 db.createWidget(WidgetKinds.global.hashtag, tag, tag.replace('#', ''));
}
navigate('/auth/onboarding/step-3', { replace: true });
} catch (e) {
await message(e, { title: 'Error', type: '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 backdrop-blur-xl">
{data.map((item: { hashtag: string }) => (
<button
key={item.hashtag}
type="button"
onClick={() => toggleTag(item.hashtag)}
className="inline-flex transform items-center justify-between bg-white/10 px-4 py-2 backdrop-blur-xl hover:bg-white/20"
>
<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 backdrop-blur-xl hover:bg-white/10 focus:outline-none"
>
Skip, you can add later
</Link>
</div>
</div>
</div>
);
}

View File

@@ -1,189 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { UserRelay } from '@app/auth/components/userRelay';
import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
import { ArrowRightCircleIcon, CheckCircleIcon, LoaderIcon } from '@shared/icons';
import { FULL_RELAYS } from '@stores/constants';
import { useOnboarding } from '@stores/onboarding';
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 { db } = useStorage();
const { ndk } = useNDK();
const { status, data } = useQuery(
['relays'],
async () => {
const tmp = new Map<string, string>();
const events = await ndk.fetchEvents({
kinds: [10002],
authors: db.account.follows,
});
if (events) {
events.forEach((event) => {
event.tags.forEach((tag) => {
tmp.set(tag[1], event.pubkey);
});
});
}
return tmp;
},
{
enabled: db.account ? true : false,
refetchOnWindowFocus: false,
}
);
const relaysAsArray = Array.from(data?.keys() || []);
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) => {
try {
setLoading(true);
if (!skip) {
for (const relay of relays) {
await db.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 db.createRelay(relay);
}
}
// update last login
await db.updateLastLogin();
clearStep();
navigate('/', { replace: true });
} catch (e) {
setLoading(false);
console.log('error: ', e);
}
};
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 backdrop-blur-xl">
{status === 'loading' ? (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-4 w-4 animate-spin text-white" />
</div>
) : relaysAsArray.length === 0 ? (
<div className="flex h-full w-full items-center justify-center px-6">
<p className="text-center text-white/50">
Lume couldn&apos;t find any relays from your follows.
<br />
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 backdrop-blur-xl 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 backdrop-blur-xl hover:bg-white/10 focus:outline-none"
>
Skip, use Lume default relays
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,181 +0,0 @@
import { appConfigDir } from '@tauri-apps/api/path';
import { getPublicKey, nip19 } from 'nostr-tools';
import { useState } from 'react';
import { Resolver, useForm } from 'react-hook-form';
import { Link, useNavigate } from 'react-router-dom';
import { Stronghold } from 'tauri-plugin-stronghold-api';
import { useStorage } from '@libs/storage/provider';
import { EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
import { useStronghold } from '@stores/stronghold';
type FormValues = {
password: string;
privkey: string;
};
const resolver: Resolver<FormValues> = async (values) => {
return {
values: values.password ? values : {},
errors: !values.password
? {
password: {
type: 'required',
message: 'This is required.',
},
}
: {},
};
};
export function ResetScreen() {
const navigate = useNavigate();
const setPrivkey = useStronghold((state) => state.setPrivkey);
const [passwordInput, setPasswordInput] = useState('password');
const [loading, setLoading] = useState(false);
const { db } = useStorage();
// toggle private key
const showPassword = () => {
if (passwordInput === 'password') {
setPasswordInput('text');
} else {
setPasswordInput('password');
}
};
const {
register,
setError,
handleSubmit,
formState: { errors, isDirty, isValid },
} = useForm<FormValues>({ resolver });
const onSubmit = async (data: { [x: string]: string }) => {
setLoading(true);
if (data.password.length > 3) {
try {
let privkey = data.privkey;
if (privkey.startsWith('nsec')) {
privkey = nip19.decode(privkey).data as string;
}
const tmpPubkey = getPublicKey(privkey);
if (tmpPubkey !== db.account.pubkey) {
setLoading(false);
setError('password', {
type: 'custom',
message:
"Private key don't match current account store in database, please check again",
});
} else {
// remove old stronghold
await db.secureReset();
// save privkey to secure storage
const dir = await appConfigDir();
const stronghold = await Stronghold.load(
`${dir}/lume.stronghold`,
data.password
);
if (!db.secureDB) db.secureDB = stronghold;
await db.secureSave(db.account.pubkey, db.account.privkey);
// add privkey to state
setPrivkey(db.account.privkey);
// redirect to home
navigate('/auth/unlock', { replace: true });
}
} catch {
setLoading(false);
setError('password', {
type: 'custom',
message: 'Invalid private key',
});
}
} else {
setLoading(false);
setError('password', {
type: 'custom',
message: 'Password is required and must be greater than 3',
});
}
};
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mx-auto w-full max-w-md">
<div className="mb-6 text-center">
<h1 className="text-2xl font-semibold text-white">Reset unlock password</h1>
</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 backdrop-blur-xl placeholder:text-white/50"
/>
</div>
</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 backdrop-blur-xl 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 backdrop-blur-xl hover:bg-white/10"
>
{passwordInput === 'password' ? (
<EyeOffIcon className="h-5 w-5 text-white/50 group-hover:text-white" />
) : (
<EyeOnIcon className="h-5 w-5 text-white/50 group-hover:text-white" />
)}
</button>
</div>
<span className="text-sm text-red-400">
{errors.password && <p>{errors.password.message}</p>}
</span>
</div>
<div className="flex flex-col items-center justify-center">
<button
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>
<Link
to="/auth/unlock"
className="mt-1 inline-flex h-11 w-full items-center justify-center rounded-lg text-center text-white/50 hover:bg-white/10"
>
Back
</Link>
</div>
</form>
</div>
</div>
);
}

View File

@@ -1,17 +1,14 @@
import { appConfigDir } from '@tauri-apps/api/path';
import { useState } from 'react';
import { Resolver, useForm } from 'react-hook-form';
import { Link, useNavigate } from 'react-router-dom';
import { Stronghold } from 'tauri-plugin-stronghold-api';
import { useNavigate } from 'react-router-dom';
import { User } from '@app/auth/components/user';
import { useStorage } from '@libs/storage/provider';
import { ArrowRightCircleIcon, EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
import { EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
import { useStronghold } from '@stores/stronghold';
import { useAccount } from '@utils/hooks/useAccount';
import { useSecureStorage } from '@utils/hooks/useSecureStorage';
type FormValues = {
password: string;
};
@@ -32,12 +29,26 @@ const resolver: Resolver<FormValues> = async (values) => {
export function UnlockScreen() {
const navigate = useNavigate();
const setPrivkey = useStronghold((state) => state.setPrivkey);
const [showPassword, setShowPassword] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
const [passwordInput, setPasswordInput] = useState('password');
const [loading, setLoading] = useState(false);
const [setPrivkey, setPassword] = useStronghold((state) => [
state.setPrivkey,
state.setPassword,
]);
const { account } = useAccount();
const { load } = useSecureStorage();
// toggle private key
const showPassword = () => {
if (passwordInput === 'password') {
setPasswordInput('text');
} else {
setPasswordInput('password');
}
};
const { db } = useStorage();
const {
register,
setError,
@@ -46,24 +57,29 @@ export function UnlockScreen() {
} = useForm<FormValues>({ resolver });
const onSubmit = async (data: { [x: string]: string }) => {
try {
setLoading(true);
setLoading(true);
if (data.password.length > 3) {
// add password to local state
setPassword(data.password);
const dir = await appConfigDir();
const stronghold = await Stronghold.load(`${dir}/lume.stronghold`, data.password);
if (!db.secureDB) db.secureDB = stronghold;
const privkey = await db.secureLoad(db.account.pubkey);
setPrivkey(privkey);
// redirect to home
navigate('/', { replace: true });
} catch (e) {
// load private in secure storage
try {
const privkey = await load(account.pubkey, data.password);
setPrivkey(privkey);
// redirect to home
navigate('/', { replace: true });
} catch {
setLoading(false);
setError('password', {
type: 'custom',
message: 'Wrong password',
});
}
} else {
setLoading(false);
setError('password', {
type: 'custom',
message: e,
message: 'Password is required and must be greater than 3',
});
}
};
@@ -71,65 +87,59 @@ export function UnlockScreen() {
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mx-auto w-full max-w-md">
<div className="mb-6 text-center">
<h1 className="text-2xl font-semibold text-white">Enter password to unlock</h1>
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-zinc-100">
Enter password to unlock
</h1>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col">
<div className="flex flex-col rounded-lg bg-white/5">
<div className="w-full rounded-t-lg border-b border-white/10 bg-white/5 p-4">
<User pubkey={db.account.pubkey} />
<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 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="relative">
<input
{...register('password', { required: true, minLength: 4 })}
type={showPassword ? 'text' : 'password'}
placeholder="Password"
className="relative h-12 w-full rounded-b-lg bg-white/10 py-1 text-center text-white !outline-none backdrop-blur-xl placeholder:text-white/50"
/>
<div className="flex items-center justify-center">
<button
type="button"
onClick={() => setShowPassword((prev) => !prev)}
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 backdrop-blur-xl hover:bg-white/10"
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"
>
{showPassword ? (
<EyeOffIcon className="h-5 w-5 text-white/50 group-hover:text-white" />
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
) : (
<EyeOnIcon className="h-5 w-5 text-white/50 group-hover:text-white" />
'Continue →'
)}
</button>
</div>
</div>
<div className="flex flex-col items-center justify-center">
<span className="mb-3 text-sm text-red-400">
{errors.password && <p>{errors.password.message}</p>}
</span>
<button
type="submit"
disabled={!isDirty || !isValid}
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none disabled:opacity-50"
>
{loading ? (
<>
<span className="w-5" />
<span>Decryting...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
</>
) : (
<>
<span className="w-5" />
<span>Continue</span>
<ArrowRightCircleIcon className="h-5 w-5" />
</>
)}
</button>
<Link
to="/auth/reset"
className="mt-1 inline-flex h-11 w-full items-center justify-center rounded-lg text-center text-white/50 hover:bg-white/10"
>
Reset password
</Link>
</div>
</form>
</form>
</div>
</div>
</div>
);

View File

@@ -1,47 +1,29 @@
import { LogicalSize, getCurrent } from '@tauri-apps/api/window';
import { useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Frame } from '@shared/frame';
import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
export function WelcomeScreen() {
const appWindow = getCurrent();
async function setWindow() {
await appWindow.setSize(new LogicalSize(400, 500));
await appWindow.setResizable(false);
await appWindow.center();
}
async function resetWindow() {
await appWindow.setSize(new LogicalSize(1080, 800));
await appWindow.setResizable(false);
await appWindow.center();
}
useEffect(() => {
setWindow();
return () => {
resetWindow();
};
}, []);
return (
<Frame className="flex h-screen w-full flex-col justify-between">
<div className="flex flex-col gap-10 pt-16">
<div className="flex flex-col gap-1.5 text-center">
<h1 className="text-3xl font-semibold text-white">Welcome to Lume</h1>
<p className="mx-auto w-2/3 leading-tight text-white/50">
Let&apos;s get you up and connecting with all peoples around the world on
Nostr
</p>
<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>
</h3>
</div>
<div className="inline-flex w-full flex-col items-center gap-2 px-4 pb-10">
<div className="mt-auto flex w-full flex-col gap-2 px-4 py-4">
<Link
to="/auth/import"
className="inline-flex h-11 w-2/3 items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium text-zinc-100 hover:bg-fuchsia-600"
>
<span className="w-5" />
<span>Login with private key</span>
@@ -49,15 +31,24 @@ export function WelcomeScreen() {
</Link>
<Link
to="/auth/create"
className="inline-flex h-11 w-2/3 items-center justify-center gap-2 rounded-lg bg-white/10 px-6 font-medium leading-none text-white backdrop-blur-xl hover:bg-white/20 focus:outline-none"
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"
>
Create new key
</Link>
</div>
</div>
<div className="flex flex-1 items-end justify-center pb-10">
<img src="/lume.png" alt="lume" className="h-auto w-1/3" />
</div>
</Frame>
<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>
);
}

View File

@@ -0,0 +1,58 @@
import { Popover, Transition } from '@headlessui/react';
import { Fragment } from 'react';
import { MutedItem } from '@app/channel/components/mutedItem';
import { MuteIcon } from '@shared/icons';
export function ChannelBlackList({ blacklist }: { blacklist: any }) {
return (
<Popover className="relative">
{({ open }) => (
<>
<Popover.Button
className={`group inline-flex h-8 w-8 items-center justify-center rounded-md ring-2 ring-zinc-950 focus:outline-none ${
open ? 'bg-zinc-800 hover:bg-zinc-700' : 'bg-zinc-900 hover:bg-zinc-800'
}`}
>
<MuteIcon
width={16}
height={16}
className="text-zinc-400 group-hover:text-zinc-100"
/>
</Popover.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute right-0 z-10 mt-1 w-screen max-w-xs transform px-4 sm:px-0">
<div className="shadow-popover flex flex-col gap-2 overflow-hidden rounded-lg border border-zinc-800 bg-zinc-900">
<div className="h-min w-full shrink-0 border-b border-zinc-800 p-3">
<div className="flex flex-col gap-0.5">
<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">
Currently, unmute only affect locally, when you move to new client,
muted list will loaded again
</p>
</div>
</div>
<div className="flex flex-col gap-2 px-3 pb-3 pt-1">
{blacklist.map((item: any) => (
<MutedItem key={item.id} data={item} />
))}
</div>
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
);
}

View File

@@ -0,0 +1,269 @@
import { Dialog, Transition } from '@headlessui/react';
import { NDKEvent, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Fragment, useContext, useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { useNDK } from '@libs/ndk/provider';
import { createChannel } from '@libs/storage';
import { AvatarUploader } from '@shared/avatarUploader';
import { CancelIcon, LoaderIcon, PlusIcon } from '@shared/icons';
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { dateToUnix } from '@utils/date';
import { useAccount } from '@utils/hooks/useAccount';
export function ChannelCreateModal() {
const { ndk } = useNDK();
const queryClient = useQueryClient();
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [image, setImage] = useState(DEFAULT_AVATAR);
const { account } = useAccount();
const closeModal = () => {
setIsOpen(false);
};
const openModal = () => {
setIsOpen(true);
};
const {
register,
handleSubmit,
reset,
setValue,
formState: { isDirty, isValid },
} = useForm();
const addChannel = useMutation({
mutationFn: (event: any) => {
return createChannel(
event.id,
event.pubkey,
event.name,
event.picture,
event.about,
event.created_at
);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['channels'] });
},
});
const onSubmit = (data: any) => {
setLoading(true);
try {
const signer = new NDKPrivateKeySigner(account.privkey);
ndk.signer = signer;
const event = new NDKEvent(ndk);
// build event
event.content = JSON.stringify(data);
event.kind = 40;
event.created_at = dateToUnix();
event.pubkey = account.pubkey;
event.tags = [];
// publish event
event.publish();
// insert to database
addChannel.mutate({
...event,
name: data.name,
picture: data.picture,
about: data.about,
});
// reset form
reset();
setTimeout(() => {
// close modal
setIsOpen(false);
// redirect to channel page
navigate(`/app/channel/${event.id}`);
}, 1000);
} catch (e) {
console.log('error: ', e);
}
};
useEffect(() => {
setValue('picture', image);
}, [setValue, image]);
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 width={12} height={12} className="text-zinc-500" />
</div>
<div>
<h5 className="font-medium text-zinc-400">Create channel</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-2">
<div className="flex items-center justify-between">
<Dialog.Title
as="h3"
className="text-lg font-semibold leading-none text-zinc-100"
>
Create channel
</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={20} height={20} className="text-zinc-300" />
</button>
</div>
<Dialog.Description className="text-sm leading-tight text-zinc-400">
Channels are freedom square, everyone can speech freely, no one can
stop you or deceive what to speech
</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('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"
/>
<div className="flex flex-col gap-1">
<span className="text-sm font-medium uppercase tracking-wider text-zinc-400">
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">
<Image
src={image}
fallback={DEFAULT_AVATAR}
alt="channel picture"
className="relative z-10 h-11 w-11 rounded-md"
/>
<div className="absolute bottom-3 right-3 z-10">
<AvatarUploader setPicture={setImage} />
</div>
</div>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="name"
className="text-sm font-semibold uppercase tracking-wider text-zinc-400"
>
Channel name *
</label>
<input
type={'text'}
{...register('name', {
required: true,
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"
/>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="about"
className="text-sm font-semibold uppercase tracking-wider text-zinc-400"
>
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"
/>
</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">
Encrypted
</span>
<p className="w-4/5 text-sm leading-none text-zinc-400">
All messages are encrypted and only invited members can view and
send message
</p>
</div>
<div>
<button
type="button"
disabled
className="relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent bg-zinc-900 transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-fuchsia-600 focus:ring-offset-2"
role="switch"
aria-checked="false"
>
<span className="pointer-events-none inline-block h-5 w-5 translate-x-0 transform rounded-full bg-zinc-600 shadow ring-0 transition duration-200 ease-in-out" />
</button>
</div>
</div>
<div>
<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"
>
{loading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-zinc-100" />
) : (
'Create channel →'
)}
</button>
</div>
</form>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition>
</>
);
}

View File

@@ -0,0 +1,34 @@
import { NavLink } from 'react-router-dom';
import { twMerge } from 'tailwind-merge';
import { useChannelProfile } from '@app/channel/hooks/useChannelProfile';
export function ChannelsListItem({ data }: { data: any }) {
const channel = useChannelProfile(data.event_id);
return (
<NavLink
to={`/app/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' : ''
)
}
>
<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>
</div>
<div className="inline-flex w-full items-center justify-between">
<h5 className="truncate font-medium text-zinc-200">{channel?.name}</h5>
<div className="flex items-center">
{data.new_messages && (
<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">
{data.new_messages}
</span>
)}
</div>
</div>
</NavLink>
);
}

View File

@@ -0,0 +1,52 @@
import { useQuery } from '@tanstack/react-query';
import { ChannelCreateModal } from '@app/channel/components/createModal';
import { ChannelsListItem } from '@app/channel/components/item';
import { getChannels } from '@libs/storage';
export function ChannelsList() {
const {
status,
data: channels,
isFetching,
} = useQuery(
['channels'],
async () => {
return await getChannels();
},
{
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
}
);
return (
<div className="flex flex-col">
{status === 'loading' ? (
<>
<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.5 w-full animate-pulse rounded-sm bg-zinc-800" />
</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.5 w-full animate-pulse rounded-sm bg-zinc-800" />
</div>
</>
) : (
channels.map((item: { event_id: string }) => (
<ChannelsListItem key={item.event_id} data={item} />
))
)}
{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.5 w-full animate-pulse rounded-sm bg-zinc-800" />
</div>
)}
<ChannelCreateModal />
</div>
);
}

View File

@@ -0,0 +1,24 @@
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
export function Member({ pubkey }: { pubkey: string }) {
const { user, isError, isLoading } = useProfile(pubkey);
return (
<>
{isError || isLoading ? (
<div className="h-7 w-7 animate-pulse rounded bg-zinc-800" />
) : (
<Image
className="inline-block h-7 w-7 rounded"
src={user?.image}
fallback={DEFAULT_AVATAR}
alt={pubkey}
/>
)}
</>
);
}

View File

@@ -0,0 +1,28 @@
import { useQuery } from '@tanstack/react-query';
import { Member } from '@app/channel/components/member';
import { getChannelUsers } from '@libs/storage';
export function ChannelMembers({ id }: { id: string }) {
const { status, data, isFetching } = useQuery(['channel-members', id], async () => {
return await getChannelUsers(id);
});
return (
<div className="mt-3">
<h5 className="border-b border-zinc-900 pb-1 font-semibold text-zinc-200">
Members
</h5>
<div className="mt-3 flex w-full flex-wrap gap-1.5">
{status === 'loading' || isFetching ? (
<p>Loading...</p>
) : (
data.map((member: { pubkey: string }) => (
<Member key={member.pubkey} pubkey={member.pubkey} />
))
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,115 @@
import { NDKEvent, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import { useContext, useState } from 'react';
import { UserReply } from '@app/channel/components/messages/userReply';
import { useNDK } from '@libs/ndk/provider';
import { CancelIcon, EnterIcon } from '@shared/icons';
import { MediaUploader } from '@shared/mediaUploader';
import { useChannelMessages } from '@stores/channels';
import { dateToUnix } from '@utils/date';
import { useAccount } from '@utils/hooks/useAccount';
export function ChannelMessageForm({ channelID }: { channelID: string }) {
const { ndk } = useNDK();
const [value, setValue] = useState('');
const [replyTo, closeReply] = useChannelMessages((state: any) => [
state.replyTo,
state.closeReply,
]);
const { account } = useAccount();
const submit = () => {
let tags: string[][];
if (replyTo.id !== null) {
tags = [
['e', channelID, '', 'root'],
['e', replyTo.id, '', 'reply'],
['p', replyTo.pubkey, ''],
];
} else {
tags = [['e', channelID, '', 'root']];
}
const signer = new NDKPrivateKeySigner(account.privkey);
ndk.signer = signer;
const event = new NDKEvent(ndk);
// build event
event.content = value;
event.kind = 42;
event.created_at = dateToUnix();
event.pubkey = account.pubkey;
event.tags = tags;
// publish event
event.publish();
// reset state
setValue('');
};
const handleEnterPress = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
submit();
}
};
const stopReply = () => {
closeReply();
};
return (
<div className={`relative w-full ${replyTo.id ? 'h-36' : 'h-24'}`}>
{replyTo.id && (
<div className="absolute left-0 top-0 z-10 h-16 w-full p-[2px]">
<div className="flex h-full w-full items-center justify-between rounded-t-md border-b border-zinc-700/70 bg-zinc-900 px-3">
<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>
</div>
<button
type="button"
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" />
</button>
</div>
</div>
)}
<textarea
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleEnterPress}
spellCheck={false}
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`}
/>
<div className="absolute bottom-0 right-2 h-11">
<div className="flex h-full items-center justify-end gap-3 text-zinc-500">
<MediaUploader setState={setValue} />
<button
type="button"
onClick={submit}
className="inline-flex items-center gap-1 text-sm leading-none"
>
<EnterIcon width={14} height={14} className="" />
Send
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,132 @@
import { Dialog, Transition } from '@headlessui/react';
import { NDKEvent, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import { Fragment, useState } from 'react';
import { useNDK } from '@libs/ndk/provider';
import { CancelIcon, HideIcon } from '@shared/icons';
import { useChannelMessages } from '@stores/channels';
import { dateToUnix } from '@utils/date';
import { useAccount } from '@utils/hooks/useAccount';
export function MessageHideButton({ id }: { id: string }) {
const { ndk } = useNDK();
const hide = useChannelMessages((state: any) => state.hideMessage);
const [isOpen, setIsOpen] = useState(false);
const { account } = useAccount();
const closeModal = () => {
setIsOpen(false);
};
const openModal = () => {
setIsOpen(true);
};
const hideMessage = () => {
const signer = new NDKPrivateKeySigner(account.privkey);
ndk.signer = signer;
const event = new NDKEvent(ndk);
// build event
event.content = '';
event.kind = 43;
event.created_at = dateToUnix();
event.pubkey = account.pubkey;
event.tags = [['e', id]];
// publish event
event.publish();
// update state
hide(id);
// close modal
closeModal();
};
return (
<>
<button
type="button"
onClick={openModal}
className="inline-flex h-7 w-7 items-center justify-center rounded hover:bg-zinc-800"
>
<HideIcon width={16} height={16} className="text-zinc-200" />
</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 rounded-lg border border-zinc-800 bg-zinc-900">
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-6">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Dialog.Title
as="h3"
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-xl font-semibold leading-none text-transparent"
>
Are you sure!
</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={20} height={20} className="text-zinc-300" />
</button>
</div>
<Dialog.Description className="leading-tight text-zinc-400">
This message will be hidden from your feed.
</Dialog.Description>
</div>
</div>
<div className="flex h-full w-full flex-col items-end justify-center overflow-y-auto px-5 py-2.5">
<div className="flex items-center gap-2">
<button
type="button"
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"
>
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"
>
Confirm
</button>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition>
</>
);
}

View File

@@ -0,0 +1,56 @@
import { MessageHideButton } from '@app/channel/components/messages/hideButton';
import { MessageMuteButton } from '@app/channel/components/messages/muteButton';
import { MessageReplyButton } from '@app/channel/components/messages/replyButton';
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 { User } from '@shared/user';
import { parser } from '@utils/parser';
import { LumeEvent } from '@utils/types';
export function ChannelMessageItem({ data }: { data: LumeEvent }) {
const content = parser(data);
return (
<div className="group relative flex h-min min-h-min w-full select-text flex-col px-5 py-3 hover:bg-black/20">
<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">
{content.parsed}
</p>
{Array.isArray(content.images) && content.images.length ? (
<ImagePreview urls={content.images} />
) : (
<></>
)}
{Array.isArray(content.videos) && content.videos.length ? (
<VideoPreview urls={content.videos} />
) : (
<></>
)}
{Array.isArray(content.links) && content.links.length ? (
<LinkPreview urls={content.links} />
) : (
<></>
)}
{Array.isArray(content.notes) && content.notes.length ? (
content.notes.map((note: string) => <MentionNote key={note} id={note} />)
) : (
<></>
)}
</div>
</div>
<div className="absolute -top-4 right-4 z-10 hidden group-hover:inline-flex">
<div className="inline-flex h-8 items-center justify-center gap-1.5 rounded bg-zinc-900 px-0.5 shadow-md shadow-black/20 ring-1 ring-zinc-800">
<MessageReplyButton id={data.id} pubkey={data.pubkey} content={data.content} />
<MessageHideButton id={data.id} />
<MessageMuteButton pubkey={data.pubkey} />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,132 @@
import { Dialog, Transition } from '@headlessui/react';
import { NDKEvent, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import { Fragment, useContext, useState } from 'react';
import { useNDK } from '@libs/ndk/provider';
import { CancelIcon, MuteIcon } from '@shared/icons';
import { useChannelMessages } from '@stores/channels';
import { dateToUnix } from '@utils/date';
import { useAccount } from '@utils/hooks/useAccount';
export function MessageMuteButton({ pubkey }: { pubkey: string }) {
const { ndk } = useNDK();
const mute = useChannelMessages((state: any) => state.muteUser);
const [isOpen, setIsOpen] = useState(false);
const { account } = useAccount();
const closeModal = () => {
setIsOpen(false);
};
const openModal = () => {
setIsOpen(true);
};
const muteUser = () => {
const signer = new NDKPrivateKeySigner(account.privkey);
ndk.signer = signer;
const event = new NDKEvent(ndk);
// build event
event.content = '';
event.kind = 44;
event.created_at = dateToUnix();
event.pubkey = account.pubkey;
event.tags = [['p', pubkey]];
// publish event
event.publish();
// update state
mute(pubkey);
// close modal
closeModal();
};
return (
<>
<button
type="button"
onClick={() => openModal()}
className="inline-flex h-7 w-7 items-center justify-center rounded hover:bg-zinc-800"
>
<MuteIcon width={16} height={16} className="text-zinc-200" />
</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 rounded-lg border border-zinc-800 bg-zinc-900">
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-6">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Dialog.Title
as="h3"
className="bg-gradient-to-br from-zinc-200 to-zinc-400 bg-clip-text text-xl font-semibold leading-none text-transparent"
>
Are you sure!
</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={20} height={20} className="text-zinc-300" />
</button>
</div>
<Dialog.Description className="leading-tight text-zinc-400">
You will no longer see messages from this user.
</Dialog.Description>
</div>
</div>
<div className="flex h-full w-full flex-col items-end justify-center overflow-y-auto px-5 py-2.5">
<div className="flex items-center gap-2">
<button
type="button"
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"
>
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"
>
Confirm
</button>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition>
</>
);
}

View File

@@ -0,0 +1,29 @@
import { ReplyMessageIcon } from '@shared/icons';
import { useChannelMessages } from '@stores/channels';
export function MessageReplyButton({
id,
pubkey,
content,
}: {
id: string;
pubkey: string;
content: string;
}) {
const openReply = useChannelMessages((state: any) => state.openReply);
const createReply = () => {
openReply(id, pubkey, content);
};
return (
<button
type="button"
onClick={() => createReply()}
className="inline-flex h-7 w-7 items-center justify-center rounded hover:bg-zinc-800"
>
<ReplyMessageIcon width={16} height={16} className="text-zinc-200" />
</button>
);
}

View File

@@ -0,0 +1,40 @@
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
export function ChannelMessageUserMute({ pubkey }: { pubkey: string }) {
const { user, isError, isLoading } = useProfile(pubkey);
return (
<div className="flex items-center gap-3">
{isError || isLoading ? (
<>
<div className="relative h-11 w-11 shrink animate-pulse rounded-md bg-zinc-800" />
<div className="flex w-full flex-1 items-center justify-between">
<div className="flex items-baseline gap-2 text-base">
<div className="h-4 w-20 animate-pulse rounded bg-zinc-800" />
</div>
</div>
</>
) : (
<>
<div className="relative h-11 w-11 shrink-0 rounded-md">
<Image
src={user?.image}
fallback={DEFAULT_AVATAR}
alt={pubkey}
className="h-11 w-11 rounded-md object-cover"
/>
</div>
<div className="flex w-full flex-1 items-center justify-between">
<span className="leading-none text-zinc-300">
You has been muted this user
</span>
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,35 @@
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
import { shortenKey } from '@utils/shortenKey';
export function UserReply({ pubkey }: { pubkey: string }) {
const { user, isError, isLoading } = useProfile(pubkey);
return (
<div className="group flex items-start gap-2">
{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" />
</>
) : (
<>
<div className="relative h-9 w-9 shrink overflow-hidden rounded">
<Image
src={user?.image}
fallback={DEFAULT_AVATAR}
alt={pubkey}
className="h-9 w-9 rounded object-cover"
/>
</div>
<span className="max-w-[10rem] truncate text-sm font-medium leading-none text-zinc-500">
Replying to {user?.name || shortenKey(pubkey)}
</span>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,44 @@
import { nip19 } from 'nostr-tools';
import { useChannelProfile } from '@app/channel/hooks/useChannelProfile';
import { CopyIcon } from '@shared/icons';
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
export function ChannelMetadata({ id }: { id: string }) {
const metadata = useChannelProfile(id);
const noteID = id ? nip19.noteEncode(id) : null;
const copyNoteID = async () => {
const { writeText } = await import('@tauri-apps/api/clipboard');
if (noteID) {
await writeText(noteID);
}
};
return (
<div className="flex flex-col gap-2">
<div className="relative h-11 w-11 shrink-0 rounded-md">
<Image
src={metadata?.picture}
fallback={DEFAULT_AVATAR}
alt={id}
className="h-11 w-11 rounded-md bg-zinc-900 object-contain"
/>
</div>
<div className="flex flex-col gap-2">
<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" />
</button>
</div>
<p className="leading-tight text-zinc-400">
{metadata?.about || (noteID && `${noteID.substring(0, 24)}...`)}
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,85 @@
import { useState } from 'react';
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
import { shortenKey } from '@utils/shortenKey';
export function MutedItem({ data }: { data: any }) {
const { user, isError, isLoading } = useProfile(data.content);
const [status, setStatus] = useState(data.status);
const unmute = async () => {
const { updateItemInBlacklist } = await import('@libs/storage');
const res = await updateItemInBlacklist(data.content, 0);
if (res) {
setStatus(0);
}
};
const mute = async () => {
const { updateItemInBlacklist } = await import('@libs/storage');
const res = await updateItemInBlacklist(data.content, 1);
if (res) {
setStatus(1);
}
};
return (
<div className="flex items-center justify-between">
{isError || isLoading ? (
<>
<div className="flex items-center gap-1.5">
<div className="relative h-9 w-9 shrink animate-pulse rounded-md bg-zinc-800" />
<div className="flex w-full flex-1 flex-col items-start gap-0.5 text-start">
<div className="h-3 w-16 animate-pulse bg-zinc-800" />
<div className="h-2 w-10 animate-pulse bg-zinc-800" />
</div>
</div>
</>
) : (
<>
<div className="flex items-center gap-1.5">
<div className="relative h-9 w-9 shrink rounded-md">
<Image
src={user?.image}
fallback={DEFAULT_AVATAR}
alt={data.content}
className="h-9 w-9 rounded-md object-cover"
/>
</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">
{user?.displayName || user?.name || 'Pleb'}
</span>
<span className="text-base leading-none text-zinc-400">
{shortenKey(data.content)}
</span>
</div>
</div>
<div>
{status === 1 ? (
<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"
>
Unmute
</button>
) : (
<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"
>
Mute
</button>
)}
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,36 @@
import { useQuery } from '@tanstack/react-query';
import { useEffect } from 'react';
import { useNDK } from '@libs/ndk/provider';
import { getChannel, updateChannelMetadata } from '@libs/storage';
export function useChannelProfile(id: string) {
const { ndk } = useNDK();
const { data } = useQuery(['channel-metadata', id], async () => {
return await getChannel(id);
});
useEffect(() => {
// subscribe to channel
const sub = ndk.subscribe(
{
'#e': [id],
kinds: [41],
},
{
closeOnEose: true,
}
);
sub.addListener('event', (event: { content: string }) => {
// update in local database
updateChannelMetadata(id, event.content);
});
return () => {
sub.stop();
};
}, []);
return data;
}

149
src/app/channel/index.tsx Normal file
View File

@@ -0,0 +1,149 @@
import { useCallback, useContext, useEffect, useLayoutEffect, useRef } from 'react';
import { useParams } from 'react-router-dom';
import { Virtuoso } from 'react-virtuoso';
import { ChannelMembers } from '@app/channel/components/members';
import { ChannelMessageForm } from '@app/channel/components/messages/form';
import { ChannelMetadata } from '@app/channel/components/metadata';
import { useNDK } from '@libs/ndk/provider';
import { useChannelMessages } from '@stores/channels';
import { dateToUnix, getHourAgo } from '@utils/date';
import { LumeEvent } from '@utils/types';
import { ChannelMessageItem } from './components/messages/item';
const now = new Date();
const Header = (
<div className="relative py-4">
<div className="absolute inset-0 flex items-center">
<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">
{getHourAgo(24, now).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</div>
</div>
</div>
);
const Empty = (
<div className="flex flex-col gap-1 text-center">
<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">
Be the first to share a message in this channel.
</p>
</div>
);
export function ChannelScreen() {
const { ndk } = useNDK();
const virtuosoRef = useRef(null);
const { id } = useParams();
const [messages, fetchMessages, addMessage, clearMessages] = useChannelMessages(
(state: any) => [state.messages, state.fetch, state.add, state.clear]
);
useLayoutEffect(() => {
fetchMessages(id);
}, [fetchMessages]);
useEffect(() => {
// subscribe to channel
const sub = ndk.subscribe(
{
'#e': [id],
kinds: [42],
since: dateToUnix(),
},
{ closeOnEose: false }
);
sub.addListener('event', (event: LumeEvent) => {
addMessage(id, event);
});
return () => {
clearMessages();
sub.stop();
};
}, []);
const itemContent: any = useCallback(
(index: string | number) => {
return <ChannelMessageItem data={messages[index]} />;
},
[messages]
);
const computeItemKey = useCallback(
(index: string | number) => {
return messages[index].event_id;
},
[messages]
);
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">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">
<div className="h-full w-full flex-1">
{!messages ? (
<p>Loading...</p>
) : (
<Virtuoso
ref={virtuosoRef}
data={messages}
itemContent={itemContent}
computeItemKey={computeItemKey}
initialTopMostItemIndex={messages.length - 1}
alignToBottom={true}
followOutput={true}
overscan={50}
increaseViewportBy={{ top: 200, bottom: 200 }}
className="scrollbar-hide overflow-y-auto"
components={{
Header: () => Header,
EmptyPlaceholder: () => Empty,
}}
/>
)}
</div>
<div className="z-50 shrink-0 rounded-b-xl border-t border-zinc-800 bg-zinc-900 p-3 px-5">
<ChannelMessageForm channelID={id} />
</div>
</div>
</div>
</div>
<div className="col-span-1 flex flex-col">
<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="flex flex-col gap-3 p-3">
<ChannelMetadata id={id} />
<ChannelMembers id={id} />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,61 @@
import { NavLink } from 'react-router-dom';
import { twMerge } from 'tailwind-merge';
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
import { shortenKey } from '@utils/shortenKey';
export function ChatsListItem({ data }: { data: any }) {
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>
);
}
return (
<NavLink
to={`/app/chat/${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' : ''
)
}
>
<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>
<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">
{data.new_messages}
</span>
)}
</div>
</div>
</NavLink>
);
}

View File

@@ -0,0 +1,64 @@
import { useQuery } from '@tanstack/react-query';
import { ChatsListItem } from '@app/chat/components/item';
import { NewMessageModal } from '@app/chat/components/modal';
import { ChatsListSelfItem } from '@app/chat/components/self';
import { getChats } from '@libs/storage';
import { useAccount } from '@utils/hooks/useAccount';
export function ChatsList() {
const { account } = useAccount();
const {
status,
data: chats,
isFetching,
} = useQuery(['chats'], async () => {
const chats = await getChats();
const sorted = chats.sort(
(a, b) => parseInt(a.new_messages) - parseInt(b.new_messages)
);
return sorted;
});
if (status === 'loading') {
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>
<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>
</div>
);
}
return (
<div className="flex flex-col">
<NewMessageModal />
{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>
)}
{chats.map((item) => {
if (account.pubkey !== item.sender_pubkey) {
return <ChatsListItem key={item.sender_pubkey} data={item} />;
}
})}
{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>
)}
</div>
);
}

View File

@@ -1,12 +1,10 @@
import { nip04 } from 'nostr-tools';
import { useCallback, useState } from 'react';
import TextareaAutosize from 'react-textarea-autosize';
import { MediaUploader } from '@app/chats/components/messages/mediaUploader';
import { EnterIcon } from '@shared/icons';
import { MediaUploader } from '@shared/mediaUploader';
import { useNostr } from '@utils/hooks/useNostr';
import { usePublish } from '@utils/hooks/usePublish';
export function ChatMessageForm({
receiverPubkey,
@@ -16,7 +14,7 @@ export function ChatMessageForm({
userPubkey: string;
userPrivkey: string;
}) {
const { publish } = useNostr();
const publish = usePublish();
const [value, setValue] = useState('');
const encryptMessage = useCallback(async () => {
@@ -36,7 +34,7 @@ export function ChatMessageForm({
const handleEnterPress = (e: {
key: string;
shiftKey: KeyboardEvent['shiftKey'];
shiftKey: any;
preventDefault: () => void;
}) => {
if (e.key === 'Enter' && !e.shiftKey) {
@@ -46,28 +44,27 @@ export function ChatMessageForm({
};
return (
<div className="flex w-full items-center justify-between rounded-md bg-white/20 px-3">
<TextareaAutosize
<div className="relative h-11 w-full">
<input
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleEnterPress}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
placeholder="Message"
className="min-h-[44px] flex-1 resize-none bg-transparent py-3 text-white !outline-none placeholder:text-white"
className="relative h-11 w-full resize-none rounded-md bg-zinc-800 px-5 !outline-none placeholder:text-zinc-500"
/>
<div className="inline-flex items-center gap-2">
<MediaUploader setState={setValue} />
<button
type="button"
onClick={submit}
className="inline-flex items-center gap-1.5 text-sm font-medium leading-none text-white/50"
>
<EnterIcon className="h-5 w-5" />
Send
</button>
<div className="absolute right-2 top-0 h-11">
<div className="flex h-full items-center justify-end gap-3 text-zinc-500">
<MediaUploader setState={setValue} />
<button
type="button"
onClick={submit}
className="inline-flex items-center gap-1 text-sm leading-none"
>
<EnterIcon width={14} height={14} className="" />
Send
</button>
</div>
</div>
</div>
);

View File

@@ -0,0 +1,45 @@
import { useDecryptMessage } from '@app/chat/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 { User } from '@shared/user';
import { parser } from '@utils/parser';
export function ChatMessageItem({
data,
userPubkey,
userPrivkey,
}: {
data: any;
userPubkey: string;
userPrivkey: string;
}) {
const decryptedContent = useDecryptMessage(data, userPubkey, userPrivkey);
// if we have decrypted content, use it instead of the encrypted content
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 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>
{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

@@ -0,0 +1,123 @@
import { Dialog, Transition } from '@headlessui/react';
import { Fragment, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { User } from '@app/auth/components/user';
import { CancelIcon, LoaderIcon, PlusIcon } from '@shared/icons';
import { useAccount } from '@utils/hooks/useAccount';
export function NewMessageModal() {
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false);
const { status, account } = useAccount();
const follows = account ? JSON.parse(account.follows) : [];
const closeModal = () => {
setIsOpen(false);
};
const openModal = () => {
setIsOpen(true);
};
const openChat = (pubkey: string) => {
closeModal();
navigate(`/app/chat/${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 width={12} height={12} className="text-zinc-500" />
</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>
<button
type="button"
onClick={closeModal}
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
>
<CancelIcon width={20} height={20} className="text-zinc-300" />
</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="inline-flex w-max translate-x-20 transform rounded border-t border-zinc-600/50 bg-zinc-700 px-3 py-1.5 text-sm transition-transform duration-150 ease-in-out hover:bg-fuchsia-500 group-hover:translate-x-0"
>
Chat
</button>
</div>
</div>
))
)}
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition>
</>
);
}

View File

@@ -0,0 +1,52 @@
import { NavLink } from 'react-router-dom';
import { twMerge } from 'tailwind-merge';
import { Image } from '@shared/image';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
import { shortenKey } from '@utils/shortenKey';
export function ChatsListSelfItem({ data }: { data: any }) {
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>
);
}
return (
<NavLink
to={`/app/chat/${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' : ''
)
}
>
<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>
<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>
<span className="text-zinc-500">(you)</span>
</div>
</NavLink>
);
}

View File

@@ -1,10 +1,11 @@
import { Link } from 'react-router-dom';
import { Image } from '@shared/image';
import { NIP05 } from '@shared/nip05';
import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
import { displayNpub } from '@utils/shortenKey';
import { shortenKey } from '@utils/shortenKey';
export function ChatSidebar({ pubkey }: { pubkey: string }) {
const { user } = useProfile(pubkey);
@@ -15,6 +16,7 @@ export function ChatSidebar({ pubkey }: { pubkey: string }) {
<div className="relative h-11 w-11 shrink rounded-md">
<Image
src={user?.picture || user?.image}
fallback={DEFAULT_AVATAR}
alt={pubkey}
className="h-11 w-11 rounded-md object-cover"
/>
@@ -22,25 +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?.display_name || user?.name}
{user?.displayName || user?.name}
</h3>
{user?.nip05 ? (
<NIP05
pubkey={pubkey}
nip05={user?.nip05}
className="leading-none text-white/50"
/>
) : (
<span className="leading-none text-white/50">
{displayNpub(pubkey, 16)}
</span>
)}
<h5 className="leading-none text-zinc-400">
{user?.nip05 || shortenKey(pubkey)}
</h5>
</div>
<div>
<p className="leading-tight">{user?.bio || user?.about}</p>
<Link
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 backdrop-blur-xl hover:bg-fuchsia-500"
to={`/app/user/${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"
>
View full profile
</Link>

View File

@@ -0,0 +1,19 @@
import { nip04 } from 'nostr-tools';
import { useEffect, useState } from 'react';
export function useDecryptMessage(data: any, userPubkey: string, userPriv: string) {
const [content, setContent] = useState(data.content);
useEffect(() => {
async function decrypt() {
const pubkey =
userPubkey === data.sender_pubkey ? data.receiver_pubkey : data.sender_pubkey;
const result = await nip04.decrypt(userPriv, pubkey, data.content);
setContent(result);
}
decrypt().catch(console.error);
}, []);
return content;
}

View File

@@ -1,42 +1,45 @@
import { NDKSubscription } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useCallback, useEffect, useRef } from 'react';
import { useParams } from 'react-router-dom';
import { Virtuoso } from 'react-virtuoso';
import { ChatMessageForm } from '@app/chats/components/messages/form';
import { ChatMessageItem } from '@app/chats/components/messages/item';
import { ChatSidebar } from '@app/chats/components/sidebar';
import { ChatMessageForm } from '@app/chat/components/messages/form';
import { ChatMessageItem } from '@app/chat/components/messages/item';
import { ChatSidebar } from '@app/chat/components/sidebar';
import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
import { LoaderIcon } from '@shared/icons';
import { createChat, getChatMessages } from '@libs/storage';
import { useStronghold } from '@stores/stronghold';
import { useNostr } from '@utils/hooks/useNostr';
import { useAccount } from '@utils/hooks/useAccount';
export function ChatScreen() {
const queryClient = useQueryClient();
const virtuosoRef = useRef(null);
const userPrivkey = useStronghold((state) => state.privkey);
const { db } = useStorage();
const { ndk } = useNDK();
const { pubkey } = useParams();
const { fetchNIP04Messages } = useNostr();
const { status, data } = useQuery(['nip04-dm', pubkey], async () => {
return await fetchNIP04Messages(pubkey);
});
const { account } = useAccount();
const { status, data } = useQuery(
['chat', pubkey],
async () => {
return await getChatMessages(account.pubkey, pubkey);
},
{
enabled: account ? true : false,
}
);
const itemContent = useCallback(
const userPrivkey = useStronghold((state) => state.privkey);
const itemContent: any = useCallback(
(index: string | number) => {
const message = data[index];
if (!message) return;
return (
<ChatMessageItem
message={message}
userPubkey={db.account.pubkey}
data={data[index]}
userPubkey={account.pubkey}
userPrivkey={userPrivkey}
/>
);
@@ -51,11 +54,27 @@ export function ChatScreen() {
[data]
);
const chat = useMutation({
mutationFn: (data: any) => {
return createChat(
data.id,
data.receiver_pubkey,
data.sender_pubkey,
data.content,
data.tags,
data.created_at
);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['chat', pubkey] });
},
});
useEffect(() => {
const sub: NDKSubscription = ndk.subscribe(
{
kinds: [4],
authors: [db.account.pubkey],
authors: [account.pubkey],
'#p': [pubkey],
since: Math.floor(Date.now() / 1000),
},
@@ -65,7 +84,14 @@ export function ChatScreen() {
);
sub.addListener('event', (event) => {
console.log(event);
chat.mutate({
id: event.id,
receiver_pubkey: pubkey,
sender_pubkey: event.pubkey,
content: event.content,
tags: event.tags,
created_at: event.created_at,
});
});
return () => {
@@ -74,18 +100,19 @@ export function ChatScreen() {
}, [pubkey]);
return (
<div className="grid h-full w-full grid-cols-3 bg-white/10 backdrop-blur-xl">
<div className="col-span-2 border-r border-white/5">
<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="h-full w-full flex-1 p-3">
<div className="flex h-full flex-col justify-between overflow-hidden rounded-xl bg-white/10 backdrop-blur-xl">
<div className="flex h-full flex-col justify-between overflow-hidden rounded-xl border-t border-zinc-800/50 bg-zinc-900">
<div className="h-full w-full flex-1">
{status === 'loading' ? (
<div className="flex h-full w-full items-center justify-center">
<div className="flex flex-col items-center gap-1.5">
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
<p className="text-sm font-medium text-white/50">Loading messages</p>
</div>
</div>
<p>Loading...</p>
) : (
<Virtuoso
ref={virtuosoRef}
@@ -104,17 +131,21 @@ export function ChatScreen() {
/>
)}
</div>
<div className="z-50 shrink-0 rounded-b-xl border-t border-white/5 bg-white/10 p-3 px-5 backdrop-blur-xl">
<div className="z-50 shrink-0 rounded-b-xl border-t border-zinc-800 bg-zinc-900 p-3 px-5">
<ChatMessageForm
receiverPubkey={pubkey}
userPubkey={db.account.pubkey}
userPubkey={account.pubkey}
userPrivkey={userPrivkey}
/>
</div>
</div>
</div>
</div>
<div className="col-span-1 pt-3">
<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"
/>
<ChatSidebar pubkey={pubkey} />
</div>
</div>
@@ -124,7 +155,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-white/50">
<p className="leading-none text-zinc-400">
You two didn&apos;t talk yet, let&apos;s send first message
</p>
</div>

View File

@@ -1,46 +0,0 @@
import { NavLink } from 'react-router-dom';
import { twMerge } from 'tailwind-merge';
import { Image } from '@shared/image';
import { useProfile } from '@utils/hooks/useProfile';
import { displayNpub } from '@utils/shortenKey';
export function ChatsListItem({ pubkey }: { pubkey: string }) {
const { status, user } = useProfile(pubkey);
if (status === 'loading') {
return (
<div className="inline-flex h-10 items-center gap-2.5 rounded-md px-2">
<div className="relative h-7 w-7 shrink-0 animate-pulse rounded bg-white/10 backdrop-blur-xl" />
<div className="h-2.5 w-2/3 animate-pulse rounded bg-white/10 backdrop-blur-xl" />
</div>
);
}
return (
<NavLink
to={`/chats/${pubkey}`}
preventScrollReset={true}
className={({ isActive }) =>
twMerge(
'flex h-10 items-center gap-2.5 rounded-r-lg border-l-2 pl-4 pr-2',
isActive
? 'border-fuchsia-500 bg-white/5 text-white'
: 'border-transparent text-white/80'
)
}
>
<Image
src={user?.picture || user?.image}
alt={pubkey}
className="h-7 w-7 shrink-0 rounded"
/>
<div className="inline-flex w-full flex-1 items-center justify-between">
<h5 className="max-w-[10rem] truncate">
{user?.name || user?.display_name || displayNpub(pubkey, 16)}
</h5>
</div>
</NavLink>
);
}

View File

@@ -1,50 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { useCallback } from 'react';
import { ChatsListItem } from '@app/chats/components/item';
import { NewMessageModal } from '@app/chats/components/modal';
import { UnknownsModal } from '@app/chats/components/unknowns';
import { useStorage } from '@libs/storage/provider';
import { useNostr } from '@utils/hooks/useNostr';
export function ChatsList() {
const { db } = useStorage();
const { fetchNIP04Chats } = useNostr();
const { status, data: chats } = useQuery(
['nip04-chats'],
async () => {
return await fetchNIP04Chats();
},
{ refetchOnWindowFocus: false }
);
const renderItem = useCallback(
(item: string) => {
if (db.account.pubkey !== item) {
return <ChatsListItem key={item} pubkey={item} />;
}
},
[chats]
);
if (status === 'loading') {
return (
<div className="flex flex-col">
<div className="inline-flex h-10 items-center gap-2.5 border-l-2 border-transparent pl-4">
<div className="relative h-7 w-7 shrink-0 animate-pulse rounded bg-white/10 backdrop-blur-xl" />
<div className="h-4 w-full animate-pulse rounded bg-white/10 backdrop-blur-xl" />
</div>
</div>
);
}
return (
<div className="flex flex-col">
{chats.follows.map((item) => renderItem(item))}
{chats.unknowns.length > 0 && <UnknownsModal data={chats.unknowns} />}
<NewMessageModal />
</div>
);
}

View File

@@ -1,34 +0,0 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { useDecryptMessage } from '@app/chats/hooks/useDecryptMessage';
import { User } from '@shared/user';
export function ChatMessageItem({
message,
userPubkey,
userPrivkey,
}: {
message: NDKEvent;
userPubkey: string;
userPrivkey: string;
}) {
const decryptedContent = useDecryptMessage(message, userPubkey, userPrivkey);
// if we have decrypted content, use it instead of the encrypted content
if (decryptedContent) {
message['content'] = decryptedContent;
}
return (
<div className="flex h-min min-h-min w-full select-text flex-col px-5 py-3 backdrop-blur-xl hover:bg-white/10">
<div className="flex flex-col">
<User pubkey={message.pubkey} time={message.created_at} isChat={true} />
<div className="-mt-[20px] pl-[49px]">
<p className="select-text whitespace-pre-line break-words text-base text-white">
{message.content}
</p>
</div>
</div>
</div>
);
}

View File

@@ -1,53 +0,0 @@
import * as Tooltip from '@radix-ui/react-tooltip';
import { Dispatch, SetStateAction, useState } from 'react';
import { LoaderIcon, MediaIcon } from '@shared/icons';
import { useImageUploader } from '@utils/hooks/useUploader';
export function MediaUploader({
setState,
}: {
setState: Dispatch<SetStateAction<string>>;
}) {
const upload = useImageUploader();
const [loading, setLoading] = useState(false);
const uploadMedia = async () => {
setLoading(true);
const image = await upload(null);
if (image.url) {
setState((prev: string) => `${prev}\n${image.url}`);
}
setLoading(false);
};
return (
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button
type="button"
onClick={() => uploadMedia()}
className="group inline-flex h-8 w-8 items-center justify-center rounded backdrop-blur-xl hover:bg-white/10"
>
{loading ? (
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
) : (
<MediaIcon className="h-5 w-5 text-white" />
)}
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
className="-left-10 select-none rounded-md bg-black px-3.5 py-1.5 text-sm leading-none text-white will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade"
sideOffset={5}
>
Upload media
<Tooltip.Arrow className="fill-black" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}

View File

@@ -1,80 +0,0 @@
import * as Dialog from '@radix-ui/react-dialog';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { User } from '@app/auth/components/user';
import { useStorage } from '@libs/storage/provider';
import { CancelIcon, PlusIcon } from '@shared/icons';
export function NewMessageModal() {
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const { db } = useStorage();
const openChat = (pubkey: string) => {
setOpen(false);
navigate(`/chats/${pubkey}`);
};
return (
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger asChild>
<button
type="button"
className="inline-flex h-10 items-center gap-2.5 rounded-r-lg border-l-2 border-transparent pl-4 pr-2"
>
<div className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded bg-white/10 backdrop-blur-xl">
<PlusIcon className="h-4 w-4 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-2xl" />
<Dialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
<div className="relative h-min w-full max-w-xl rounded-xl bg-white/10 backdrop-blur-xl">
<div className="h-min w-full shrink-0 border-b border-white/10 bg-white/5 px-5 py-5">
<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 backdrop-blur-xl hover:bg-white/10">
<CancelIcon className="h-4 w-4 text-white/50" />
</Dialog.Close>
</div>
<Dialog.Description className="text-sm leading-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">
{db.account?.follows?.map((follow) => (
<div
key={follow}
className="group flex items-center justify-between px-4 py-2 backdrop-blur-xl hover:bg-white/10"
>
<User pubkey={follow} />
<div>
<button
type="button"
onClick={() => openChat(follow)}
className="hidden w-max rounded bg-white/10 px-3 py-1 text-sm font-medium backdrop-blur-xl hover:bg-fuchsia-500 group-hover:inline-flex"
>
Chat
</button>
</div>
</div>
))}
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

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