Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48066a4018 | ||
|
|
5c8850ea8f | ||
|
|
09aea3cff5 | ||
|
|
45c5a890b9 | ||
|
|
69a3e85cb3 | ||
|
|
224439f62b | ||
|
|
2389ad5fdc | ||
|
|
4019623d99 | ||
|
|
57c17ffbf9 | ||
|
|
98d2ccfc86 | ||
|
|
c74a81cfdb | ||
|
|
3ebcf4a981 | ||
|
|
5d45027776 | ||
|
|
21ea8309c7 | ||
|
|
431331174a | ||
|
|
c26cfc038d | ||
|
|
39b7b34bb7 | ||
|
|
a4cf65e7c2 | ||
|
|
37668393f1 | ||
|
|
4309f734b6 | ||
|
|
b4957bae1f | ||
|
|
7a3b19bf7b | ||
|
|
1931373515 | ||
|
|
28939d1733 | ||
|
|
e6d35bc635 | ||
|
|
cc315a190a | ||
|
|
0d207d471c | ||
|
|
f2eb7a90ad | ||
|
|
c29ed9669e | ||
|
|
aced6077bd | ||
|
|
abe4d11498 | ||
|
|
91e50efb1a | ||
|
|
2914c54a47 | ||
|
|
d1701eff20 | ||
|
|
d4eb237e40 | ||
|
|
f4b2458417 | ||
|
|
c89e7e48ee | ||
|
|
5a3207f878 | ||
|
|
3d4afb40bc | ||
|
|
bf91187c1f | ||
|
|
963328e064 | ||
|
|
53227c7050 | ||
|
|
fe28cd95bd | ||
|
|
0f212828a7 | ||
|
|
bfb7d7915f | ||
|
|
92d49c306b | ||
|
|
b2df8ae320 | ||
|
|
98687bd78b | ||
|
|
970115d059 | ||
|
|
4893ebd932 | ||
|
|
3455eb701f | ||
|
|
c97c685149 | ||
|
|
0912948b31 | ||
|
|
4830f0b236 | ||
|
|
917e49b25d | ||
|
|
fe8c2fd2c6 | ||
|
|
c4a7ef8867 | ||
|
|
bac70b19ec | ||
|
|
08e3a66ece | ||
|
|
eda18f8c34 | ||
|
|
c85502e427 | ||
|
|
5626579b3f | ||
|
|
414dd50a5c | ||
|
|
ab61bfb2cd | ||
|
|
c05bb54976 | ||
|
|
2d53019c10 | ||
|
|
6e28bcdb96 | ||
|
|
adca37223c | ||
|
|
823b203b73 | ||
|
|
6c6f50444e | ||
|
|
c42c78fc98 | ||
|
|
33fd7512e7 | ||
|
|
3b02b3f554 | ||
|
|
a02577bb55 | ||
|
|
f8753eca90 |
18
.github/workflows/main.yml
vendored
18
.github/workflows/main.yml
vendored
@@ -1,8 +1,5 @@
|
||||
name: 'publish'
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- release
|
||||
name: 'Publish'
|
||||
on: workflow_dispatch
|
||||
|
||||
env:
|
||||
CARGO_INCREMENTAL: 0
|
||||
@@ -17,7 +14,7 @@ jobs:
|
||||
settings:
|
||||
- platform: 'macos-latest'
|
||||
args: '--target universal-apple-darwin'
|
||||
- platform: 'ubuntu-20.04'
|
||||
- platform: 'ubuntu-22.04'
|
||||
args: ''
|
||||
- platform: 'windows-latest'
|
||||
args: '--target x86_64-pc-windows-msvc'
|
||||
@@ -32,14 +29,14 @@ jobs:
|
||||
with:
|
||||
targets: aarch64-apple-darwin
|
||||
- name: install dependencies (ubuntu only)
|
||||
if: matrix.settings.platform == 'ubuntu-20.04'
|
||||
if: matrix.settings.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-dev webkit2gtk-4.0 libappindicator3-dev librsvg2-dev patchelf
|
||||
sudo apt-get install -y build-essential libssl-dev libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 7.x.x
|
||||
version: 8.x.x
|
||||
run_install: false
|
||||
- name: Setup node and cache for package data
|
||||
uses: actions/setup-node@v3
|
||||
@@ -63,9 +60,10 @@ jobs:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
with:
|
||||
tagName: v__VERSION__ # the action automatically replaces \_\_VERSION\_\_ with the app version
|
||||
tagName: v__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
|
||||
|
||||
96
package.json
96
package.json
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"name": "lume",
|
||||
"description": "the communication app",
|
||||
"private": true,
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.3",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
@@ -18,98 +19,85 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ctrl/magnet-link": "^3.1.2",
|
||||
"@headlessui/react": "^1.7.16",
|
||||
"@nostr-dev-kit/ndk": "^0.8.11",
|
||||
"@nostr-fetch/adapter-ndk": "^0.11.0",
|
||||
"@getalby/sdk": "^2.4.0",
|
||||
"@nostr-dev-kit/ndk": "^0.8.23",
|
||||
"@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",
|
||||
"@radix-ui/react-popover": "^1.0.6",
|
||||
"@radix-ui/react-tooltip": "^1.0.6",
|
||||
"@tanstack/react-query": "^4.32.6",
|
||||
"@tanstack/react-query-devtools": "^4.32.6",
|
||||
"@tanstack/react-query": "^4.33.0",
|
||||
"@tanstack/react-virtual": "3.0.0-beta.54",
|
||||
"@tauri-apps/api": "2.0.0-alpha.5",
|
||||
"@tauri-apps/plugin-app": "github:tauri-apps/tauri-plugin-app#v2",
|
||||
"@tauri-apps/plugin-autostart": "github:tauri-apps/tauri-plugin-autostart#v2",
|
||||
"@tauri-apps/plugin-clipboard-manager": "github:tauri-apps/tauri-plugin-clipboard-manager#v2",
|
||||
"@tauri-apps/plugin-dialog": "github:tauri-apps/tauri-plugin-dialog#v2",
|
||||
"@tauri-apps/plugin-fs": "github:tauri-apps/tauri-plugin-fs#v2",
|
||||
"@tauri-apps/plugin-http": "github:tauri-apps/tauri-plugin-http#v2",
|
||||
"@tauri-apps/plugin-notification": "github:tauri-apps/tauri-plugin-notification#v2",
|
||||
"@tauri-apps/plugin-os": "github:tauri-apps/tauri-plugin-os#v2",
|
||||
"@tauri-apps/plugin-process": "github:tauri-apps/tauri-plugin-process#v2",
|
||||
"@tauri-apps/plugin-shell": "github:tauri-apps/tauri-plugin-shell#v2",
|
||||
"@tauri-apps/plugin-sql": "github:tauri-apps/tauri-plugin-sql#v2",
|
||||
"@tauri-apps/plugin-store": "github:tauri-apps/tauri-plugin-store#v2",
|
||||
"@tauri-apps/plugin-stronghold": "github:tauri-apps/tauri-plugin-stronghold#v2",
|
||||
"@tauri-apps/plugin-upload": "github:tauri-apps/tauri-plugin-upload#v2",
|
||||
"@tauri-apps/plugin-window": "2.0.0-alpha.0",
|
||||
"@tiptap/extension-image": "^2.0.4",
|
||||
"@tiptap/extension-mention": "^2.0.4",
|
||||
"@tiptap/extension-placeholder": "^2.0.4",
|
||||
"@tiptap/pm": "^2.0.4",
|
||||
"@tiptap/react": "^2.0.4",
|
||||
"@tiptap/starter-kit": "^2.0.4",
|
||||
"@tiptap/suggestion": "^2.0.4",
|
||||
"@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": "^1.2.2",
|
||||
"get-urls": "^11.0.0",
|
||||
"destr": "^2.0.1",
|
||||
"get-urls": "^12.1.0",
|
||||
"html-to-text": "^9.0.5",
|
||||
"immer": "^10.0.2",
|
||||
"light-bolt11-decoder": "^3.0.0",
|
||||
"lru-cache": "^10.0.1",
|
||||
"nostr-fetch": "^0.12.2",
|
||||
"nostr-tools": "^1.14.0",
|
||||
"minidenticons": "^4.2.0",
|
||||
"nostr-fetch": "^0.13.0",
|
||||
"nostr-tools": "^1.14.2",
|
||||
"qrcode.react": "^3.1.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.45.4",
|
||||
"react-hook-form": "^7.46.1",
|
||||
"react-hotkeys-hook": "^4.4.1",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-player": "^2.12.0",
|
||||
"react-player": "^2.13.0",
|
||||
"react-router-dom": "^6.15.0",
|
||||
"react-string-replace": "^1.1.1",
|
||||
"react-textarea-autosize": "^8.5.3",
|
||||
"react-virtuoso": "^4.5.0",
|
||||
"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",
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@tauri-apps/cli": "2.0.0-alpha.10",
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"@tauri-apps/cli": "^1.4.0",
|
||||
"@trivago/prettier-plugin-sort-imports": "^4.2.0",
|
||||
"@types/html-to-text": "^9.0.1",
|
||||
"@types/node": "^18.17.5",
|
||||
"@types/react": "^18.2.20",
|
||||
"@types/node": "^20.5.9",
|
||||
"@types/react": "^18.2.21",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@types/youtube-player": "^5.5.7",
|
||||
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
||||
"@typescript-eslint/parser": "^5.62.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.6.0",
|
||||
"@typescript-eslint/parser": "^6.6.0",
|
||||
"@vitejs/plugin-react-swc": "^3.3.2",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"autoprefixer": "^10.4.15",
|
||||
"clsx": "^2.0.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"csstype": "^3.1.2",
|
||||
"encoding": "^0.1.13",
|
||||
"eslint": "^8.47.0",
|
||||
"eslint-config-prettier": "^8.10.0",
|
||||
"eslint": "^8.48.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.7.1",
|
||||
"eslint-plugin-react": "^7.33.1",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-simple-import-sort": "^10.0.0",
|
||||
"husky": "^8.0.3",
|
||||
"lint-staged": "^13.2.3",
|
||||
"postcss": "^8.4.27",
|
||||
"prettier": "^2.8.8",
|
||||
"prettier-plugin-tailwindcss": "^0.3.0",
|
||||
"lint-staged": "^14.0.1",
|
||||
"postcss": "^8.4.29",
|
||||
"prettier": "^3.0.3",
|
||||
"prettier-plugin-tailwindcss": "^0.5.4",
|
||||
"prop-types": "^15.8.1",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"typescript": "^4.9.5",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^4.4.9",
|
||||
"vite-plugin-top-level-await": "^1.3.1",
|
||||
"vite-tsconfig-paths": "^4.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
3211
pnpm-lock.yaml
generated
3211
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
BIN
public/ghost.png
Normal file
BIN
public/ghost.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
BIN
public/zap.png
Normal file
BIN
public/zap.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 341 KiB |
1726
src-tauri/Cargo.lock
generated
1726
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,52 +1,56 @@
|
||||
[package]
|
||||
name = "lume"
|
||||
version = "1.2.0"
|
||||
description = "nostr client"
|
||||
version = "1.2.3"
|
||||
description = "the communication app"
|
||||
authors = ["Ren Amamiya"]
|
||||
license = ""
|
||||
repository = ""
|
||||
license = "GPL-3.0"
|
||||
repository = "https://github.com/luminous-devs/lume"
|
||||
edition = "2021"
|
||||
rust-version = "1.71"
|
||||
rust-version = "1.66"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.0.0-alpha.6", features = [] }
|
||||
tauri-build = { version = "1.4", features = [] }
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
tauri = { version = "2.0.0-alpha.10", features = [
|
||||
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",
|
||||
"macos-private-api",
|
||||
"system-tray",
|
||||
"http-multipart",
|
||||
] }
|
||||
tauri-plugin-single-instance = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
||||
tauri-plugin-autostart = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
||||
tauri-plugin-stronghold = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
||||
tauri-plugin-upload = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
||||
tauri-plugin-updater = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
||||
tauri-plugin-dialog = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
||||
tauri-plugin-http = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
||||
tauri-plugin-fs = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
||||
tauri-plugin-clipboard-manager = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
||||
tauri-plugin-notification = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
||||
tauri-plugin-app = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
||||
tauri-plugin-process = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
||||
tauri-plugin-os = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
||||
tauri-plugin-window = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
||||
tauri-plugin-store = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
||||
tauri-plugin-shell = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
||||
tauri-plugin-sql = { git = "hhttps://github.com/tauri-apps/plugins-workspace", branch = "v1", features = [
|
||||
"sqlite",
|
||||
] }
|
||||
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",
|
||||
] }
|
||||
rust-argon2 = "1.0"
|
||||
rand = "0.8.5"
|
||||
|
||||
[dependencies.tauri-plugin-sql]
|
||||
git = "https://github.com/tauri-apps/plugins-workspace"
|
||||
branch = "v2"
|
||||
features = ["sqlite"]
|
||||
webpage = { version = "1.6.0", features = ["serde"] }
|
||||
|
||||
[features]
|
||||
# by default Tauri runs in production mode
|
||||
|
||||
13
src-tauri/migrations/20230814083543_add_events_table.sql
Normal file
13
src-tauri/migrations/20230814083543_add_events_table.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- 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)
|
||||
);
|
||||
8
src-tauri/migrations/20230816090508_clean_up_tables.sql
Normal file
8
src-tauri/migrations/20230816090508_clean_up_tables.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- Add migration script here
|
||||
DROP TABLE IF EXISTS notes;
|
||||
|
||||
DROP TABLE IF EXISTS chats;
|
||||
|
||||
DROP TABLE IF EXISTS metadata;
|
||||
|
||||
DROP TABLE IF EXISTS replies;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Add migration script here
|
||||
ALTER TABLE accounts
|
||||
ADD COLUMN last_login_at NUMBER NOT NULL DEFAULT 0;
|
||||
@@ -3,11 +3,14 @@
|
||||
windows_subsystem = "windows"
|
||||
)]
|
||||
|
||||
// use rand::distributions::{Alphanumeric, DistString};
|
||||
use tauri::Manager;
|
||||
use tauri_plugin_autostart::MacosLauncher;
|
||||
use tauri_plugin_sql::{Migration, MigrationKind};
|
||||
use window_vibrancy::{apply_mica, apply_vibrancy, NSVisualEffectMaterial};
|
||||
use webpage::{Webpage, WebpageOptions};
|
||||
use std::time::Duration;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use window_vibrancy::{apply_vibrancy, NSVisualEffectMaterial};
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
struct Payload {
|
||||
@@ -15,6 +18,71 @@ 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
|
||||
@@ -27,6 +95,15 @@ async fn close_splashscreen(window: tauri::Window) {
|
||||
|
||||
fn main() {
|
||||
tauri::Builder::default()
|
||||
.setup(|app| {
|
||||
let window = app.get_window("main").unwrap();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
apply_vibrancy(&window, NSVisualEffectMaterial::HudWindow, None, None)
|
||||
.expect("Unsupported platform! 'apply_vibrancy' is only supported on macOS");
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.plugin(
|
||||
tauri_plugin_sql::Builder::default()
|
||||
.add_migrations(
|
||||
@@ -116,6 +193,24 @@ fn main() {
|
||||
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(),
|
||||
@@ -131,7 +226,6 @@ 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",
|
||||
@@ -153,33 +247,9 @@ fn main() {
|
||||
.emit_all("single-instance", Payload { args: argv, cwd })
|
||||
.unwrap();
|
||||
}))
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_upload::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_http::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_app::init())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_os::init())
|
||||
.plugin(tauri_plugin_window::init())
|
||||
.plugin(tauri_plugin_store::Builder::default().build())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.setup(|app| {
|
||||
let window = app.get_window("main").unwrap();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
apply_vibrancy(&window, NSVisualEffectMaterial::HudWindow, None, None)
|
||||
.expect("Unsupported platform! 'apply_vibrancy' is only supported on macOS");
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
apply_mica(&window, None, None)
|
||||
.expect("Unsupported platform! 'apply_blur' is only supported on Windows");
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![close_splashscreen])
|
||||
.invoke_handler(tauri::generate_handler![close_splashscreen, opengraph])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
|
||||
"build": {
|
||||
"beforeBuildCommand": "pnpm build",
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
@@ -8,40 +9,75 @@
|
||||
},
|
||||
"package": {
|
||||
"productName": "Lume",
|
||||
"version": "1.2.0"
|
||||
},
|
||||
"plugins": {
|
||||
"fs": {
|
||||
"scope": [
|
||||
"$APPDATA/*",
|
||||
"$DATA/*",
|
||||
"$LOCALDATA/*",
|
||||
"$DESKTOP/*",
|
||||
"$DOCUMENT/*",
|
||||
"$DOWNLOAD/*",
|
||||
"$HOME/*",
|
||||
"$PICTURE/*",
|
||||
"$PUBLIC/*",
|
||||
"$VIDEO/*"
|
||||
]
|
||||
},
|
||||
"http": {
|
||||
"scope": [
|
||||
"http://**/",
|
||||
"https://**/"
|
||||
]
|
||||
},
|
||||
"shell": {
|
||||
"open": true
|
||||
},
|
||||
"updater": {
|
||||
"endpoints": [
|
||||
"https://lus.reya3772.workers.dev/v1/{{target}}/{{arch}}/{{current_version}}",
|
||||
"https://lus.reya3772.workers.dev/{{target}}/{{current_version}}"
|
||||
]
|
||||
}
|
||||
"version": "1.2.3"
|
||||
},
|
||||
"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": {
|
||||
@@ -67,59 +103,28 @@
|
||||
"exceptionDomain": "",
|
||||
"frameworks": [],
|
||||
"providerShortName": null,
|
||||
"signingIdentity": null
|
||||
"signingIdentity": null,
|
||||
"minimumSystemVersion": "10.15.0"
|
||||
},
|
||||
"resources": [],
|
||||
"shortDescription": "",
|
||||
"targets": "all",
|
||||
"updater": {
|
||||
"active": true,
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEU4RjAzODFBREQ4MkM3RTEKUldUaHg0TGRHamp3NkI5bnhoOEVjanlHWFNzQ2Q3NDhubFFLUmJpSHJ1L2FqNnB3alF1Y2R3U3gK",
|
||||
"windows": {
|
||||
"installMode": "passive"
|
||||
}
|
||||
},
|
||||
"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": "upgrade-insecure-requests"
|
||||
},
|
||||
"systemTray": {
|
||||
"iconAsTemplate": true,
|
||||
"iconPath": "icons/icon.png"
|
||||
},
|
||||
"windows": [
|
||||
{
|
||||
"width": 1080,
|
||||
"height": 800,
|
||||
"minWidth": 1080,
|
||||
"minHeight": 800,
|
||||
"resizable": true,
|
||||
"theme": "Dark",
|
||||
"title": "Lume",
|
||||
"titleBarStyle": "Overlay",
|
||||
"transparent": true,
|
||||
"center": true,
|
||||
"fullscreen": false,
|
||||
"hiddenTitle": true,
|
||||
"visible": false
|
||||
},
|
||||
{
|
||||
"width": 400,
|
||||
"height": 500,
|
||||
"decorations": true,
|
||||
"hiddenTitle": true,
|
||||
"center": true,
|
||||
"resizable": false,
|
||||
"titleBarStyle": "Overlay",
|
||||
"label": "splashscreen",
|
||||
"url": "splashscreen"
|
||||
"csp": {
|
||||
"content-security-policy": "upgrade-insecure-requests"
|
||||
}
|
||||
],
|
||||
"macOSPrivateApi": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
32
src-tauri/tauri.linux.conf.json
Normal file
32
src-tauri/tauri.linux.conf.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"$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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
36
src-tauri/tauri.macos.conf.json
Normal file
36
src-tauri/tauri.macos.conf.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
|
||||
"tauri": {
|
||||
"macOSPrivateApi": true,
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
32
src-tauri/tauri.windows.conf.json
Normal file
32
src-tauri/tauri.windows.conf.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"$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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
130
src/app.tsx
130
src/app.tsx
@@ -5,47 +5,49 @@ import { AuthImportScreen } from '@app/auth/import';
|
||||
import { OnboardingScreen } from '@app/auth/onboarding';
|
||||
import { ErrorScreen } from '@app/error';
|
||||
|
||||
import { getActiveAccount } from '@libs/storage';
|
||||
|
||||
import { AppLayout } from '@shared/appLayout';
|
||||
import { AuthLayout } from '@shared/authLayout';
|
||||
import { Frame } from '@shared/frame';
|
||||
import { LoaderIcon } from '@shared/icons';
|
||||
import { SettingsLayout } from '@shared/settingsLayout';
|
||||
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 './index.css';
|
||||
|
||||
const appLoader = async () => {
|
||||
const account = await getActiveAccount();
|
||||
const stronghold = sessionStorage.getItem('stronghold');
|
||||
const privkey = JSON.parse(stronghold).state.privkey || null;
|
||||
const onboarding = localStorage.getItem('onboarding');
|
||||
const step = JSON.parse(onboarding).state.step || null;
|
||||
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 (step) {
|
||||
return redirect(step);
|
||||
if (step) {
|
||||
return redirect(step);
|
||||
}
|
||||
|
||||
if (!account) {
|
||||
return redirect('/auth/welcome');
|
||||
} else {
|
||||
if (!privkey) {
|
||||
return redirect('/auth/unlock');
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
throw new Error('App failed to load');
|
||||
}
|
||||
|
||||
if (!account) {
|
||||
return redirect('/auth/welcome');
|
||||
}
|
||||
|
||||
if (account && account.privkey.length > 35) {
|
||||
return redirect('/auth/migrate');
|
||||
}
|
||||
|
||||
if (account && !privkey) {
|
||||
return redirect('/auth/unlock');
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: '/',
|
||||
element: <AppLayout />,
|
||||
errorElement: <ErrorScreen />,
|
||||
loader: appLoader,
|
||||
loader: Loader,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
@@ -54,20 +56,6 @@ const router = createBrowserRouter([
|
||||
return { Component: SpaceScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'trending',
|
||||
async lazy() {
|
||||
const { TrendingScreen } = await import('@app/trending');
|
||||
return { Component: TrendingScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'events/:id',
|
||||
async lazy() {
|
||||
const { EventScreen } = await import('@app/events');
|
||||
return { Component: EventScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'users/:pubkey',
|
||||
async lazy() {
|
||||
@@ -82,6 +70,34 @@ const router = createBrowserRouter([
|
||||
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 };
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -95,6 +111,7 @@ const router = createBrowserRouter([
|
||||
{
|
||||
path: '/auth',
|
||||
element: <AuthLayout />,
|
||||
errorElement: <ErrorScreen />,
|
||||
children: [
|
||||
{
|
||||
path: 'welcome',
|
||||
@@ -106,6 +123,7 @@ const router = createBrowserRouter([
|
||||
{
|
||||
path: 'import',
|
||||
element: <AuthImportScreen />,
|
||||
errorElement: <ErrorScreen />,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
@@ -133,6 +151,7 @@ const router = createBrowserRouter([
|
||||
{
|
||||
path: 'create',
|
||||
element: <AuthCreateScreen />,
|
||||
errorElement: <ErrorScreen />,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
@@ -160,6 +179,7 @@ const router = createBrowserRouter([
|
||||
{
|
||||
path: 'onboarding',
|
||||
element: <OnboardingScreen />,
|
||||
errorElement: <ErrorScreen />,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
@@ -205,28 +225,29 @@ const router = createBrowserRouter([
|
||||
return { Component: ResetScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'hard-reset',
|
||||
async lazy() {
|
||||
const { HardResetScreen } = await import('@app/auth/hardReset');
|
||||
return { Component: HardResetScreen };
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
element: <SettingsLayout />,
|
||||
errorElement: <ErrorScreen />,
|
||||
children: [
|
||||
{
|
||||
path: 'general',
|
||||
path: '',
|
||||
async lazy() {
|
||||
const { GeneralSettingsScreen } = await import('@app/settings/general');
|
||||
return { Component: GeneralSettingsScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'shortcuts',
|
||||
async lazy() {
|
||||
const { ShortcutsSettingsScreen } = await import('@app/settings/shortcuts');
|
||||
return { Component: ShortcutsSettingsScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'account',
|
||||
path: 'backup',
|
||||
async lazy() {
|
||||
const { AccountSettingsScreen } = await import('@app/settings/account');
|
||||
return { Component: AccountSettingsScreen };
|
||||
@@ -241,11 +262,10 @@ export default function App() {
|
||||
<RouterProvider
|
||||
router={router}
|
||||
fallbackElement={
|
||||
<div className="flex h-full w-full items-center justify-center bg-black/90">
|
||||
<Frame className="flex h-full w-full items-center justify-center">
|
||||
<LoaderIcon className="h-6 w-6 animate-spin text-white" />
|
||||
</div>
|
||||
</Frame>
|
||||
}
|
||||
future={{ v7_startTransition: true }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Image } from '@shared/image';
|
||||
|
||||
import { DEFAULT_AVATAR } from '@stores/constants';
|
||||
|
||||
import { useProfile } from '@utils/hooks/useProfile';
|
||||
import { displayNpub } from '@utils/shortenKey';
|
||||
|
||||
@@ -11,31 +9,28 @@ 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" />
|
||||
<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" />
|
||||
<span className="h-3 w-1/3 animate-pulse rounded bg-white/10" />
|
||||
<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="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 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 w-full flex-1 flex-col items-start text-start">
|
||||
<span className="truncate font-medium leading-tight text-white">
|
||||
{user?.name || user?.display_name || user?.nip05}
|
||||
</span>
|
||||
<span className="max-w-[15rem] truncate text-base leading-tight text-white/50">
|
||||
{user?.nip05?.toLowerCase() || displayNpub(pubkey, 16)}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Image } from '@shared/image';
|
||||
|
||||
import { DEFAULT_AVATAR } from '@stores/constants';
|
||||
|
||||
import { useProfile } from '@utils/hooks/useProfile';
|
||||
import { displayNpub } from '@utils/shortenKey';
|
||||
|
||||
@@ -11,10 +9,10 @@ export function UserRelay({ pubkey }: { pubkey: 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" />
|
||||
<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" />
|
||||
<span className="h-3 w-1/3 animate-pulse rounded bg-white/10" />
|
||||
<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>
|
||||
);
|
||||
@@ -26,12 +24,11 @@ export function UserRelay({ pubkey }: { pubkey: string }) {
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<Image
|
||||
src={user?.picture || user?.image}
|
||||
fallback={DEFAULT_AVATAR}
|
||||
alt={pubkey}
|
||||
className="h-5 w-5 shrink-0 rounded object-cover"
|
||||
/>
|
||||
<span className="truncate text-sm font-medium leading-none text-white">
|
||||
{user?.name || user?.display_name || user?.nip05 || displayNpub(pubkey, 16)}
|
||||
{user?.name || user?.display_name || displayNpub(pubkey, 16)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { BaseDirectory, writeTextFile } from '@tauri-apps/plugin-fs';
|
||||
import { BaseDirectory, writeTextFile } from '@tauri-apps/api/fs';
|
||||
import { generatePrivateKey, getPublicKey, nip19 } from 'nostr-tools';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { createAccount } from '@libs/storage';
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
import { Button } from '@shared/button';
|
||||
import { EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
|
||||
@@ -14,7 +13,8 @@ import { useOnboarding } from '@stores/onboarding';
|
||||
import { useStronghold } from '@stores/stronghold';
|
||||
|
||||
export function CreateStep1Screen() {
|
||||
const queryClient = useQueryClient();
|
||||
const { db } = useStorage();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const setPrivkey = useStronghold((state) => state.setPrivkey);
|
||||
const setTempPrivkey = useOnboarding((state) => state.setTempPrivkey);
|
||||
@@ -40,42 +40,29 @@ export function CreateStep1Screen() {
|
||||
};
|
||||
|
||||
const download = async () => {
|
||||
await writeTextFile('lume-keys.txt', `Public key: ${npub}\nPrivate key: ${nsec}`, {
|
||||
dir: BaseDirectory.Download,
|
||||
});
|
||||
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);
|
||||
|
||||
account.mutate({
|
||||
npub,
|
||||
pubkey,
|
||||
follows: null,
|
||||
is_active: 1,
|
||||
});
|
||||
// save to database
|
||||
db.createAccount(npub, pubkey);
|
||||
|
||||
// redirect to next step
|
||||
setTimeout(() => navigate('/auth/create/step-2', { replace: true }), 1200);
|
||||
navigate('/auth/create/step-2', { replace: true });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -94,7 +81,7 @@ export function CreateStep1Screen() {
|
||||
<input
|
||||
readOnly
|
||||
value={npub}
|
||||
className="relative h-11 w-full rounded-lg bg-white/10 px-3.5 py-1 text-white !outline-none placeholder:text-white/50"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -104,12 +91,12 @@ export function CreateStep1Screen() {
|
||||
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 placeholder:text-white/50"
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => showPrivateKey()}
|
||||
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 backdrop-blur-xl hover:bg-white/10"
|
||||
>
|
||||
{privkeyInput === 'password' ? (
|
||||
<EyeOffIcon className="h-4 w-4 text-white/50 group-hover:text-white" />
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { appConfigDir } from '@tauri-apps/api/path';
|
||||
import { useEffect, 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';
|
||||
@@ -8,8 +12,6 @@ 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;
|
||||
};
|
||||
@@ -37,7 +39,7 @@ export function CreateStep2Screen() {
|
||||
const [passwordInput, setPasswordInput] = useState('password');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { save } = useSecureStorage();
|
||||
const { db } = useStorage();
|
||||
|
||||
// toggle private key
|
||||
const showPassword = () => {
|
||||
@@ -58,8 +60,13 @@ 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);
|
||||
|
||||
db.secureDB = stronghold;
|
||||
|
||||
// save privkey to secure storage
|
||||
await save(pubkey, privkey, data.password);
|
||||
await db.secureSave(pubkey, privkey);
|
||||
|
||||
// redirect to next step
|
||||
navigate('/auth/create/step-3', { replace: true });
|
||||
@@ -91,12 +98,12 @@ 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 placeholder:text-white/50"
|
||||
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"
|
||||
/>
|
||||
<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 backdrop-blur-xl hover:bg-white/10"
|
||||
>
|
||||
{passwordInput === 'password' ? (
|
||||
<EyeOffIcon className="h-4 w-4 text-white/50 group-hover:text-white" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { NDKKind } from '@nostr-dev-kit/ndk';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@@ -9,7 +9,6 @@ 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';
|
||||
@@ -17,10 +16,9 @@ import { useNostr } from '@utils/hooks/useNostr';
|
||||
export function CreateStep3Screen() {
|
||||
const navigate = useNavigate();
|
||||
const setStep = useOnboarding((state) => state.setStep);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [picture, setPicture] = useState(DEFAULT_AVATAR);
|
||||
const [picture, setPicture] = useState('https://void.cat/d/5VKmKyuHyxrNMf9bWSVPih');
|
||||
const [banner, setBanner] = useState('');
|
||||
|
||||
const { publish } = useNostr();
|
||||
@@ -43,14 +41,12 @@ export function CreateStep3Screen() {
|
||||
|
||||
const event = await publish({
|
||||
content: JSON.stringify(profile),
|
||||
kind: 0,
|
||||
kind: NDKKind.Metadata,
|
||||
tags: [],
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries(['currentAccount']);
|
||||
|
||||
if (event) {
|
||||
setTimeout(() => navigate('/auth/onboarding', { replace: true }), 1000);
|
||||
navigate('/auth/onboarding', { replace: true });
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('error: ', e);
|
||||
@@ -68,18 +64,21 @@ export function CreateStep3Screen() {
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-xl font-semibold text-white">Create your profile</h1>
|
||||
</div>
|
||||
<div className="w-full overflow-hidden rounded-xl bg-white/10">
|
||||
<div className="w-full overflow-hidden rounded-xl bg-white/10 backdrop-blur-xl">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col">
|
||||
<input type={'hidden'} {...register('picture')} value={picture} />
|
||||
<input type={'hidden'} {...register('banner')} value={banner} />
|
||||
<div className="relative">
|
||||
<div className="relative h-44 w-full bg-white/10">
|
||||
<Image
|
||||
src={banner}
|
||||
fallback="https://void.cat/d/QY1myro5tkHVs2nY7dy74b.jpg"
|
||||
alt="user's banner"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
<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="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>
|
||||
@@ -88,7 +87,6 @@ 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"
|
||||
/>
|
||||
@@ -113,7 +111,7 @@ 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 placeholder:text-white/50"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -126,7 +124,7 @@ export function CreateStep3Screen() {
|
||||
<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 placeholder:text-white/50"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -142,7 +140,7 @@ 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 placeholder:text-white/50"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
|
||||
7
src/app/auth/hardReset.tsx
Normal file
7
src/app/auth/hardReset.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export function HardResetScreen() {
|
||||
return (
|
||||
<div>
|
||||
<p>hard reset</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getPublicKey, nip19 } from 'nostr-tools';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Resolver, useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { createAccount } from '@libs/storage';
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
import { LoaderIcon } from '@shared/icons';
|
||||
import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
|
||||
@@ -31,7 +30,6 @@ 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);
|
||||
@@ -40,20 +38,7 @@ export function ImportStep1Screen() {
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
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 { db } = useStorage();
|
||||
const {
|
||||
register,
|
||||
setError,
|
||||
@@ -79,15 +64,10 @@ export function ImportStep1Screen() {
|
||||
setPubkey(pubkey);
|
||||
|
||||
// add account to local database
|
||||
account.mutate({
|
||||
npub,
|
||||
pubkey,
|
||||
follows: null,
|
||||
is_active: 1,
|
||||
});
|
||||
db.createAccount(npub, pubkey);
|
||||
|
||||
// redirect to step 2
|
||||
setTimeout(() => navigate('/auth/import/step-2', { replace: true }), 1200);
|
||||
navigate('/auth/import/step-2', { replace: true });
|
||||
}
|
||||
} catch (error) {
|
||||
setError('privkey', {
|
||||
@@ -115,7 +95,7 @@ export function ImportStep1Screen() {
|
||||
{...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 placeholder:text-white/50"
|
||||
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"
|
||||
/>
|
||||
<span className="text-sm text-red-400">
|
||||
{errors.privkey && <p>{errors.privkey.message}</p>}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { appConfigDir } from '@tauri-apps/api/path';
|
||||
import { useEffect, 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';
|
||||
@@ -8,8 +12,6 @@ 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;
|
||||
};
|
||||
@@ -37,7 +39,7 @@ export function ImportStep2Screen() {
|
||||
const [passwordInput, setPasswordInput] = useState('password');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { save } = useSecureStorage();
|
||||
const { db } = useStorage();
|
||||
|
||||
// toggle private key
|
||||
const showPassword = () => {
|
||||
@@ -58,8 +60,13 @@ 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);
|
||||
|
||||
db.secureDB = stronghold;
|
||||
|
||||
// save privkey to secure storage
|
||||
await save(pubkey, privkey, data.password);
|
||||
await db.secureSave(pubkey, privkey);
|
||||
|
||||
// redirect to next step
|
||||
navigate('/auth/import/step-3', { replace: true });
|
||||
@@ -91,12 +98,12 @@ 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 placeholder:text-white/50"
|
||||
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"
|
||||
/>
|
||||
<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 backdrop-blur-xl hover:bg-white/10"
|
||||
>
|
||||
{passwordInput === 'password' ? (
|
||||
<EyeOffIcon className="h-4 w-4 text-white/50 group-hover:text-white" />
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { User } from '@app/auth/components/user';
|
||||
|
||||
import { updateLastLogin } from '@libs/storage';
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
|
||||
|
||||
import { useOnboarding } from '@stores/onboarding';
|
||||
|
||||
import { useAccount } from '@utils/hooks/useAccount';
|
||||
import { useNostr } from '@utils/hooks/useNostr';
|
||||
|
||||
export function ImportStep3Screen() {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const setStep = useOnboarding((state) => state.setStep);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { status, account } = useAccount();
|
||||
const { fetchNotes, fetchChats } = useNostr();
|
||||
const { db } = useStorage();
|
||||
const { fetchUserData, prefetchEvents } = useNostr();
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
// show loading indicator
|
||||
setLoading(true);
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
await fetchNotes();
|
||||
await fetchChats();
|
||||
await updateLastLogin(now);
|
||||
// prefetch data
|
||||
const user = await fetchUserData();
|
||||
const data = await prefetchEvents();
|
||||
|
||||
queryClient.invalidateQueries(['currentAccount']);
|
||||
|
||||
navigate('/auth/onboarding/step-2', { replace: true });
|
||||
// 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);
|
||||
@@ -54,41 +54,29 @@ export function ImportStep3Screen() {
|
||||
{loading ? 'Prefetching data...' : 'Continue with'}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="w-full rounded-xl bg-white/10 p-4">
|
||||
{status === 'loading' ? (
|
||||
<div className="w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-11 w-11 animate-pulse rounded-lg bg-white/10" />
|
||||
<div>
|
||||
<div className="mb-1 h-4 w-16 animate-pulse rounded bg-white/10" />
|
||||
<div className="h-3 w-36 animate-pulse rounded bg-white/10" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
<User pubkey={account.pubkey} />
|
||||
<button
|
||||
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 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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,17 +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 { removePrivkey } from '@libs/storage';
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -38,8 +37,7 @@ export function MigrateScreen() {
|
||||
const [passwordInput, setPasswordInput] = useState('password');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { account } = useAccount();
|
||||
const { save } = useSecureStorage();
|
||||
const { db } = useStorage();
|
||||
|
||||
// toggle private key
|
||||
const showPassword = () => {
|
||||
@@ -63,13 +61,18 @@ export function MigrateScreen() {
|
||||
// load private in secure storage
|
||||
try {
|
||||
// save privkey to secure storage
|
||||
await save(account.pubkey, account.privkey, data.password);
|
||||
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(account.privkey);
|
||||
setPrivkey(db.account.privkey);
|
||||
// remove privkey in db
|
||||
await removePrivkey();
|
||||
await db.removePrivkey();
|
||||
// clear cache
|
||||
await queryClient.invalidateQueries(['currentAccount']);
|
||||
await queryClient.invalidateQueries(['account']);
|
||||
// redirect to home
|
||||
navigate('/', { replace: true });
|
||||
} catch {
|
||||
@@ -96,7 +99,7 @@ export function MigrateScreen() {
|
||||
Upgrade security for your account
|
||||
</h1>
|
||||
</div>
|
||||
<div className="w-full rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 py-3">
|
||||
<div className="w-full rounded-xl bg-white/10 px-3 py-3 backdrop-blur-xl">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<div className="mt-1">
|
||||
@@ -116,7 +119,7 @@ export function MigrateScreen() {
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="mb-0">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium text-zinc-200">
|
||||
<span className="font-medium text-white">
|
||||
Set a password to protect your key
|
||||
</span>
|
||||
<div className="relative">
|
||||
@@ -124,12 +127,12 @@ export function MigrateScreen() {
|
||||
{...register('password', { required: true })}
|
||||
type={passwordInput}
|
||||
placeholder="min. 4 characters"
|
||||
className="relative w-full rounded-lg bg-zinc-800 py-3 pl-3.5 pr-11 text-white !outline-none placeholder:text-white/50"
|
||||
className="relative w-full rounded-lg bg-white/10 py-3 pl-3.5 pr-11 text-white !outline-none placeholder:text-white/50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => showPassword()}
|
||||
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-zinc-700"
|
||||
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-white/10"
|
||||
>
|
||||
{passwordInput === 'password' ? (
|
||||
<EyeOffIcon
|
||||
|
||||
@@ -1,26 +1,24 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
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 { updateAccount } from '@libs/storage';
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
import { ArrowRightCircleIcon, CheckCircleIcon, LoaderIcon } from '@shared/icons';
|
||||
|
||||
import { useOnboarding } from '@stores/onboarding';
|
||||
|
||||
import { useAccount } from '@utils/hooks/useAccount';
|
||||
import { useNostr } from '@utils/hooks/useNostr';
|
||||
import { arrayToNIP02 } from '@utils/transform';
|
||||
|
||||
export function OnboardStep1Screen() {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const setStep = useOnboarding((state) => state.setStep);
|
||||
|
||||
const { publish, fetchNotes } = useNostr();
|
||||
const { account } = useAccount();
|
||||
const { 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) {
|
||||
@@ -44,22 +42,23 @@ export function OnboardStep1Screen() {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const tags = arrayToNIP02([...follows, account.pubkey]);
|
||||
const tags = arrayToNIP02([...follows, db.account.pubkey]);
|
||||
const event = await publish({ content: '', kind: 3, tags: tags });
|
||||
await updateAccount('follows', follows);
|
||||
|
||||
// prefetch notes with current follows
|
||||
const notes = await fetchNotes(follows);
|
||||
// prefetch data
|
||||
const user = await fetchUserData(follows);
|
||||
const data = await prefetchEvents();
|
||||
|
||||
// redirect to next step
|
||||
if (event && notes) {
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries(['currentAccount']);
|
||||
navigate('/auth/onboarding/step-2', { replace: true });
|
||||
}, 1000);
|
||||
if (event && user.status === 'ok' && data.status === 'ok') {
|
||||
navigate('/auth/onboarding/step-2', { replace: true });
|
||||
} else {
|
||||
setLoading(false);
|
||||
console.log('error: ', data.message);
|
||||
}
|
||||
} catch {
|
||||
console.log('error');
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
console.log('error: ', e);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -77,7 +76,7 @@ export function OnboardStep1Screen() {
|
||||
<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">
|
||||
<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" />
|
||||
@@ -89,7 +88,7 @@ export function OnboardStep1Screen() {
|
||||
key={item.pubkey}
|
||||
type="button"
|
||||
onClick={() => toggleFollow(item.pubkey)}
|
||||
className="inline-flex transform items-center justify-between bg-white/10 px-4 py-2 hover:bg-white/20"
|
||||
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) && (
|
||||
@@ -125,7 +124,7 @@ export function OnboardStep1Screen() {
|
||||
</button>
|
||||
<Link
|
||||
to="/auth/onboarding/step-2"
|
||||
className="inline-flex h-11 w-full items-center justify-center rounded-lg px-6 font-medium leading-none text-white hover:bg-white/10 focus:outline-none"
|
||||
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>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { createWidget } from '@libs/storage';
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
import { ArrowRightCircleIcon, CheckCircleIcon, LoaderIcon } from '@shared/icons';
|
||||
|
||||
import { BLOCK_KINDS } from '@stores/constants';
|
||||
import { useOnboarding } from '@stores/onboarding';
|
||||
import { WidgetKinds } from '@stores/widgets';
|
||||
|
||||
const data = [
|
||||
{ hashtag: '#bitcoin' },
|
||||
@@ -33,6 +33,8 @@ export function OnboardStep2Screen() {
|
||||
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) => {
|
||||
@@ -50,10 +52,10 @@ export function OnboardStep2Screen() {
|
||||
setLoading(true);
|
||||
|
||||
for (const tag of tags) {
|
||||
await createWidget(BLOCK_KINDS.hashtag, tag, tag.replace('#', ''));
|
||||
await db.createWidget(WidgetKinds.hashtag, tag, tag.replace('#', ''));
|
||||
}
|
||||
|
||||
setTimeout(() => navigate('/auth/onboarding/step-3', { replace: true }), 1000);
|
||||
navigate('/auth/onboarding/step-3', { replace: true });
|
||||
} catch {
|
||||
console.log('error');
|
||||
}
|
||||
@@ -73,13 +75,13 @@ export function OnboardStep2Screen() {
|
||||
<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">
|
||||
<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 hover:bg-white/20"
|
||||
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) && (
|
||||
@@ -113,7 +115,7 @@ export function OnboardStep2Screen() {
|
||||
</button>
|
||||
<Link
|
||||
to="/auth/onboarding/step-3"
|
||||
className="inline-flex h-11 w-full items-center justify-center rounded-lg px-6 font-medium leading-none text-white hover:bg-white/10 focus:outline-none"
|
||||
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>
|
||||
|
||||
@@ -5,14 +5,13 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { UserRelay } from '@app/auth/components/userRelay';
|
||||
|
||||
import { useNDK } from '@libs/ndk/provider';
|
||||
import { createRelay } from '@libs/storage';
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
import { ArrowRightCircleIcon, CheckCircleIcon, LoaderIcon } from '@shared/icons';
|
||||
|
||||
import { FULL_RELAYS } from '@stores/constants';
|
||||
import { useOnboarding } from '@stores/onboarding';
|
||||
|
||||
import { useAccount } from '@utils/hooks/useAccount';
|
||||
import { useNostr } from '@utils/hooks/useNostr';
|
||||
|
||||
export function OnboardStep3Screen() {
|
||||
@@ -23,13 +22,16 @@ export function OnboardStep3Screen() {
|
||||
const [relays, setRelays] = useState(new Set<string>());
|
||||
|
||||
const { publish } = useNostr();
|
||||
const { account } = useAccount();
|
||||
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: account.follows });
|
||||
const events = await ndk.fetchEvents({
|
||||
kinds: [10002],
|
||||
authors: db.account.follows,
|
||||
});
|
||||
|
||||
if (events) {
|
||||
events.forEach((event) => {
|
||||
@@ -42,7 +44,8 @@ export function OnboardStep3Screen() {
|
||||
return tmp;
|
||||
},
|
||||
{
|
||||
enabled: account ? true : false,
|
||||
enabled: db.account ? true : false,
|
||||
refetchOnWindowFocus: false,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -62,21 +65,22 @@ export function OnboardStep3Screen() {
|
||||
try {
|
||||
if (!skip) {
|
||||
for (const relay of relays) {
|
||||
await createRelay(relay);
|
||||
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 createRelay(relay);
|
||||
await db.createRelay(relay);
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
clearStep();
|
||||
navigate('/', { replace: true });
|
||||
}, 1000);
|
||||
// update last login
|
||||
await db.updateLastLogin();
|
||||
|
||||
clearStep();
|
||||
navigate('/', { replace: true });
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
console.log('error: ', e);
|
||||
@@ -108,16 +112,17 @@ export function OnboardStep3Screen() {
|
||||
</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">
|
||||
<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">
|
||||
<div className="flex h-full w-full items-center justify-center px-6">
|
||||
<p className="text-center text-white/50">
|
||||
Can't found any relays, you can skip this step and use default relays
|
||||
instead
|
||||
Lume couldn't find any relays from your follows.
|
||||
<br />
|
||||
You can skip this step and use default relays instead.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -126,7 +131,7 @@ export function OnboardStep3Screen() {
|
||||
key={item + index}
|
||||
type="button"
|
||||
onClick={() => toggleRelay(item)}
|
||||
className="inline-flex transform items-start justify-between bg-white/10 px-4 py-2 hover:bg-white/20"
|
||||
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>
|
||||
@@ -172,7 +177,7 @@ export function OnboardStep3Screen() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit(true)}
|
||||
className="inline-flex h-11 w-full items-center justify-center rounded-lg px-6 font-medium leading-none text-white hover:bg-white/10 focus:outline-none"
|
||||
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 default relays
|
||||
</button>
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
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 { useNavigate } from 'react-router-dom';
|
||||
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';
|
||||
|
||||
import { useAccount } from '@utils/hooks/useAccount';
|
||||
import { useSecureStorage } from '@utils/hooks/useSecureStorage';
|
||||
|
||||
type FormValues = {
|
||||
password: string;
|
||||
privkey: string;
|
||||
@@ -36,8 +37,7 @@ export function ResetScreen() {
|
||||
const [passwordInput, setPasswordInput] = useState('password');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { account } = useAccount();
|
||||
const { save, reset } = useSecureStorage();
|
||||
const { db } = useStorage();
|
||||
|
||||
// toggle private key
|
||||
const showPassword = () => {
|
||||
@@ -66,7 +66,7 @@ export function ResetScreen() {
|
||||
|
||||
const tmpPubkey = getPublicKey(privkey);
|
||||
|
||||
if (tmpPubkey !== account.pubkey) {
|
||||
if (tmpPubkey !== db.account.pubkey) {
|
||||
setLoading(false);
|
||||
setError('password', {
|
||||
type: 'custom',
|
||||
@@ -75,11 +75,20 @@ export function ResetScreen() {
|
||||
});
|
||||
} else {
|
||||
// remove old stronghold
|
||||
await reset();
|
||||
await db.secureReset();
|
||||
|
||||
// save privkey to secure storage
|
||||
await save(account.pubkey, account.privkey, data.password);
|
||||
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(account.privkey);
|
||||
setPrivkey(db.account.privkey);
|
||||
// redirect to home
|
||||
navigate('/auth/unlock', { replace: true });
|
||||
}
|
||||
@@ -115,7 +124,7 @@ export function ResetScreen() {
|
||||
{...register('privkey', { required: true })}
|
||||
type="text"
|
||||
placeholder="nsec..."
|
||||
className="relative h-12 w-full rounded-lg bg-white/10 px-3.5 py-1 text-white !outline-none placeholder:text-white/10"
|
||||
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>
|
||||
@@ -128,12 +137,12 @@ export function ResetScreen() {
|
||||
{...register('password', { required: true })}
|
||||
type={passwordInput}
|
||||
placeholder="min. 4 characters"
|
||||
className="relative h-12 w-full rounded-lg bg-white/10 px-3.5 py-1 text-white !outline-none placeholder:text-white/10"
|
||||
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 hover:bg-white/10"
|
||||
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" />
|
||||
@@ -146,7 +155,7 @@ export function ResetScreen() {
|
||||
{errors.password && <p>{errors.password.message}</p>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isDirty || !isValid}
|
||||
@@ -158,6 +167,12 @@ export function ResetScreen() {
|
||||
'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>
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
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 { EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
|
||||
import { User } from '@app/auth/components/user';
|
||||
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
import { ArrowRightCircleIcon, 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;
|
||||
};
|
||||
@@ -31,21 +34,10 @@ export function UnlockScreen() {
|
||||
const navigate = useNavigate();
|
||||
const setPrivkey = useStronghold((state) => state.setPrivkey);
|
||||
|
||||
const [passwordInput, setPasswordInput] = useState('password');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const { account } = useAccount();
|
||||
const { load } = useSecureStorage();
|
||||
|
||||
// toggle private key
|
||||
const showPassword = () => {
|
||||
if (passwordInput === 'password') {
|
||||
setPasswordInput('text');
|
||||
} else {
|
||||
setPasswordInput('password');
|
||||
}
|
||||
};
|
||||
const [showPassword, setShowPassword] = useState<boolean>(false);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const { db } = useStorage();
|
||||
const {
|
||||
register,
|
||||
setError,
|
||||
@@ -54,26 +46,24 @@ export function UnlockScreen() {
|
||||
} = useForm<FormValues>({ resolver });
|
||||
|
||||
const onSubmit = async (data: { [x: string]: string }) => {
|
||||
setLoading(true);
|
||||
if (data.password.length > 3) {
|
||||
// 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 {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
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) {
|
||||
setLoading(false);
|
||||
setError('password', {
|
||||
type: 'custom',
|
||||
message: 'Password is required and must be greater than 3',
|
||||
message: e,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -84,45 +74,57 @@ export function UnlockScreen() {
|
||||
<div className="mb-6 text-center">
|
||||
<h1 className="text-2xl font-semibold text-white">Enter password to unlock</h1>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<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>
|
||||
<div className="relative">
|
||||
<input
|
||||
{...register('password', { required: true })}
|
||||
type={passwordInput}
|
||||
className="relative h-12 w-full rounded-lg bg-white/10 py-1 text-center text-white !outline-none placeholder:text-white/10"
|
||||
{...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"
|
||||
/>
|
||||
<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"
|
||||
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"
|
||||
>
|
||||
{passwordInput === 'password' ? (
|
||||
{showPassword ? (
|
||||
<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">
|
||||
<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-12 w-full items-center justify-center rounded-md bg-fuchsia-500 font-medium text-white hover:bg-fuchsia-600"
|
||||
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin text-white" />
|
||||
<>
|
||||
<span className="w-5" />
|
||||
<span>Decryting...</span>
|
||||
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
|
||||
</>
|
||||
) : (
|
||||
'Continue →'
|
||||
<>
|
||||
<span className="w-5" />
|
||||
<span>Continue</span>
|
||||
<ArrowRightCircleIcon className="h-5 w-5" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<Link
|
||||
to="/auth/reset"
|
||||
className="inline-flex h-14 items-center justify-center text-center text-white/50"
|
||||
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>
|
||||
|
||||
@@ -1,38 +1,44 @@
|
||||
import { LogicalSize, appWindow } from '@tauri-apps/plugin-window';
|
||||
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() {
|
||||
useEffect(() => {
|
||||
async function setWindow() {
|
||||
await appWindow.setSize(new LogicalSize(400, 500));
|
||||
await appWindow.setResizable(false);
|
||||
await appWindow.center();
|
||||
}
|
||||
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 () => {
|
||||
appWindow.setSize(new LogicalSize(1080, 800)).then(() => {
|
||||
appWindow.setResizable(false);
|
||||
appWindow.center();
|
||||
});
|
||||
resetWindow();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full flex-col justify-between bg-white/10">
|
||||
<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-2 text-center">
|
||||
<h1 className="text-3xl font-medium text-white">Welcome to Lume</h1>
|
||||
<h3 className="mx-auto w-2/3 text-white/50">
|
||||
<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's get you up and connecting with all peoples around the world on
|
||||
Nostr
|
||||
</h3>
|
||||
</p>
|
||||
</div>
|
||||
<div className="inline-flex w-full flex-col items-center gap-3 px-4 pb-10">
|
||||
<div className="inline-flex w-full flex-col items-center gap-2 px-4 pb-10">
|
||||
<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"
|
||||
@@ -43,7 +49,7 @@ 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-zinc-200 hover:bg-white/20 focus:outline-none"
|
||||
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"
|
||||
>
|
||||
Create new key
|
||||
</Link>
|
||||
@@ -52,6 +58,6 @@ export function WelcomeScreen() {
|
||||
<div className="flex flex-1 items-end justify-center pb-10">
|
||||
<img src="/lume.png" alt="lume" className="h-auto w-1/3" />
|
||||
</div>
|
||||
</div>
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
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-white/50 group-hover:text-white"
|
||||
/>
|
||||
</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-white/50">
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
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(`/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-white/50" />
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="font-medium text-white/50">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-white"
|
||||
>
|
||||
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-white/50">
|
||||
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-white/50 dark:bg-zinc-800 dark:text-white dark:shadow-black/10 dark:placeholder:text-white/50"
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-medium uppercase tracking-wider text-white/50">
|
||||
Picture
|
||||
</span>
|
||||
<div className="relative inline-flex h-36 w-full items-center justify-center overflow-hidden rounded-lg border border-zinc-900 bg-zinc-950">
|
||||
<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-white/50"
|
||||
>
|
||||
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-white !outline-none placeholder:text-white/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="about"
|
||||
className="text-sm font-semibold uppercase tracking-wider text-white/50"
|
||||
>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
{...register('about')}
|
||||
spellCheck={false}
|
||||
className="relative h-20 w-full resize-none rounded-lg bg-zinc-800 px-3 py-2 text-white !outline-none placeholder:text-white/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex h-20 items-center justify-between gap-1 rounded-lg bg-zinc-800 px-4 py-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-semibold leading-none text-white">
|
||||
Encrypted
|
||||
</span>
|
||||
<p className="w-4/5 text-sm leading-none text-white/50">
|
||||
All messages are encrypted and only invited members can view and
|
||||
send message
|
||||
</p>
|
||||
</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-white hover:bg-fuchsia-600 focus:outline-none active:translate-y-1 disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-white" />
|
||||
) : (
|
||||
'Create channel →'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
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={`/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-white' : ''
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded border-t border-zinc-800/50 bg-zinc-900">
|
||||
<span className="text-xs text-white">#</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>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
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-white">{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-white" />
|
||||
</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-white/50`}
|
||||
/>
|
||||
<div className="absolute bottom-0 right-2 h-11">
|
||||
<div className="flex h-full items-center justify-end gap-3 text-white/50">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
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-white/50">
|
||||
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-white/50 hover:bg-zinc-800 hover:text-white"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => hideMessage()}
|
||||
className="inline-flex h-9 items-center justify-center rounded-md bg-red-500 px-2 text-base font-medium text-white hover:bg-red-600"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
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-white">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
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-white/50">
|
||||
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-white/50 hover:bg-zinc-800 hover:text-white"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => muteUser()}
|
||||
className="inline-flex h-9 items-center justify-center rounded-md bg-red-500 px-2 text-base font-medium text-white hover:bg-red-600"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
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-white/50" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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-white/50">
|
||||
Replying to {user?.name || shortenKey(pubkey)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
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/plugin-clipboard-manager');
|
||||
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-white/50" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="leading-tight text-white/50">
|
||||
{metadata?.about || (noteID && `${noteID.substring(0, 24)}...`)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
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-white">
|
||||
{user?.displayName || user?.name || 'Pleb'}
|
||||
</span>
|
||||
<span className="text-base leading-none text-white/50">
|
||||
{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-white/50 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-white/50 hover:bg-zinc-800 hover:text-fuchsia-500"
|
||||
>
|
||||
Mute
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
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-white/50 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-white/50">
|
||||
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-white">Public Channel</h3>
|
||||
</div>
|
||||
<div className="h-full w-full flex-1 p-3">
|
||||
<div className="flex h-full flex-col justify-between overflow-hidden rounded-xl border-t border-zinc-800/50 bg-zinc-900">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -3,55 +3,43 @@ import { twMerge } from 'tailwind-merge';
|
||||
|
||||
import { Image } from '@shared/image';
|
||||
|
||||
import { DEFAULT_AVATAR } from '@stores/constants';
|
||||
|
||||
import { useProfile } from '@utils/hooks/useProfile';
|
||||
import { displayNpub } from '@utils/shortenKey';
|
||||
import { Chats } from '@utils/types';
|
||||
|
||||
export function ChatsListItem({ data }: { data: Chats }) {
|
||||
const { status, user } = useProfile(data.sender_pubkey);
|
||||
export function ChatsListItem({ pubkey }: { pubkey: string }) {
|
||||
const { status, user } = useProfile(pubkey);
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2">
|
||||
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-white/10" />
|
||||
<div className="h-2.5 w-2/3 animate-pulse rounded bg-white/10" />
|
||||
<div 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/${data.sender_pubkey}`}
|
||||
to={`/chats/${pubkey}`}
|
||||
preventScrollReset={true}
|
||||
className={({ isActive }) =>
|
||||
twMerge(
|
||||
'inline-flex h-9 items-center gap-2.5 rounded-md px-2',
|
||||
isActive ? 'bg-white/10 text-white' : 'text-white/80'
|
||||
'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}
|
||||
fallback={DEFAULT_AVATAR}
|
||||
alt={data.sender_pubkey}
|
||||
className="h-6 w-6 shrink-0 rounded object-cover"
|
||||
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?.nip05 ||
|
||||
user?.name ||
|
||||
user?.display_name ||
|
||||
displayNpub(data.sender_pubkey, 16)}
|
||||
{user?.name || user?.display_name || displayNpub(pubkey, 16)}
|
||||
</h5>
|
||||
<div className="flex items-center">
|
||||
{data.new_messages > 0 && (
|
||||
<span className="inline-flex w-8 items-center justify-center rounded bg-fuchsia-400/10 px-1 py-1 text-xs font-medium text-fuchsia-500 ring-1 ring-inset ring-fuchsia-400/20">
|
||||
{data.new_messages}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
@@ -3,28 +3,27 @@ import { useCallback } from 'react';
|
||||
|
||||
import { ChatsListItem } from '@app/chats/components/item';
|
||||
import { NewMessageModal } from '@app/chats/components/modal';
|
||||
import { ChatsListSelfItem } from '@app/chats/components/self';
|
||||
import { UnknownsModal } from '@app/chats/components/unknowns';
|
||||
|
||||
import { getChats } from '@libs/storage';
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
import { useAccount } from '@utils/hooks/useAccount';
|
||||
import { Chats } from '@utils/types';
|
||||
import { useNostr } from '@utils/hooks/useNostr';
|
||||
|
||||
export function ChatsList() {
|
||||
const { account } = useAccount();
|
||||
const {
|
||||
status,
|
||||
data: chats,
|
||||
isFetching,
|
||||
} = useQuery(['chats'], async () => {
|
||||
return await getChats();
|
||||
});
|
||||
const { db } = useStorage();
|
||||
const { fetchNIP04Chats } = useNostr();
|
||||
const { status, data: chats } = useQuery(
|
||||
['nip04-chats'],
|
||||
async () => {
|
||||
return await fetchNIP04Chats();
|
||||
},
|
||||
{ refetchOnWindowFocus: false }
|
||||
);
|
||||
|
||||
const renderItem = useCallback(
|
||||
(item: Chats) => {
|
||||
if (account?.pubkey !== item.sender_pubkey) {
|
||||
return <ChatsListItem key={item.sender_pubkey} data={item} />;
|
||||
(item: string) => {
|
||||
if (db.account.pubkey !== item) {
|
||||
return <ChatsListItem key={item} pubkey={item} />;
|
||||
}
|
||||
},
|
||||
[chats]
|
||||
@@ -33,13 +32,9 @@ export function ChatsList() {
|
||||
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-white/10" />
|
||||
<div className="h-3 w-full animate-pulse rounded-sm bg-white/10" />
|
||||
</div>
|
||||
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2.5">
|
||||
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-white/10" />
|
||||
<div className="h-3 w-full animate-pulse rounded-sm bg-white/10" />
|
||||
<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>
|
||||
);
|
||||
@@ -47,21 +42,7 @@ export function ChatsList() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{account ? (
|
||||
<ChatsListSelfItem data={account} />
|
||||
) : (
|
||||
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2">
|
||||
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-white/10" />
|
||||
<div className="h-3 w-full animate-pulse rounded-sm bg-white/10" />
|
||||
</div>
|
||||
)}
|
||||
{chats.follows.map((item) => renderItem(item))}
|
||||
{isFetching && (
|
||||
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2">
|
||||
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-white/10" />
|
||||
<div className="h-3 w-full animate-pulse rounded-sm bg-white/10" />
|
||||
</div>
|
||||
)}
|
||||
{chats.unknowns.length > 0 && <UnknownsModal data={chats.unknowns} />}
|
||||
<NewMessageModal />
|
||||
</div>
|
||||
|
||||
@@ -1,8 +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';
|
||||
|
||||
@@ -34,7 +36,7 @@ export function ChatMessageForm({
|
||||
|
||||
const handleEnterPress = (e: {
|
||||
key: string;
|
||||
shiftKey: any;
|
||||
shiftKey: KeyboardEvent['shiftKey'];
|
||||
preventDefault: () => void;
|
||||
}) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
@@ -44,27 +46,28 @@ export function ChatMessageForm({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative h-11 w-full">
|
||||
<input
|
||||
<div className="flex w-full items-center justify-between rounded-md bg-white/20 px-3">
|
||||
<TextareaAutosize
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleEnterPress}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
placeholder="Message"
|
||||
className="relative h-11 w-full resize-none rounded-md bg-white/10 px-5 text-white !outline-none placeholder:text-white/50"
|
||||
className="min-h-[44px] flex-1 resize-none bg-transparent py-3 text-white !outline-none placeholder:text-white"
|
||||
/>
|
||||
<div className="absolute right-2 top-0 h-11">
|
||||
<div className="flex h-full items-center justify-end gap-3 text-white/50">
|
||||
<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 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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,32 +1,31 @@
|
||||
import { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||
|
||||
import { useDecryptMessage } from '@app/chats/hooks/useDecryptMessage';
|
||||
|
||||
import { NoteContent } from '@shared/notes';
|
||||
import { User } from '@shared/user';
|
||||
|
||||
import { parser } from '@utils/parser';
|
||||
|
||||
export function ChatMessageItem({
|
||||
data,
|
||||
message,
|
||||
userPubkey,
|
||||
userPrivkey,
|
||||
}: {
|
||||
data: any;
|
||||
message: NDKEvent;
|
||||
userPubkey: string;
|
||||
userPrivkey: string;
|
||||
}) {
|
||||
const decryptedContent = useDecryptMessage(data, userPubkey, userPrivkey);
|
||||
const decryptedContent = useDecryptMessage(message, userPubkey, userPrivkey);
|
||||
// if we have decrypted content, use it instead of the encrypted content
|
||||
if (decryptedContent) {
|
||||
data['content'] = decryptedContent;
|
||||
message['content'] = decryptedContent;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-min min-h-min w-full select-text flex-col px-5 py-3 hover:bg-white/10">
|
||||
<div className="flex 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={data.sender_pubkey} time={data.created_at} isChat={true} />
|
||||
<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">
|
||||
{data.content}
|
||||
{message.content}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||
import { useState } from 'react';
|
||||
import { Dispatch, SetStateAction, useState } from 'react';
|
||||
|
||||
import { LoaderIcon, MediaIcon } from '@shared/icons';
|
||||
|
||||
import { useImageUploader } from '@utils/hooks/useUploader';
|
||||
|
||||
export function MediaUploader({ setState }: { setState: any }) {
|
||||
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) {
|
||||
// update state
|
||||
setState((prev: string) => `${prev}\n${image.url}`);
|
||||
// stop loading
|
||||
setLoading(false);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -26,12 +29,12 @@ export function MediaUploader({ setState }: { setState: any }) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => uploadMedia()}
|
||||
className="group inline-flex h-8 w-8 items-center justify-center rounded hover:bg-white/10"
|
||||
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-black dark:text-white" />
|
||||
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
|
||||
) : (
|
||||
<MediaIcon className="h-5 w-5 text-white/50 group-hover:text-white" />
|
||||
<MediaIcon className="h-5 w-5 text-white" />
|
||||
)}
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
@@ -4,15 +4,15 @@ import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { User } from '@app/auth/components/user';
|
||||
|
||||
import { CancelIcon, LoaderIcon, PlusIcon } from '@shared/icons';
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
import { useAccount } from '@utils/hooks/useAccount';
|
||||
import { CancelIcon, PlusIcon } from '@shared/icons';
|
||||
|
||||
export function NewMessageModal() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const { status, account } = useAccount();
|
||||
const { db } = useStorage();
|
||||
|
||||
const openChat = (pubkey: string) => {
|
||||
setOpen(false);
|
||||
@@ -24,10 +24,10 @@ export function NewMessageModal() {
|
||||
<Dialog.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-9 items-center gap-2.5 rounded-md px-2"
|
||||
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-6 w-6 shrink-0 items-center justify-center rounded bg-white/10">
|
||||
<PlusIcon className="h-3 w-3 text-white" />
|
||||
<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>
|
||||
@@ -35,16 +35,16 @@ export function NewMessageModal() {
|
||||
</button>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Portal className="relative z-10">
|
||||
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xl" />
|
||||
<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">
|
||||
<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 hover:bg-white/10">
|
||||
<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>
|
||||
@@ -54,29 +54,23 @@ export function NewMessageModal() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-[500px] flex-col overflow-y-auto overflow-x-hidden pb-2 pt-2">
|
||||
{status === 'loading' ? (
|
||||
<div className="inline-flex items-center justify-center px-4 py-3">
|
||||
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
|
||||
</div>
|
||||
) : (
|
||||
account?.follows?.map((follow) => (
|
||||
<div
|
||||
key={follow}
|
||||
className="group flex items-center justify-between px-4 py-2 hover:bg-white/10"
|
||||
>
|
||||
<User pubkey={follow} />
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openChat(follow)}
|
||||
className="hidden w-max rounded bg-white/10 px-3 py-1 text-sm font-medium hover:bg-fuchsia-500 group-hover:inline-flex"
|
||||
>
|
||||
Chat
|
||||
</button>
|
||||
</div>
|
||||
{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>
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
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 { displayNpub } from '@utils/shortenKey';
|
||||
|
||||
export function ChatsListSelfItem({ data }: { data: { pubkey: string } }) {
|
||||
const { status, user } = useProfile(data.pubkey);
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="inline-flex h-9 items-center gap-2.5 rounded-md px-2">
|
||||
<div className="relative h-6 w-6 shrink-0 animate-pulse rounded bg-white/10" />
|
||||
<div className="h-2.5 w-2/3 animate-pulse rounded bg-white/10" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
to={`/chats/${data.pubkey}`}
|
||||
preventScrollReset={true}
|
||||
className={({ isActive }) =>
|
||||
twMerge(
|
||||
'inline-flex h-9 items-center gap-2.5 rounded-md px-2',
|
||||
isActive ? 'bg-white/10 text-white' : 'text-white/80'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Image
|
||||
src={user?.picture || user?.image}
|
||||
fallback={DEFAULT_AVATAR}
|
||||
alt={data.pubkey}
|
||||
className="h-6 w-6 shrink-0 rounded bg-white object-cover"
|
||||
/>
|
||||
<div className="inline-flex items-baseline gap-1">
|
||||
<h5 className="max-w-[10rem] truncate">
|
||||
{user?.nip05 || user?.name || displayNpub(data.pubkey, 16)}
|
||||
</h5>
|
||||
<span className="text-white/50">(you)</span>
|
||||
</div>
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Image } from '@shared/image';
|
||||
|
||||
import { DEFAULT_AVATAR } from '@stores/constants';
|
||||
import { NIP05 } from '@shared/nip05';
|
||||
|
||||
import { useProfile } from '@utils/hooks/useProfile';
|
||||
import { displayNpub } from '@utils/shortenKey';
|
||||
@@ -16,7 +15,6 @@ 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"
|
||||
/>
|
||||
@@ -26,15 +24,23 @@ export function ChatSidebar({ pubkey }: { pubkey: string }) {
|
||||
<h3 className="text-lg font-semibold leading-none">
|
||||
{user?.display_name || user?.name}
|
||||
</h3>
|
||||
<h5 className="leading-none text-white/50">
|
||||
{user?.nip05 || displayNpub(pubkey, 16)}
|
||||
</h5>
|
||||
{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>
|
||||
)}
|
||||
</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 hover:bg-fuchsia-500"
|
||||
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"
|
||||
>
|
||||
View full profile
|
||||
</Link>
|
||||
|
||||
@@ -4,12 +4,11 @@ import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { User } from '@app/auth/components/user';
|
||||
|
||||
import { CancelIcon, PlusIcon } from '@shared/icons';
|
||||
import { CancelIcon, StrangersIcon } from '@shared/icons';
|
||||
|
||||
import { compactNumber } from '@utils/number';
|
||||
import { Chats } from '@utils/types';
|
||||
|
||||
export function UnknownsModal({ data }: { data: Chats[] }) {
|
||||
export function UnknownsModal({ data }: { data: string[] }) {
|
||||
const navigate = useNavigate();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -23,10 +22,10 @@ export function UnknownsModal({ data }: { data: Chats[] }) {
|
||||
<Dialog.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-9 items-center gap-2.5 rounded-md px-2"
|
||||
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-6 w-6 shrink-0 items-center justify-center rounded bg-white/10">
|
||||
<PlusIcon className="h-3 w-3 text-white" />
|
||||
<div className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded bg-white/10 backdrop-blur-xl">
|
||||
<StrangersIcon className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h5 className="text-white/50">
|
||||
@@ -36,16 +35,16 @@ export function UnknownsModal({ data }: { data: Chats[] }) {
|
||||
</button>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Portal className="relative z-10">
|
||||
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xl" />
|
||||
<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">
|
||||
<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">
|
||||
{data.length} unknowns
|
||||
</Dialog.Title>
|
||||
<Dialog.Close className="inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-white/10">
|
||||
<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>
|
||||
@@ -55,17 +54,17 @@ export function UnknownsModal({ data }: { data: Chats[] }) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-[500px] flex-col overflow-y-auto overflow-x-hidden pb-2 pt-2">
|
||||
{data.map((user) => (
|
||||
{data.map((pubkey) => (
|
||||
<div
|
||||
key={user.sender_pubkey}
|
||||
className="group flex items-center justify-between px-4 py-2 hover:bg-white/10"
|
||||
key={pubkey}
|
||||
className="group flex items-center justify-between px-4 py-2 backdrop-blur-xl hover:bg-white/10"
|
||||
>
|
||||
<User pubkey={user.sender_pubkey} />
|
||||
<User pubkey={pubkey} />
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openChat(user.sender_pubkey)}
|
||||
className="hidden w-max rounded bg-white/10 px-3 py-1 text-sm font-medium hover:bg-fuchsia-500 group-hover:inline-flex"
|
||||
onClick={() => openChat(pubkey)}
|
||||
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>
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||
import { nip04 } from 'nostr-tools';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Chats } from '@utils/types';
|
||||
|
||||
export function useDecryptMessage(data: Chats, userPubkey: string, userPriv: string) {
|
||||
const [content, setContent] = useState(data.content);
|
||||
export function useDecryptMessage(
|
||||
message: NDKEvent,
|
||||
userPubkey: string,
|
||||
userPriv: string
|
||||
) {
|
||||
const [content, setContent] = useState(message.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);
|
||||
userPubkey === message.pubkey
|
||||
? message.tags.find((el) => el[0] === 'p')[1]
|
||||
: message.pubkey;
|
||||
const result = await nip04.decrypt(userPriv, pubkey, message.content);
|
||||
setContent(result);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NDKSubscription } from '@nostr-dev-kit/ndk';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
@@ -9,38 +9,34 @@ import { ChatMessageItem } from '@app/chats/components/messages/item';
|
||||
import { ChatSidebar } from '@app/chats/components/sidebar';
|
||||
|
||||
import { useNDK } from '@libs/ndk/provider';
|
||||
import { createChat, getChatMessages } from '@libs/storage';
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
import { LoaderIcon } from '@shared/icons';
|
||||
|
||||
import { useStronghold } from '@stores/stronghold';
|
||||
|
||||
import { useAccount } from '@utils/hooks/useAccount';
|
||||
import { Chats } from '@utils/types';
|
||||
import { useNostr } from '@utils/hooks/useNostr';
|
||||
|
||||
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 { account } = useAccount();
|
||||
const { status, data } = useQuery(
|
||||
['chat', pubkey],
|
||||
async () => {
|
||||
return await getChatMessages(account.pubkey, pubkey);
|
||||
},
|
||||
{
|
||||
enabled: account ? true : false,
|
||||
}
|
||||
);
|
||||
|
||||
const userPrivkey = useStronghold((state) => state.privkey);
|
||||
const { fetchNIP04Messages } = useNostr();
|
||||
const { status, data } = useQuery(['nip04-dm', pubkey], async () => {
|
||||
return await fetchNIP04Messages(pubkey);
|
||||
});
|
||||
|
||||
const itemContent = useCallback(
|
||||
(index: string | number) => {
|
||||
const message = data[index];
|
||||
if (!message) return;
|
||||
return (
|
||||
<ChatMessageItem
|
||||
data={data[index]}
|
||||
userPubkey={account.pubkey}
|
||||
message={message}
|
||||
userPubkey={db.account.pubkey}
|
||||
userPrivkey={userPrivkey}
|
||||
/>
|
||||
);
|
||||
@@ -55,27 +51,11 @@ export function ChatScreen() {
|
||||
[data]
|
||||
);
|
||||
|
||||
const chat = useMutation({
|
||||
mutationFn: (data: Chats) => {
|
||||
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: [account.pubkey],
|
||||
authors: [db.account.pubkey],
|
||||
'#p': [pubkey],
|
||||
since: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
@@ -85,14 +65,7 @@ export function ChatScreen() {
|
||||
);
|
||||
|
||||
sub.addListener('event', (event) => {
|
||||
chat.mutate({
|
||||
id: event.id,
|
||||
receiver_pubkey: pubkey,
|
||||
sender_pubkey: event.pubkey,
|
||||
content: event.content,
|
||||
tags: event.tags,
|
||||
created_at: event.created_at,
|
||||
});
|
||||
console.log(event);
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -101,13 +74,18 @@ export function ChatScreen() {
|
||||
}, [pubkey]);
|
||||
|
||||
return (
|
||||
<div className="grid h-full w-full grid-cols-3 bg-white/10">
|
||||
<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="h-full w-full flex-1 p-3">
|
||||
<div className="flex h-full flex-col justify-between overflow-hidden rounded-xl bg-white/10">
|
||||
<div className="flex h-full flex-col justify-between overflow-hidden rounded-xl bg-white/10 backdrop-blur-xl">
|
||||
<div className="h-full w-full flex-1">
|
||||
{status === 'loading' ? (
|
||||
<p>Loading...</p>
|
||||
<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>
|
||||
) : (
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
@@ -126,10 +104,10 @@ 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">
|
||||
<div className="z-50 shrink-0 rounded-b-xl border-t border-white/5 bg-white/10 p-3 px-5 backdrop-blur-xl">
|
||||
<ChatMessageForm
|
||||
receiverPubkey={pubkey}
|
||||
userPubkey={account.pubkey}
|
||||
userPubkey={db.account.pubkey}
|
||||
userPrivkey={userPrivkey}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,93 @@
|
||||
import { useRouteError } from 'react-router-dom';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLocation, useRouteError } from 'react-router-dom';
|
||||
|
||||
interface IRouteError {
|
||||
import { Frame } from '@shared/frame';
|
||||
|
||||
interface RouteError {
|
||||
statusText: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface DebugInfo {
|
||||
os: null | string;
|
||||
version: null | string;
|
||||
appDir: null | string;
|
||||
}
|
||||
|
||||
export function ErrorScreen() {
|
||||
const error = useRouteError() as IRouteError;
|
||||
const error = useRouteError() as RouteError;
|
||||
const location = useLocation();
|
||||
|
||||
const [debugInfo, setDebugInfo] = useState<DebugInfo>({
|
||||
os: null,
|
||||
version: null,
|
||||
appDir: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
async function getInformation() {
|
||||
const { platform, version } = await import('@tauri-apps/api/os');
|
||||
const { getVersion } = await import('@tauri-apps/api/app');
|
||||
const { appConfigDir } = await import('@tauri-apps/api/path');
|
||||
|
||||
const platformName = await platform();
|
||||
const osVersion = await version();
|
||||
const appVersion = await getVersion();
|
||||
const appDir = await appConfigDir();
|
||||
|
||||
setDebugInfo({
|
||||
os: platformName + ' ' + osVersion,
|
||||
version: appVersion,
|
||||
appDir: appDir,
|
||||
});
|
||||
}
|
||||
|
||||
getInformation();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div>
|
||||
<h1>Oops!</h1>
|
||||
<p>Sorry, an unexpected error has occurred.</p>
|
||||
<p>
|
||||
<i>{error.statusText || error.message}</i>
|
||||
</p>
|
||||
<Frame className="flex h-full items-center justify-center">
|
||||
<div className="flex w-full flex-col gap-4 px-4 md:max-w-lg md:px-0">
|
||||
<div className="flex flex-col">
|
||||
<h1 className="mb-1 text-2xl font-semibold text-white">
|
||||
Sorry, an unexpected error has occurred.
|
||||
</h1>
|
||||
<div className="mt-4 inline-flex h-16 items-center justify-center rounded-xl border border-dashed border-red-400 bg-red-200/10 px-5">
|
||||
<p className="select-text text-sm font-medium text-red-400">
|
||||
{error.statusText || error.message}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<p className="font-medium text-white/50">
|
||||
Current location: {location.pathname}
|
||||
</p>
|
||||
<p className="font-medium text-white/50">App version: {debugInfo.version}</p>
|
||||
<p className="font-medium text-white/50">Platform: {debugInfo.os}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<a
|
||||
href="https://github.com/luminous-devs/lume/issues/new"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex h-11 w-full items-center justify-center rounded-lg text-sm font-medium text-white backdrop-blur-xl hover:bg-white/10"
|
||||
>
|
||||
Click here to report the issue on GitHub
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-11 w-full items-center justify-center rounded-lg text-sm font-medium text-white backdrop-blur-xl hover:bg-white/10"
|
||||
>
|
||||
Reload app
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-11 w-full items-center justify-center rounded-lg text-sm font-medium text-white backdrop-blur-xl hover:bg-white/10"
|
||||
>
|
||||
Reset app
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import {
|
||||
NoteActions,
|
||||
NoteContent,
|
||||
NoteReplyForm,
|
||||
NoteStats,
|
||||
ThreadUser,
|
||||
} from '@shared/notes';
|
||||
import { RepliesList } from '@shared/notes/replies/list';
|
||||
import { NoteSkeleton } from '@shared/notes/skeleton';
|
||||
|
||||
import { useAccount } from '@utils/hooks/useAccount';
|
||||
import { useEvent } from '@utils/hooks/useEvent';
|
||||
|
||||
export function EventScreen() {
|
||||
const { id } = useParams();
|
||||
const { account } = useAccount();
|
||||
const { status, data } = useEvent(id);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-[600px]">
|
||||
<div className="scrollbar-hide flex h-full w-full flex-col gap-1.5 overflow-y-auto pt-11">
|
||||
{status === 'loading' ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-xl bg-white/10 px-3 py-3">
|
||||
<NoteSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-min w-full px-3 pt-1.5">
|
||||
<div className="rounded-xl bg-white/10 px-3 pt-3">
|
||||
<ThreadUser pubkey={data.pubkey} time={data.created_at} />
|
||||
<div className="mt-2">
|
||||
<NoteContent content={data.content} />
|
||||
</div>
|
||||
<div>
|
||||
<NoteActions id={id} pubkey={data.pubkey} noOpenThread={true} />
|
||||
<NoteStats id={id} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="px-3">
|
||||
<NoteReplyForm id={id} pubkey={account.pubkey} />
|
||||
<RepliesList id={id} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
122
src/app/notes/article.tsx
Normal file
122
src/app/notes/article.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
|
||||
import { writeText } from '@tauri-apps/api/clipboard';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { EventPointer } from 'nostr-tools/lib/nip19';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
import { ArrowLeftIcon, CheckCircleIcon, ReplyIcon, ShareIcon } from '@shared/icons';
|
||||
import {
|
||||
ArticleDetailNote,
|
||||
NoteActions,
|
||||
NoteReplyForm,
|
||||
NoteStats,
|
||||
ThreadUser,
|
||||
UnknownNote,
|
||||
} from '@shared/notes';
|
||||
import { RepliesList } from '@shared/notes/replies/list';
|
||||
import { NoteSkeleton } from '@shared/notes/skeleton';
|
||||
|
||||
import { useEvent } from '@utils/hooks/useEvent';
|
||||
|
||||
export function ArticleNoteScreen() {
|
||||
const navigate = useNavigate();
|
||||
const replyRef = useRef(null);
|
||||
|
||||
const { id } = useParams();
|
||||
const { db } = useStorage();
|
||||
const { status, data } = useEvent(id);
|
||||
|
||||
const [isCopy, setIsCopy] = useState(false);
|
||||
|
||||
const share = async () => {
|
||||
await writeText(
|
||||
'https://nostr.com/' +
|
||||
nip19.neventEncode({ id: data.id, author: data.pubkey } as EventPointer)
|
||||
);
|
||||
// update state
|
||||
setIsCopy(true);
|
||||
// reset state after 2 sec
|
||||
setTimeout(() => setIsCopy(false), 2000);
|
||||
};
|
||||
|
||||
const scrollToReply = () => {
|
||||
replyRef.current.scrollIntoView();
|
||||
};
|
||||
|
||||
const renderKind = (event: NDKEvent) => {
|
||||
switch (event.kind) {
|
||||
case NDKKind.Article:
|
||||
return <ArticleDetailNote event={event} />;
|
||||
default:
|
||||
return <UnknownNote event={event} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="scrollbar-hide h-full w-full overflow-y-auto scroll-smooth">
|
||||
<div className="container mx-auto px-4 pt-16 sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-5">
|
||||
<div className="col-span-1 pr-8">
|
||||
<div className="sticky top-16 flex flex-col items-end gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(-1)}
|
||||
className="inline-flex h-12 w-12 items-center justify-center rounded-xl border border-white/10 bg-white/5"
|
||||
>
|
||||
<ArrowLeftIcon className="h-5 w-5 text-white" />
|
||||
</button>
|
||||
<div className="flex flex-col divide-y divide-white/5 rounded-xl border border-white/10 bg-white/5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={share}
|
||||
className="sticky top-16 inline-flex h-12 w-12 items-center justify-center rounded-t-xl "
|
||||
>
|
||||
{isCopy ? (
|
||||
<CheckCircleIcon className="h-5 w-5 text-green-500" />
|
||||
) : (
|
||||
<ShareIcon className="h-5 w-5 text-white" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={scrollToReply}
|
||||
className="sticky top-16 inline-flex h-12 w-12 items-center justify-center rounded-b-xl"
|
||||
>
|
||||
<ReplyIcon className="h-5 w-5 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-3 flex flex-col gap-1.5">
|
||||
{status === 'loading' ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-xl bg-white/10 px-3 py-3 backdrop-blur-xl">
|
||||
<NoteSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-min w-full px-3">
|
||||
<div className="rounded-xl bg-white/10 px-3 pt-3 backdrop-blur-xl">
|
||||
<ThreadUser pubkey={data.pubkey} time={data.created_at} />
|
||||
<div className="mt-2">{renderKind(data)}</div>
|
||||
<div>
|
||||
<NoteActions id={id} pubkey={data.pubkey} extraButtons={false} />
|
||||
<NoteStats id={id} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={replyRef} className="px-3">
|
||||
<NoteReplyForm id={id} pubkey={db.account.pubkey} />
|
||||
<RepliesList id={id} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
128
src/app/notes/text.tsx
Normal file
128
src/app/notes/text.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
|
||||
import { writeText } from '@tauri-apps/api/clipboard';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { EventPointer } from 'nostr-tools/lib/nip19';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
import { ArrowLeftIcon, CheckCircleIcon, ReplyIcon, ShareIcon } from '@shared/icons';
|
||||
import {
|
||||
ArticleNote,
|
||||
FileNote,
|
||||
NoteActions,
|
||||
NoteReplyForm,
|
||||
NoteStats,
|
||||
TextNote,
|
||||
ThreadUser,
|
||||
UnknownNote,
|
||||
} from '@shared/notes';
|
||||
import { RepliesList } from '@shared/notes/replies/list';
|
||||
import { NoteSkeleton } from '@shared/notes/skeleton';
|
||||
|
||||
import { useEvent } from '@utils/hooks/useEvent';
|
||||
|
||||
export function TextNoteScreen() {
|
||||
const navigate = useNavigate();
|
||||
const replyRef = useRef(null);
|
||||
|
||||
const { id } = useParams();
|
||||
const { db } = useStorage();
|
||||
const { status, data } = useEvent(id);
|
||||
|
||||
const [isCopy, setIsCopy] = useState(false);
|
||||
|
||||
const share = async () => {
|
||||
await writeText(
|
||||
'https://nostr.com/' +
|
||||
nip19.neventEncode({ id: data.id, author: data.pubkey } as EventPointer)
|
||||
);
|
||||
// update state
|
||||
setIsCopy(true);
|
||||
// reset state after 2 sec
|
||||
setTimeout(() => setIsCopy(false), 2000);
|
||||
};
|
||||
|
||||
const scrollToReply = () => {
|
||||
replyRef.current.scrollIntoView();
|
||||
};
|
||||
|
||||
const renderKind = (event: NDKEvent) => {
|
||||
switch (event.kind) {
|
||||
case NDKKind.Text:
|
||||
return <TextNote content={event.content} />;
|
||||
case NDKKind.Article:
|
||||
return <ArticleNote event={event} />;
|
||||
case 1063:
|
||||
return <FileNote event={event} />;
|
||||
default:
|
||||
return <UnknownNote event={event} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="scrollbar-hide h-full w-full overflow-y-auto scroll-smooth">
|
||||
<div className="container mx-auto px-4 pt-16 sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-5">
|
||||
<div className="col-span-1 pr-8">
|
||||
<div className="sticky top-16 flex flex-col items-end gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(-1)}
|
||||
className="inline-flex h-12 w-12 items-center justify-center rounded-xl border border-white/10 bg-white/5"
|
||||
>
|
||||
<ArrowLeftIcon className="h-5 w-5 text-white" />
|
||||
</button>
|
||||
<div className="flex flex-col divide-y divide-white/5 rounded-xl border border-white/10 bg-white/5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={share}
|
||||
className="sticky top-16 inline-flex h-12 w-12 items-center justify-center rounded-t-xl "
|
||||
>
|
||||
{isCopy ? (
|
||||
<CheckCircleIcon className="h-5 w-5 text-green-500" />
|
||||
) : (
|
||||
<ShareIcon className="h-5 w-5 text-white" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={scrollToReply}
|
||||
className="sticky top-16 inline-flex h-12 w-12 items-center justify-center rounded-b-xl"
|
||||
>
|
||||
<ReplyIcon className="h-5 w-5 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-3 flex flex-col gap-1.5">
|
||||
{status === 'loading' ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-xl bg-white/10 px-3 py-3 backdrop-blur-xl">
|
||||
<NoteSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-min w-full px-3">
|
||||
<div className="rounded-xl bg-white/10 px-3 pt-3 backdrop-blur-xl">
|
||||
<ThreadUser pubkey={data.pubkey} time={data.created_at} />
|
||||
<div className="mt-2">{renderKind(data)}</div>
|
||||
<div>
|
||||
<NoteActions id={id} pubkey={data.pubkey} extraButtons={false} />
|
||||
<NoteStats id={id} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={replyRef} className="px-3">
|
||||
<NoteReplyForm id={id} pubkey={db.account.pubkey} />
|
||||
<RepliesList id={id} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
src/app/notifications/components/content.tsx
Normal file
27
src/app/notifications/components/content.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
import { Hashtag, MentionUser } from '@shared/notes';
|
||||
|
||||
import { RichContent } from '@utils/types';
|
||||
|
||||
export function NotiContent({ content }: { content: RichContent }) {
|
||||
return (
|
||||
<>
|
||||
<ReactMarkdown
|
||||
className="markdown"
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
del: ({ children }) => {
|
||||
const key = children[0] as string;
|
||||
if (key.startsWith('pub') && key.length > 50 && key.length < 100)
|
||||
return <MentionUser pubkey={key.replace('pub-', '')} />;
|
||||
if (key.startsWith('tag')) return <Hashtag tag={key.replace('tag-', '')} />;
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content?.parsed}
|
||||
</ReactMarkdown>
|
||||
</>
|
||||
);
|
||||
}
|
||||
32
src/app/notifications/components/mention.tsx
Normal file
32
src/app/notifications/components/mention.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { NotiContent } from '@app/notifications/components/content';
|
||||
import { NotiUser } from '@app/notifications/components/user';
|
||||
|
||||
import { formatCreatedAt } from '@utils/createdAt';
|
||||
import { parser } from '@utils/parser';
|
||||
|
||||
export function NotiMention({ event }: { event: NDKEvent }) {
|
||||
const createdAt = formatCreatedAt(event.created_at);
|
||||
const content = useMemo(() => parser(event), [event]);
|
||||
|
||||
return (
|
||||
<div className="h-min w-full px-3 py-1.5">
|
||||
<div className="relative overflow-hidden rounded-xl bg-white/10 px-3 pt-3 backdrop-blur-xl">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-1">
|
||||
<NotiUser pubkey={event.pubkey} />
|
||||
<p className="leading-none text-white/50">has reply you post · {createdAt}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="f- relative z-10 -mt-6 flex gap-3">
|
||||
<div className="h-11 w-11 shrink-0" />
|
||||
<div className="mb-2 mt-3 w-full cursor-default rounded-lg bg-white/10 px-3 py-3 backdrop-blur-xl">
|
||||
<NotiContent content={content} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
src/app/notifications/components/reaction.tsx
Normal file
30
src/app/notifications/components/reaction.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||
|
||||
import { SimpleNote } from '@app/notifications/components/simpleNote';
|
||||
import { NotiUser } from '@app/notifications/components/user';
|
||||
|
||||
import { formatCreatedAt } from '@utils/createdAt';
|
||||
|
||||
export function NotiReaction({ event }: { event: NDKEvent }) {
|
||||
const root = event.tags.find((e) => e[0] === 'e')?.[1];
|
||||
const createdAt = formatCreatedAt(event.created_at);
|
||||
|
||||
return (
|
||||
<div className="h-min w-full px-3 py-1.5">
|
||||
<div className="relative overflow-hidden rounded-xl bg-white/10 px-3 pt-3 backdrop-blur-xl">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-1">
|
||||
<NotiUser pubkey={event.pubkey} />
|
||||
<p className="leading-none text-white/50">
|
||||
reacted {event.content} · {createdAt}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative z-10 -mt-6 flex gap-3">
|
||||
<div className="h-11 w-11 shrink-0" />
|
||||
<div className="flex-1">{root && <SimpleNote id={root} />}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
src/app/notifications/components/repost.tsx
Normal file
38
src/app/notifications/components/repost.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||
|
||||
import { SimpleNote } from '@app/notifications/components/simpleNote';
|
||||
import { NotiUser } from '@app/notifications/components/user';
|
||||
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
import { formatCreatedAt } from '@utils/createdAt';
|
||||
|
||||
export function NotiRepost({ event }: { event: NDKEvent }) {
|
||||
const { db } = useStorage();
|
||||
|
||||
const root = event.tags.find((e) => e[0] === 'e')?.[1];
|
||||
const createdAt = formatCreatedAt(event.created_at);
|
||||
|
||||
return (
|
||||
<div className="h-min w-full px-3 py-1.5">
|
||||
<div className="relative overflow-hidden rounded-xl bg-white/10 px-3 pt-3 backdrop-blur-xl">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-1">
|
||||
<NotiUser pubkey={event.pubkey} />
|
||||
<p className="leading-none text-white/50">
|
||||
repost{' '}
|
||||
{event.pubkey !== db.account.pubkey
|
||||
? 'a post that mention you'
|
||||
: 'your post'}{' '}
|
||||
· {createdAt}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative z-10 -mt-6 flex gap-3">
|
||||
<div className="h-11 w-11 shrink-0" />
|
||||
<div className="flex-1">{root && <SimpleNote id={root} />}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
61
src/app/notifications/components/simpleNote.tsx
Normal file
61
src/app/notifications/components/simpleNote.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
import { NoteSkeleton } from '@shared/notes';
|
||||
import { User } from '@shared/user';
|
||||
|
||||
import { WidgetKinds, useWidgets } from '@stores/widgets';
|
||||
|
||||
import { useEvent } from '@utils/hooks/useEvent';
|
||||
|
||||
export const SimpleNote = memo(function SimpleNote({ id }: { id: string }) {
|
||||
const { db } = useStorage();
|
||||
const { status, data } = useEvent(id);
|
||||
|
||||
const setWidget = useWidgets((state) => state.setWidget);
|
||||
|
||||
const openThread = (event, thread: string) => {
|
||||
const selection = window.getSelection();
|
||||
if (selection.toString().length === 0) {
|
||||
setWidget(db, { kind: WidgetKinds.local.thread, title: 'Thread', content: thread });
|
||||
} else {
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="mb-2 mt-3 cursor-default rounded-lg bg-white/10 px-3 py-3 backdrop-blur-xl">
|
||||
<NoteSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<div className="mb-2 mt-3 cursor-default rounded-lg bg-white/10 px-3 py-3 backdrop-blur-xl">
|
||||
<p>Can't get event from relay</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={(e) => openThread(e, id)}
|
||||
onKeyDown={(e) => openThread(e, id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="mb-2 mt-3 cursor-default rounded-lg bg-white/10 px-3 py-3 backdrop-blur-xl"
|
||||
>
|
||||
<User pubkey={data.pubkey} time={data.created_at} size="small" />
|
||||
<div className="markdown">
|
||||
<p>
|
||||
{data.content.length > 200
|
||||
? data.content.substring(0, 200) + '...'
|
||||
: data.content}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Image } from '@shared/image';
|
||||
|
||||
import { DEFAULT_AVATAR } from '@stores/constants';
|
||||
|
||||
import { useProfile } from '@utils/hooks/useProfile';
|
||||
import { displayNpub } from '@utils/shortenKey';
|
||||
|
||||
@@ -11,9 +9,9 @@ export function NotiUser({ pubkey }: { pubkey: string }) {
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="relative h-8 w-8 shrink-0 animate-pulse rounded-md bg-zinc-800" />
|
||||
<div className="relative h-8 w-8 shrink-0 animate-pulse rounded-md bg-white/10" />
|
||||
<div className="flex w-full flex-1 flex-col items-start gap-1 text-start">
|
||||
<span className="h-4 w-1/2 animate-pulse rounded bg-zinc-800" />
|
||||
<span className="h-4 w-1/2 animate-pulse rounded bg-white/10" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -23,12 +21,11 @@ export function NotiUser({ pubkey }: { pubkey: string }) {
|
||||
<div className="flex shrink-0 items-start justify-start gap-3">
|
||||
<Image
|
||||
src={user?.picture || user?.image}
|
||||
fallback={DEFAULT_AVATAR}
|
||||
alt={pubkey}
|
||||
className="h-10 w-10 shrink-0 rounded-md object-cover"
|
||||
className="h-11 w-11 shrink-0 rounded-lg object-cover"
|
||||
/>
|
||||
<span className="max-w-[10rem] flex-1 truncate font-medium leading-none text-white">
|
||||
{user?.nip05 || user?.name || user?.display_name || displayNpub(pubkey, 16)}
|
||||
{user?.name || user?.display_name || displayNpub(pubkey, 16)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
73
src/app/notifications/index.tsx
Normal file
73
src/app/notifications/index.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { NotiMention } from '@app/notifications/components/mention';
|
||||
import { NotiReaction } from '@app/notifications/components/reaction';
|
||||
import { NotiRepost } from '@app/notifications/components/repost';
|
||||
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
import { LoaderIcon } from '@shared/icons';
|
||||
import { TitleBar } from '@shared/titleBar';
|
||||
|
||||
import { useNostr } from '@utils/hooks/useNostr';
|
||||
|
||||
export function NotificationScreen() {
|
||||
const { db } = useStorage();
|
||||
const { fetchActivities } = useNostr();
|
||||
const { status, data } = useQuery(
|
||||
['notifications', db.account.pubkey],
|
||||
async () => {
|
||||
return await fetchActivities();
|
||||
},
|
||||
{ refetchOnWindowFocus: false }
|
||||
);
|
||||
|
||||
const renderItem = useCallback(
|
||||
(event: NDKEvent) => {
|
||||
switch (event.kind) {
|
||||
case 1:
|
||||
return <NotiMention key={event.id} event={event} />;
|
||||
case 6:
|
||||
return <NotiRepost key={event.id} event={event} />;
|
||||
case 7:
|
||||
return <NotiReaction key={event.id} event={event} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[data]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="scrollbar-hide h-full w-full overflow-y-auto bg-white/10 backdrop-blur-xl">
|
||||
<div className="grid h-full grid-cols-3">
|
||||
<div className="col-span-2 flex flex-col border-r border-white/5">
|
||||
<TitleBar title="Activities in the last 24 hours" />
|
||||
<div className="flex h-full flex-col gap-1.5">
|
||||
<div className="flex h-full flex-col">
|
||||
{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</p>
|
||||
</div>
|
||||
</div>
|
||||
) : data?.length < 1 ? (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center">
|
||||
<p className="mb-1 text-4xl">🎉</p>
|
||||
<p className="font-medium text-white/50">
|
||||
Yo!, no new activities around you in the last 24 hours
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
data.map((event) => renderItem(event))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,91 +1,126 @@
|
||||
import { useState } from 'react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
import { EyeOffIcon, EyeOnIcon } from '@shared/icons';
|
||||
|
||||
import { useStronghold } from '@stores/stronghold';
|
||||
|
||||
import { useAccount } from '@utils/hooks/useAccount';
|
||||
|
||||
export function AccountSettingsScreen() {
|
||||
const { status, account } = useAccount();
|
||||
const [type, setType] = useState('password');
|
||||
const { db } = useStorage();
|
||||
|
||||
const [privType, setPrivType] = useState('password');
|
||||
const [nsecType, setNsecType] = useState('password');
|
||||
|
||||
const privkey = useStronghold((state) => state.privkey);
|
||||
const nsec = useMemo(() => nip19.nsecEncode(privkey), [privkey]);
|
||||
|
||||
const showPrivateKey = () => {
|
||||
if (type === 'password') {
|
||||
setType('text');
|
||||
const showPrivkey = () => {
|
||||
if (privType === 'password') {
|
||||
setPrivType('text');
|
||||
} else {
|
||||
setType('password');
|
||||
setPrivType('password');
|
||||
}
|
||||
};
|
||||
|
||||
const showNsec = () => {
|
||||
if (nsecType === 'password') {
|
||||
setNsecType('text');
|
||||
} else {
|
||||
setNsecType('password');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full w-full px-3 pt-12">
|
||||
<div className="h-full w-full px-3 pt-11">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-lg font-semibold text-white">Account</h1>
|
||||
<div className="">
|
||||
{status === 'loading' ? (
|
||||
<p>Loading...</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="pubkey" className="text-base font-semibold text-white/50">
|
||||
Public Key
|
||||
</label>
|
||||
<input
|
||||
readOnly
|
||||
value={account.pubkey}
|
||||
className="relative w-2/3 rounded-lg bg-white/10 py-3 pl-3.5 pr-11 text-white !outline-none placeholder:text-white/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="npub" className="text-base font-semibold text-white/50">
|
||||
Npub
|
||||
</label>
|
||||
<input
|
||||
readOnly
|
||||
value={account.npub}
|
||||
className="relative w-2/3 rounded-lg bg-white/10 py-3 pl-3.5 pr-11 text-white !outline-none placeholder:text-white/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="privkey"
|
||||
className="text-base font-semibold text-white/50"
|
||||
>
|
||||
Private Key
|
||||
</label>
|
||||
<div className="relative w-2/3">
|
||||
<input
|
||||
readOnly
|
||||
type={type}
|
||||
value={privkey}
|
||||
className="relative w-full rounded-lg bg-white/10 py-3 pl-3.5 pr-11 text-white !outline-none placeholder:text-white/50"
|
||||
<h1 className="text-xl font-semibold text-white">Account</h1>
|
||||
<div className="flex flex-col gap-4 rounded-xl bg-white/10 p-3 backdrop-blur-xl">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="pubkey" className="text-base font-semibold text-white/50">
|
||||
Public Key
|
||||
</label>
|
||||
<input
|
||||
readOnly
|
||||
value={db.account.pubkey}
|
||||
className="relative w-full rounded-lg bg-white/10 py-3 pl-3.5 pr-11 text-white !outline-none backdrop-blur-xl placeholder:text-white/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="npub" className="text-base font-semibold text-white/50">
|
||||
Npub
|
||||
</label>
|
||||
<input
|
||||
readOnly
|
||||
value={db.account.npub}
|
||||
className="relative w-full rounded-lg bg-white/10 py-3 pl-3.5 pr-11 text-white !outline-none backdrop-blur-xl placeholder:text-white/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="privkey" className="text-base font-semibold text-white/50">
|
||||
Private Key
|
||||
</label>
|
||||
<div className="relative w-full">
|
||||
<input
|
||||
readOnly
|
||||
type={privType}
|
||||
value={privkey}
|
||||
className="relative w-full rounded-lg bg-white/10 py-3 pl-3.5 pr-11 text-white !outline-none backdrop-blur-xl placeholder:text-white/50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => showPrivkey()}
|
||||
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-zinc-700"
|
||||
>
|
||||
{privType === 'password' ? (
|
||||
<EyeOffIcon
|
||||
width={20}
|
||||
height={20}
|
||||
className="text-white/50 group-hover:text-white"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => showPrivateKey()}
|
||||
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-zinc-700"
|
||||
>
|
||||
{type === 'password' ? (
|
||||
<EyeOffIcon
|
||||
width={20}
|
||||
height={20}
|
||||
className="text-white/50 group-hover:text-white"
|
||||
/>
|
||||
) : (
|
||||
<EyeOnIcon
|
||||
width={20}
|
||||
height={20}
|
||||
className="text-white/50 group-hover:text-white"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<EyeOnIcon
|
||||
width={20}
|
||||
height={20}
|
||||
className="text-white/50 group-hover:text-white"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="privkey" className="text-base font-semibold text-white/50">
|
||||
Nsec
|
||||
</label>
|
||||
<div className="relative w-full">
|
||||
<input
|
||||
readOnly
|
||||
type={nsecType}
|
||||
value={nsec}
|
||||
className="relative w-full rounded-lg bg-white/10 py-3 pl-3.5 pr-11 text-white !outline-none backdrop-blur-xl placeholder:text-white/50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => showNsec()}
|
||||
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 hover:bg-zinc-700"
|
||||
>
|
||||
{privType === 'password' ? (
|
||||
<EyeOffIcon
|
||||
width={20}
|
||||
height={20}
|
||||
className="text-white/50 group-hover:text-white"
|
||||
/>
|
||||
) : (
|
||||
<EyeOnIcon
|
||||
width={20}
|
||||
height={20}
|
||||
className="text-white/50 group-hover:text-white"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,58 +1,10 @@
|
||||
import { Switch } from '@headlessui/react';
|
||||
import { disable, enable, isEnabled } from '@tauri-apps/plugin-autostart';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
import { getSetting, updateSetting } from '@libs/storage';
|
||||
|
||||
export function AutoStartSetting() {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
|
||||
const toggle = async () => {
|
||||
if (!enabled) {
|
||||
await enable();
|
||||
await updateSetting('auto_start', 1);
|
||||
console.log(`registered for autostart? ${await isEnabled()}`);
|
||||
} else {
|
||||
await disable();
|
||||
await updateSetting('auto_start', 0);
|
||||
}
|
||||
setEnabled(!enabled);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
async function getAppSetting() {
|
||||
const setting = await getSetting('auto_start');
|
||||
if (parseInt(setting) === 0) {
|
||||
setEnabled(false);
|
||||
} else {
|
||||
setEnabled(true);
|
||||
}
|
||||
}
|
||||
getAppSetting();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="inline-flex items-center justify-between px-5 py-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium leading-none text-zinc-200">Auto start</span>
|
||||
<span className="text-sm leading-none text-white/50">Auto start at login</span>
|
||||
</div>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onChange={toggle}
|
||||
className={twMerge(
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-fuchsia-500 focus:ring-offset-2',
|
||||
enabled ? 'bg-fuchsia-500' : 'bg-zinc-700'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={twMerge(
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-zinc-900 shadow ring-0 transition duration-200 ease-in-out',
|
||||
enabled ? 'translate-x-5' : 'translate-x-0'
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { getSetting, updateSetting } from '@libs/storage';
|
||||
|
||||
import { CheckCircleIcon } from '@shared/icons';
|
||||
|
||||
const setting = await getSetting('cache_time');
|
||||
const cacheTime = setting;
|
||||
|
||||
export function CacheTimeSetting() {
|
||||
const [time, setTime] = useState(cacheTime);
|
||||
const [time, setTime] = useState('0');
|
||||
|
||||
const update = async () => {
|
||||
await updateSetting('cache_time', time);
|
||||
// await updateSetting('cache_time', time);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
28
src/app/settings/components/dataPath.tsx
Normal file
28
src/app/settings/components/dataPath.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { appConfigDir } from '@tauri-apps/api/path';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function DataPath() {
|
||||
const [path, setPath] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
async function getPath() {
|
||||
const dir = await appConfigDir();
|
||||
setPath(dir);
|
||||
}
|
||||
getPath();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="inline-flex items-center justify-between px-5 py-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium leading-none text-zinc-200">App data path</span>
|
||||
<span className="text-sm leading-none text-white/50">
|
||||
Where the local data is stored
|
||||
</span>
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<span className="font-medium text-zinc-300">{path}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import { getVersion } from '@tauri-apps/plugin-app';
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { RefreshIcon } from '@shared/icons';
|
||||
|
||||
export function VersionSetting() {
|
||||
const [version, setVersion] = useState<string>('');
|
||||
|
||||
@@ -24,12 +22,6 @@ export function VersionSetting() {
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<span className="font-medium text-zinc-300">{version}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-zinc-800 font-medium hover:bg-fuchsia-500"
|
||||
>
|
||||
<RefreshIcon className="h-4 w-4 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { AutoStartSetting } from '@app/settings/components/autoStart';
|
||||
import { CacheTimeSetting } from '@app/settings/components/cacheTime';
|
||||
import { DataPath } from '@app/settings/components/dataPath';
|
||||
import { VersionSetting } from '@app/settings/components/version';
|
||||
|
||||
export function GeneralSettingsScreen() {
|
||||
return (
|
||||
<div className="h-full w-full px-3 pt-12">
|
||||
<div className="h-full w-full px-3 pt-11">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-lg font-semibold text-white">General</h1>
|
||||
<div className="w-full rounded-xl bg-white/10">
|
||||
<h1 className="text-xl font-semibold text-white">General</h1>
|
||||
<div className="w-full rounded-xl bg-white/10 backdrop-blur-xl">
|
||||
<div className="flex h-full w-full flex-col divide-y divide-white/5">
|
||||
<AutoStartSetting />
|
||||
<CacheTimeSetting />
|
||||
<DataPath />
|
||||
<VersionSetting />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,17 +5,17 @@ export function ShortcutsSettingsScreen() {
|
||||
<div className="h-full w-full px-3 pt-12">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-lg font-semibold text-white">Shortcuts</h1>
|
||||
<div className="w-full rounded-xl bg-white/10">
|
||||
<div className="w-full rounded-xl bg-white/10 backdrop-blur-xl">
|
||||
<div className="flex h-full w-full flex-col divide-y divide-white/5">
|
||||
<div className="inline-flex items-center justify-between px-5 py-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium leading-none text-white">Open composer</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-white/10">
|
||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-white/10 backdrop-blur-xl">
|
||||
<CommandIcon width={12} height={12} className="text-white/50" />
|
||||
</div>
|
||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-white/10">
|
||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-white/10 backdrop-blur-xl">
|
||||
<span className="text-sm leading-none text-white/50">N</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -27,10 +27,10 @@ export function ShortcutsSettingsScreen() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-white/10">
|
||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-white/10 backdrop-blur-xl">
|
||||
<CommandIcon width={12} height={12} className="text-white/50" />
|
||||
</div>
|
||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-white/10">
|
||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-white/10 backdrop-blur-xl">
|
||||
<span className="text-sm leading-none text-white/50">I</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -42,10 +42,10 @@ export function ShortcutsSettingsScreen() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-white/10">
|
||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-white/10 backdrop-blur-xl">
|
||||
<CommandIcon width={12} height={12} className="text-white/50" />
|
||||
</div>
|
||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-white/10">
|
||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-white/10 backdrop-blur-xl">
|
||||
<span className="text-sm leading-none text-white/50">F</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -57,10 +57,10 @@ export function ShortcutsSettingsScreen() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-white/10">
|
||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-white/10 backdrop-blur-xl">
|
||||
<CommandIcon width={12} height={12} className="text-white/50" />
|
||||
</div>
|
||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-white/10">
|
||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-white/10 backdrop-blur-xl">
|
||||
<span className="text-sm leading-none text-white/50">P</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -72,10 +72,10 @@ export function ShortcutsSettingsScreen() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-white/10">
|
||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-white/10 backdrop-blur-xl">
|
||||
<CommandIcon width={12} height={12} className="text-white/50" />
|
||||
</div>
|
||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-white/10">
|
||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-white/10 backdrop-blur-xl">
|
||||
<span className="text-sm leading-none text-white/50">B</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import { getNotesByAuthors } from '@libs/storage';
|
||||
|
||||
import { NoteKind_1, NoteKind_1063, NoteThread, Repost } from '@shared/notes';
|
||||
import { NoteKindUnsupport } from '@shared/notes/kinds/unsupport';
|
||||
import { NoteSkeleton } from '@shared/notes/skeleton';
|
||||
import { TitleBar } from '@shared/titleBar';
|
||||
|
||||
import { LumeEvent, Widget } from '@utils/types';
|
||||
|
||||
const ITEM_PER_PAGE = 10;
|
||||
|
||||
export function FeedBlock({ params }: { params: Widget }) {
|
||||
const { status, data, fetchNextPage, hasNextPage, isFetchingNextPage } =
|
||||
useInfiniteQuery({
|
||||
queryKey: ['newsfeed', params.content],
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
return await getNotesByAuthors(params.content, ITEM_PER_PAGE, pageParam);
|
||||
},
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
});
|
||||
|
||||
const notes = data ? data.pages.flatMap((d: { data: LumeEvent[] }) => d.data) : [];
|
||||
|
||||
const parentRef = useRef();
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: hasNextPage ? notes.length + 1 : notes.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 500,
|
||||
overscan: 2,
|
||||
});
|
||||
|
||||
const itemsVirtualizer = rowVirtualizer.getVirtualItems();
|
||||
const totalSize = rowVirtualizer.getTotalSize();
|
||||
|
||||
useEffect(() => {
|
||||
const [lastItem] = [...rowVirtualizer.getVirtualItems()].reverse();
|
||||
|
||||
if (!lastItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastItem.index >= notes.length - 1 && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [notes.length, fetchNextPage, rowVirtualizer.getVirtualItems()]);
|
||||
|
||||
const renderItem = useCallback(
|
||||
(index: string | number) => {
|
||||
const note: LumeEvent = notes[index];
|
||||
if (!note) return;
|
||||
switch (note.kind) {
|
||||
case 1: {
|
||||
const root = note.tags.find((el) => el[3] === 'root')?.[1];
|
||||
const reply = note.tags.find((el) => el[3] === 'reply')?.[1];
|
||||
if (root || reply) {
|
||||
return (
|
||||
<div
|
||||
key={note.event_id || note.id}
|
||||
data-index={index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
>
|
||||
<NoteThread event={note} root={root} reply={reply} />
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div
|
||||
key={note.event_id || note.id}
|
||||
data-index={index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
>
|
||||
<NoteKind_1 event={note} skipMetadata={false} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
case 6:
|
||||
return (
|
||||
<div
|
||||
key={note.event_id || note.id}
|
||||
data-index={index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
>
|
||||
<Repost key={note.event_id} event={note} />
|
||||
</div>
|
||||
);
|
||||
case 1063:
|
||||
return (
|
||||
<div
|
||||
key={note.event_id || note.id}
|
||||
data-index={index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
>
|
||||
<NoteKind_1063 key={note.event_id} event={note} />
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div
|
||||
key={note.event_id || note.id}
|
||||
data-index={index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
>
|
||||
<NoteKindUnsupport key={note.event_id} event={note} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
[notes]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative w-[400px] shrink-0 bg-white/10">
|
||||
<TitleBar id={params.id} title={params.title} />
|
||||
<div ref={parentRef} className="scrollbar-hide h-full overflow-y-auto pb-20">
|
||||
{status === 'loading' ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-xl bg-white/10 px-3 py-3">
|
||||
<NoteSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
) : itemsVirtualizer.length === 0 ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="bbg-white/10 rounded-xl px-3 py-6">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<p className="text-center text-sm text-white">
|
||||
Not found any posts from last 48 hours
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{
|
||||
height: `${totalSize}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute left-0 top-0 w-full"
|
||||
style={{
|
||||
transform: `translateY(${
|
||||
itemsVirtualizer[0].start - rowVirtualizer.options.scrollMargin
|
||||
}px)`,
|
||||
}}
|
||||
>
|
||||
{itemsVirtualizer.map((virtualRow) => renderItem(virtualRow.index))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isFetchingNextPage && (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-xl bg-white/10 px-3 py-3">
|
||||
<NoteSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useRef } from 'react';
|
||||
|
||||
import { useNDK } from '@libs/ndk/provider';
|
||||
|
||||
import { NoteKind_1, NoteSkeleton } from '@shared/notes';
|
||||
import { TitleBar } from '@shared/titleBar';
|
||||
|
||||
import { nHoursAgo } from '@utils/date';
|
||||
import { LumeEvent, Widget } from '@utils/types';
|
||||
|
||||
export function HashtagBlock({ params }: { params: Widget }) {
|
||||
const { ndk } = useNDK();
|
||||
const { status, data } = useQuery(['hashtag', params.content], async () => {
|
||||
const events = await ndk.fetchEvents({
|
||||
kinds: [1],
|
||||
'#t': [params.content],
|
||||
since: nHoursAgo(24),
|
||||
});
|
||||
return [...events] as unknown as LumeEvent[];
|
||||
});
|
||||
|
||||
const parentRef = useRef();
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: data ? data.length : 0,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 400,
|
||||
});
|
||||
|
||||
const itemsVirtualizer = rowVirtualizer.getVirtualItems();
|
||||
const totalSize = rowVirtualizer.getTotalSize();
|
||||
|
||||
return (
|
||||
<div className="relative w-[400px] shrink-0 bg-white/10">
|
||||
<TitleBar id={params.id} title={params.title + ' in 24 hours ago'} />
|
||||
<div ref={parentRef} className="scrollbar-hide h-full overflow-y-auto pb-20">
|
||||
{status === 'loading' ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-xl bg-white/10 px-3 py-3">
|
||||
<NoteSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
) : itemsVirtualizer.length === 0 ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-xl bg-white/10 px-3 py-6">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<p className="text-center text-sm font-medium text-white">
|
||||
No new posts about this hashtag in 24 hours ago
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{
|
||||
height: `${totalSize}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute left-0 top-0 w-full"
|
||||
style={{
|
||||
transform: `translateY(${
|
||||
itemsVirtualizer[0].start - rowVirtualizer.options.scrollMargin
|
||||
}px)`,
|
||||
}}
|
||||
>
|
||||
{itemsVirtualizer.map((virtualRow) => (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
data-index={virtualRow.index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
>
|
||||
<NoteKind_1 event={data[virtualRow.index]} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { CancelIcon } from '@shared/icons';
|
||||
import { Image } from '@shared/image';
|
||||
|
||||
import { DEFAULT_AVATAR } from '@stores/constants';
|
||||
import { useWidgets } from '@stores/widgets';
|
||||
|
||||
import { Widget } from '@utils/types';
|
||||
|
||||
export function ImageBlock({ params }: { params: Widget }) {
|
||||
const remove = useWidgets((state) => state.removeWidget);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-[400px] shrink-0 flex-col justify-between">
|
||||
<div className="relative h-full w-full flex-1 overflow-hidden p-3">
|
||||
<div className="absolute left-0 top-3 h-16 w-full px-3">
|
||||
<div className="flex h-16 items-center justify-between overflow-hidden rounded-t-xl px-5">
|
||||
<h3 className="font-medium text-white drop-shadow-lg">{params.title}</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(params.id)}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md bg-white/30 backdrop-blur-lg"
|
||||
>
|
||||
<CancelIcon width={16} height={16} className="text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Image
|
||||
src={params.content}
|
||||
fallback={DEFAULT_AVATAR}
|
||||
alt={params.title}
|
||||
className="h-full w-full rounded-xl object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { useNewsfeed } from '@app/space/hooks/useNewsfeed';
|
||||
|
||||
import { getNotes } from '@libs/storage';
|
||||
|
||||
import { NoteKind_1, NoteKind_1063, NoteThread, Repost } from '@shared/notes';
|
||||
import { NoteKindUnsupport } from '@shared/notes/kinds/unsupport';
|
||||
import { NoteSkeleton } from '@shared/notes/skeleton';
|
||||
import { TitleBar } from '@shared/titleBar';
|
||||
|
||||
import { LumeEvent } from '@utils/types';
|
||||
|
||||
const ITEM_PER_PAGE = 10;
|
||||
|
||||
export function NetworkBlock() {
|
||||
// subscribe for live update
|
||||
useNewsfeed();
|
||||
|
||||
const { status, data, fetchNextPage, hasNextPage, isFetchingNextPage } =
|
||||
useInfiniteQuery({
|
||||
queryKey: ['newsfeed-circle'],
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
return await getNotes(ITEM_PER_PAGE, pageParam);
|
||||
},
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
});
|
||||
|
||||
const notes = data ? data.pages.flatMap((d: { data: LumeEvent[] }) => d.data) : [];
|
||||
const parentRef = useRef();
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: hasNextPage ? notes.length + 1 : notes.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 500,
|
||||
overscan: 2,
|
||||
});
|
||||
|
||||
const itemsVirtualizer = rowVirtualizer.getVirtualItems();
|
||||
const totalSize = rowVirtualizer.getTotalSize();
|
||||
|
||||
useEffect(() => {
|
||||
const [lastItem] = [...itemsVirtualizer].reverse();
|
||||
|
||||
if (!lastItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastItem.index >= notes.length - 1 && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [notes.length, fetchNextPage, itemsVirtualizer]);
|
||||
|
||||
const renderItem = useCallback(
|
||||
(index: string | number) => {
|
||||
const note: LumeEvent = notes[index];
|
||||
if (!note) return;
|
||||
switch (note.kind) {
|
||||
case 1: {
|
||||
let root: string;
|
||||
let reply: string;
|
||||
if (note.tags?.[0]?.[0] === 'e' && !note.tags?.[0]?.[3]) {
|
||||
root = note.tags[0][1];
|
||||
} else {
|
||||
root = note.tags.find((el) => el[3] === 'root')?.[1];
|
||||
reply = note.tags.find((el) => el[3] === 'reply')?.[1];
|
||||
}
|
||||
if (root || reply) {
|
||||
return (
|
||||
<div
|
||||
key={(root || reply) + (note.event_id || note.id) + index}
|
||||
data-index={index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
>
|
||||
<NoteThread event={note} root={root} reply={reply} />
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div
|
||||
key={(note.event_id || note.id) + index}
|
||||
data-index={index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
>
|
||||
<NoteKind_1 event={note} skipMetadata={false} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
case 6:
|
||||
return (
|
||||
<div
|
||||
key={(note.event_id || note.id) + index}
|
||||
data-index={index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
>
|
||||
<Repost key={note.event_id} event={note} />
|
||||
</div>
|
||||
);
|
||||
case 1063:
|
||||
return (
|
||||
<div
|
||||
key={(note.event_id || note.id) + index}
|
||||
data-index={index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
>
|
||||
<NoteKind_1063 key={note.event_id} event={note} />
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div
|
||||
key={(note.event_id || note.id) + index}
|
||||
data-index={index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
>
|
||||
<NoteKindUnsupport key={note.event_id} event={note} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
[notes]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative w-[400px] shrink-0 bg-white/10">
|
||||
<TitleBar title="Network" />
|
||||
<div ref={parentRef} className="scrollbar-hide h-full overflow-y-auto pb-20">
|
||||
{status === 'loading' ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-xl bg-white/10 px-3 py-3">
|
||||
<NoteSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
) : itemsVirtualizer.length === 0 ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-xl bg-white/10 px-3 py-6">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<p className="text-center text-sm text-white">
|
||||
You not have any posts to see yet
|
||||
<br />
|
||||
Follow more people to have more fun.
|
||||
</p>
|
||||
<Link
|
||||
to="/trending"
|
||||
className="inline-flex w-max rounded bg-fuchsia-500 px-2.5 py-1.5 text-sm hover:bg-fuchsia-600"
|
||||
>
|
||||
Trending
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{
|
||||
height: `${totalSize}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute left-0 top-0 w-full"
|
||||
style={{
|
||||
transform: `translateY(${
|
||||
itemsVirtualizer[0].start - rowVirtualizer.options.scrollMargin
|
||||
}px)`,
|
||||
}}
|
||||
>
|
||||
{itemsVirtualizer.map((virtualRow) => renderItem(virtualRow.index))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isFetchingNextPage && (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-xl bg-white/10 px-3 py-3">
|
||||
<NoteSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
// import { useLiveThread } from '@app/space/hooks/useLiveThread';
|
||||
import {
|
||||
NoteActions,
|
||||
NoteContent,
|
||||
NoteReplyForm,
|
||||
NoteStats,
|
||||
ThreadUser,
|
||||
} from '@shared/notes';
|
||||
import { RepliesList } from '@shared/notes/replies/list';
|
||||
import { NoteSkeleton } from '@shared/notes/skeleton';
|
||||
import { TitleBar } from '@shared/titleBar';
|
||||
|
||||
import { useAccount } from '@utils/hooks/useAccount';
|
||||
import { useEvent } from '@utils/hooks/useEvent';
|
||||
import { Widget } from '@utils/types';
|
||||
|
||||
export function ThreadBlock({ params }: { params: Widget }) {
|
||||
const { status, data } = useEvent(params.content);
|
||||
const { account } = useAccount();
|
||||
|
||||
// subscribe to live reply
|
||||
// useLiveThread(params.content);
|
||||
|
||||
return (
|
||||
<div className="scrollbar-hide h-full w-[400px] shrink-0 overflow-y-auto bg-white/10 pb-20">
|
||||
<TitleBar id={params.id} title={params.title} />
|
||||
<div className="h-full">
|
||||
{status === 'loading' ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-xl bg-white/10 px-3 py-3">
|
||||
<NoteSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-min w-full px-3 pt-1.5">
|
||||
<div className="rounded-xl bg-white/10 px-3 pt-3">
|
||||
<ThreadUser pubkey={data.pubkey} time={data.created_at} />
|
||||
<div className="mt-2">
|
||||
<NoteContent content={data.content} />
|
||||
</div>
|
||||
<div>
|
||||
<NoteActions
|
||||
id={params.content}
|
||||
pubkey={data.pubkey}
|
||||
noOpenThread={true}
|
||||
/>
|
||||
<NoteStats id={params.content} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="px-3">
|
||||
{account && <NoteReplyForm id={params.content} pubkey={account.pubkey} />}
|
||||
<RepliesList id={params.content} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useRef } from 'react';
|
||||
|
||||
import { useNDK } from '@libs/ndk/provider';
|
||||
|
||||
import { NoteKind_1, NoteSkeleton } from '@shared/notes';
|
||||
import { TitleBar } from '@shared/titleBar';
|
||||
import { UserProfile } from '@shared/userProfile';
|
||||
|
||||
import { nHoursAgo } from '@utils/date';
|
||||
import { LumeEvent, Widget } from '@utils/types';
|
||||
|
||||
export function UserBlock({ params }: { params: Widget }) {
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { ndk } = useNDK();
|
||||
const { status, data } = useQuery(['user-feed', params.content], async () => {
|
||||
const events = await ndk.fetchEvents({
|
||||
kinds: [1],
|
||||
authors: [params.content],
|
||||
since: nHoursAgo(48),
|
||||
});
|
||||
return [...events] as unknown as LumeEvent[];
|
||||
});
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: data ? data.length : 0,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 400,
|
||||
overscan: 2,
|
||||
});
|
||||
|
||||
const itemsVirtualizer = rowVirtualizer.getVirtualItems();
|
||||
|
||||
return (
|
||||
<div className="relative w-[400px] shrink-0 bg-white/10">
|
||||
<TitleBar id={params.id} title={params.title} />
|
||||
<div ref={parentRef} className="scrollbar-hide h-full overflow-y-auto pb-20">
|
||||
<div className="px-3 pt-1.5">
|
||||
<UserProfile pubkey={params.content} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mt-4 px-3 text-lg font-semibold text-white">Latest postrs</h3>
|
||||
<div className="flex h-full w-full flex-col justify-between gap-1.5 pb-10">
|
||||
{status === 'loading' ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-md bg-white/10 px-3 py-3">
|
||||
<NoteSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
) : itemsVirtualizer.length === 0 ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-xl bg-white/10 px-3 py-6">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<p className="text-center text-sm text-white">
|
||||
No new posts from this user in 48 hours ago
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute left-0 top-0 w-full"
|
||||
style={{
|
||||
transform: `translateY(${
|
||||
itemsVirtualizer[0].start - rowVirtualizer.options.scrollMargin
|
||||
}px)`,
|
||||
}}
|
||||
>
|
||||
{itemsVirtualizer.map((virtualRow) => (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
data-index={virtualRow.index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
>
|
||||
<NoteKind_1 event={data[virtualRow.index]} />
|
||||
</div>
|
||||
))}
|
||||
<div className="h-20" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
import { Combobox } from '@headlessui/react';
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
|
||||
import { User } from '@app/auth/components/user';
|
||||
|
||||
import { CancelIcon, CheckCircleIcon, CommandIcon, LoaderIcon } from '@shared/icons';
|
||||
|
||||
import { BLOCK_KINDS, DEFAULT_AVATAR } from '@stores/constants';
|
||||
import { ADD_FEEDBLOCK_SHORTCUT } from '@stores/shortcuts';
|
||||
import { useWidgets } from '@stores/widgets';
|
||||
|
||||
import { useAccount } from '@utils/hooks/useAccount';
|
||||
|
||||
export function FeedModal() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selected, setSelected] = useState([]);
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const { status, account } = useAccount();
|
||||
|
||||
const setWidget = useWidgets((state) => state.setWidget);
|
||||
|
||||
useHotkeys(ADD_FEEDBLOCK_SHORTCUT, () => setOpen(true));
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { isDirty, isValid },
|
||||
} = useForm();
|
||||
|
||||
const onSubmit = (data: { kind: number; title: string; content: string }) => {
|
||||
setLoading(true);
|
||||
|
||||
selected.forEach((item, index) => {
|
||||
if (item.substring(0, 4) === 'npub') {
|
||||
selected[index] = nip19.decode(item).data;
|
||||
}
|
||||
});
|
||||
|
||||
// insert to database
|
||||
setWidget({
|
||||
kind: BLOCK_KINDS.feed,
|
||||
title: data.title,
|
||||
content: JSON.stringify(selected),
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
// reset form
|
||||
reset();
|
||||
// close modal
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={setOpen}>
|
||||
<Dialog.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-9 w-72 items-center justify-start gap-2.5 rounded-md px-2.5"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-white/10">
|
||||
<CommandIcon className="h-3 w-3 text-white" />
|
||||
</div>
|
||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-white/10">
|
||||
<span className="text-sm leading-none text-white">F</span>
|
||||
</div>
|
||||
</div>
|
||||
<h5 className="font-medium text-white/50">Add newsfeed widget</h5>
|
||||
</button>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Portal className="relative z-10">
|
||||
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xl" />
|
||||
<Dialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
|
||||
<div className="relative h-min w-full max-w-xl rounded-xl bg-white/10">
|
||||
<div className="h-min w-full shrink-0 border-b border-white/10 bg-white/5 px-5 py-5">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Dialog.Title className="text-lg font-semibold leading-none text-white">
|
||||
Create feed block
|
||||
</Dialog.Title>
|
||||
<Dialog.Close className="inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-white/10">
|
||||
<CancelIcon className="h-4 w-4 text-white/50" />
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
<Dialog.Description className="text-sm leading-tight text-white/50">
|
||||
Specific newsfeed space for people you want to keep up to date
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col overflow-y-auto overflow-x-hidden px-5 pb-5 pt-2">
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="mb-0 flex h-full w-full flex-col gap-4"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="title"
|
||||
className="text-sm font-medium uppercase tracking-wider text-white/50"
|
||||
>
|
||||
Title *
|
||||
</label>
|
||||
<input
|
||||
type={'text'}
|
||||
{...register('title', {
|
||||
required: true,
|
||||
})}
|
||||
spellCheck={false}
|
||||
className="relative h-11 w-full rounded-lg bg-white/10 px-3 py-2 text-white !outline-none placeholder:text-white/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-medium uppercase tracking-wider text-white/50">
|
||||
Choose at least 1 user *
|
||||
</span>
|
||||
<div className="flex h-[300px] w-full flex-col overflow-y-auto overflow-x-hidden rounded-lg bg-white/10">
|
||||
<div className="w-full px-3 py-2">
|
||||
<Combobox value={selected} onChange={setSelected} multiple>
|
||||
<Combobox.Input
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
spellCheck={false}
|
||||
placeholder="Enter pubkey or npub..."
|
||||
className="relative mb-2 h-10 w-full rounded-md bg-white/10 px-3 py-2 text-white !outline-none placeholder:text-white/50"
|
||||
/>
|
||||
<Combobox.Options static>
|
||||
{query.length > 0 && (
|
||||
<Combobox.Option
|
||||
value={query}
|
||||
className="group flex w-full items-center justify-between rounded-md px-2 py-2 hover:bg-white/10"
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
alt={query}
|
||||
src={DEFAULT_AVATAR}
|
||||
className="h-11 w-11 shrink-0 rounded object-cover"
|
||||
/>
|
||||
<div className="inline-flex flex-col gap-1">
|
||||
<span className="text-base leading-tight text-white/50">
|
||||
{query}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{selected && (
|
||||
<CheckCircleIcon className="h-4 w-4 text-green-500" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
)}
|
||||
{status === 'loading' ? (
|
||||
<p>Loading...</p>
|
||||
) : (
|
||||
account?.follows?.map((follow) => (
|
||||
<Combobox.Option
|
||||
key={follow}
|
||||
value={follow}
|
||||
className="group flex w-full items-center justify-between rounded-md px-2 py-2 hover:bg-white/10"
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
<User pubkey={follow} />
|
||||
{selected && (
|
||||
<CheckCircleIcon className="h-4 w-4 text-green-500" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
))
|
||||
)}
|
||||
</Combobox.Options>
|
||||
</Combobox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isDirty || !isValid}
|
||||
className="shadow-button inline-flex h-11 w-full transform items-center justify-center rounded-lg bg-fuchsia-500 font-medium text-white active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-30"
|
||||
>
|
||||
{loading ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-white" />
|
||||
) : (
|
||||
'Confirm'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
|
||||
import { CancelIcon, CommandIcon, LoaderIcon } from '@shared/icons';
|
||||
|
||||
import { BLOCK_KINDS } from '@stores/constants';
|
||||
import { ADD_HASHTAGBLOCK_SHORTCUT } from '@stores/shortcuts';
|
||||
import { useWidgets } from '@stores/widgets';
|
||||
|
||||
export function HashtagModal() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const setWidget = useWidgets((state) => state.setWidget);
|
||||
|
||||
useHotkeys(ADD_HASHTAGBLOCK_SHORTCUT, () => setOpen(false));
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { isDirty, isValid },
|
||||
} = useForm();
|
||||
|
||||
const onSubmit = async (data: { hashtag: string }) => {
|
||||
setLoading(true);
|
||||
|
||||
// mutate
|
||||
setWidget({
|
||||
kind: BLOCK_KINDS.hashtag,
|
||||
title: data.hashtag,
|
||||
content: data.hashtag.replace('#', ''),
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
// reset form
|
||||
reset();
|
||||
// close modal
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={setOpen}>
|
||||
<Dialog.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-9 w-72 items-center justify-start gap-2.5 rounded-md px-2.5"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-white/10">
|
||||
<CommandIcon className="h-3 w-3 text-white" />
|
||||
</div>
|
||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-white/10">
|
||||
<span className="text-sm leading-none text-white">T</span>
|
||||
</div>
|
||||
</div>
|
||||
<h5 className="font-medium text-white/50">Add hashtag widget</h5>
|
||||
</button>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Portal className="relative z-10">
|
||||
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xl" />
|
||||
<Dialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
|
||||
<div className="relative h-min w-full max-w-xl rounded-xl bg-white/10">
|
||||
<div className="h-min w-full shrink-0 border-b border-white/10 bg-white/5 px-5 py-5">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Dialog.Title className="text-lg font-semibold leading-none text-white">
|
||||
Create hashtag block
|
||||
</Dialog.Title>
|
||||
<Dialog.Close className="inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-white/10">
|
||||
<CancelIcon className="h-4 w-4 text-white/50" />
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
<Dialog.Description className="text-sm leading-tight text-white/50">
|
||||
Pin the hashtag you want to keep follow up
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-full w-full flex-col overflow-y-auto px-5 pb-5 pt-3">
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="mb-0 flex h-full w-full flex-col gap-3"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="title"
|
||||
className="text-sm font-medium uppercase tracking-wider text-white/50"
|
||||
>
|
||||
Hashtag *
|
||||
</label>
|
||||
<input
|
||||
type={'text'}
|
||||
{...register('hashtag', {
|
||||
required: true,
|
||||
})}
|
||||
spellCheck={false}
|
||||
placeholder="#"
|
||||
className="relative h-11 w-full rounded-lg bg-white/10 px-3 py-2 text-white !outline-none placeholder:text-white/50"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isDirty || !isValid}
|
||||
className="inline-flex h-11 w-full transform items-center justify-center rounded-lg bg-fuchsia-500 font-medium text-white active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-30"
|
||||
>
|
||||
{loading ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin text-white" />
|
||||
) : (
|
||||
'Confirm'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
|
||||
import { CancelIcon, CommandIcon, LoaderIcon } from '@shared/icons';
|
||||
import { Image } from '@shared/image';
|
||||
|
||||
import { BLOCK_KINDS, DEFAULT_AVATAR } from '@stores/constants';
|
||||
import { ADD_IMAGEBLOCK_SHORTCUT } from '@stores/shortcuts';
|
||||
import { useWidgets } from '@stores/widgets';
|
||||
|
||||
import { useImageUploader } from '@utils/hooks/useUploader';
|
||||
|
||||
export function ImageModal() {
|
||||
const upload = useImageUploader();
|
||||
const setWidget = useWidgets((state) => state.setWidget);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [image, setImage] = useState('');
|
||||
|
||||
useHotkeys(ADD_IMAGEBLOCK_SHORTCUT, () => setOpen(false));
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setValue,
|
||||
formState: { isDirty, isValid },
|
||||
} = useForm();
|
||||
|
||||
const uploadImage = async () => {
|
||||
const image = await upload(null, true);
|
||||
if (image.url) {
|
||||
setImage(image.url);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (data: { kind: number; title: string; content: string }) => {
|
||||
setLoading(true);
|
||||
|
||||
// mutate
|
||||
setWidget({ kind: BLOCK_KINDS.image, title: data.title, content: data.content });
|
||||
|
||||
setLoading(false);
|
||||
// reset form
|
||||
reset();
|
||||
// close modal
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setValue('content', image);
|
||||
}, [setValue, image]);
|
||||
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={setOpen}>
|
||||
<Dialog.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-9 w-72 items-center justify-start gap-2.5 rounded-md px-2.5"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-white/10">
|
||||
<CommandIcon width={12} height={12} className="text-white" />
|
||||
</div>
|
||||
<div className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded bg-white/10">
|
||||
<span className="text-sm leading-none text-white">I</span>
|
||||
</div>
|
||||
</div>
|
||||
<h5 className="font-medium text-white/50">Add image widget</h5>
|
||||
</button>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Portal className="relative z-10">
|
||||
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-xl" />
|
||||
<Dialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
|
||||
<div className="relative h-min w-full max-w-xl rounded-xl bg-white/10">
|
||||
<div className="h-min w-full shrink-0 border-b border-white/10 bg-white/5 px-5 py-5">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Dialog.Title className="text-lg font-semibold leading-none text-white">
|
||||
Create image block
|
||||
</Dialog.Title>
|
||||
<Dialog.Close className="inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-white/10">
|
||||
<CancelIcon className="h-4 w-4 text-white/50" />
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
<Dialog.Description className="text-sm leading-tight text-white/50">
|
||||
Pin your favorite image to Space then you can view every time that you
|
||||
use Lume, your image will be broadcast to Nostr Relay as well
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-full w-full flex-col overflow-y-auto px-5 pb-5 pt-3">
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="mb-0 flex h-full w-full flex-col gap-3"
|
||||
>
|
||||
<input type={'hidden'} {...register('content')} value={image} />
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="title"
|
||||
className="text-sm font-medium uppercase tracking-wider text-white/50"
|
||||
>
|
||||
Title *
|
||||
</label>
|
||||
<input
|
||||
type={'text'}
|
||||
{...register('title', {
|
||||
required: true,
|
||||
})}
|
||||
spellCheck={false}
|
||||
className="relative h-11 w-full rounded-lg bg-white/10 px-3 py-2 text-white !outline-none placeholder:text-white/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="picture"
|
||||
className="text-sm font-medium uppercase tracking-wider text-white/50"
|
||||
>
|
||||
Picture
|
||||
</label>
|
||||
<div className="relative inline-flex h-56 w-full items-center justify-center overflow-hidden rounded-lg bg-white/10">
|
||||
<Image
|
||||
src={image}
|
||||
fallback={DEFAULT_AVATAR}
|
||||
alt="content"
|
||||
className="relative z-10 h-auto max-h-[156px] w-[150px] rounded-md object-cover"
|
||||
/>
|
||||
<div className="absolute bottom-3 right-3 z-10">
|
||||
<button
|
||||
onClick={() => uploadImage()}
|
||||
type="button"
|
||||
className="inline-flex h-6 items-center justify-center rounded bg-white/10 px-3 text-sm font-medium text-white hover:bg-fuchsia-500"
|
||||
>
|
||||
Upload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isDirty || !isValid}
|
||||
className="inline-flex h-11 w-full transform items-center justify-center rounded-lg bg-fuchsia-500 font-medium text-white active:translate-y-1 disabled:cursor-not-allowed disabled:opacity-30"
|
||||
>
|
||||
{loading ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin text-white" />
|
||||
) : (
|
||||
'Confirm'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
25
src/app/space/components/toggle.tsx
Normal file
25
src/app/space/components/toggle.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
import { PlusIcon } from '@shared/icons';
|
||||
|
||||
import { WidgetKinds, useWidgets } from '@stores/widgets';
|
||||
|
||||
export function ToggleWidgetList() {
|
||||
const { db } = useStorage();
|
||||
const setWidget = useWidgets((state) => state.setWidget);
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full shrink-0 grow-0 basis-[400px] items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setWidget(db, { kind: WidgetKinds.tmp.list, title: '', content: '' })
|
||||
}
|
||||
className="inline-flex items-center gap-2 text-white"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 text-white" />
|
||||
<p className="text-sm font-bold leading-none">Add widget</p>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
src/app/space/components/widgetList.tsx
Normal file
120
src/app/space/components/widgetList.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
import {
|
||||
ArticleIcon,
|
||||
FileIcon,
|
||||
FollowsIcon,
|
||||
GroupFeedsIcon,
|
||||
HashtagIcon,
|
||||
TrendingIcon,
|
||||
} from '@shared/icons';
|
||||
import { TitleBar } from '@shared/titleBar';
|
||||
|
||||
import { DefaultWidgets, WidgetKinds, useWidgets } from '@stores/widgets';
|
||||
|
||||
import { Widget, WidgetGroup, WidgetGroupItem } from '@utils/types';
|
||||
|
||||
export function WidgetList({ params }: { params: Widget }) {
|
||||
const { db } = useStorage();
|
||||
const [setWidget, removeWidget] = useWidgets((state) => [
|
||||
state.setWidget,
|
||||
state.removeWidget,
|
||||
]);
|
||||
|
||||
const openWidget = (widget: WidgetGroupItem) => {
|
||||
setWidget(db, { kind: widget.kind, title: widget.title, content: '' });
|
||||
removeWidget(db, params.id);
|
||||
};
|
||||
|
||||
const renderIcon = useCallback(
|
||||
(kind: number) => {
|
||||
switch (kind) {
|
||||
case WidgetKinds.tmp.xfeed:
|
||||
return <GroupFeedsIcon className="h-5 w-5 text-white" />;
|
||||
case WidgetKinds.local.follows:
|
||||
return <FollowsIcon className="h-5 w-5 text-white" />;
|
||||
case WidgetKinds.local.files:
|
||||
case WidgetKinds.global.files:
|
||||
return <FileIcon className="h-5 w-5 text-white" />;
|
||||
case WidgetKinds.local.articles:
|
||||
case WidgetKinds.global.articles:
|
||||
return <ArticleIcon className="h-5 w-5 text-white" />;
|
||||
case WidgetKinds.tmp.xhashtag:
|
||||
return <HashtagIcon className="h-5 w-4 text-white" />;
|
||||
case WidgetKinds.nostrBand.trendingAccounts:
|
||||
case WidgetKinds.nostrBand.trendingNotes:
|
||||
return <TrendingIcon className="h-5 w-4 text-white" />;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
},
|
||||
[DefaultWidgets]
|
||||
);
|
||||
|
||||
const renderItem = useCallback(
|
||||
(row: WidgetGroup) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<h3 className="font-medium text-white/50">{row.title}</h3>
|
||||
<div className="flex flex-col divide-y divide-white/5 overflow-hidden rounded-xl bg-white/10">
|
||||
{row.data.map((item, index) => (
|
||||
<button
|
||||
onClick={() => openWidget(item)}
|
||||
key={index}
|
||||
className="flex items-center gap-2.5 px-4 hover:bg-white/10"
|
||||
>
|
||||
{item.icon ? (
|
||||
<div className="h-10 w-10 shrink-0 rounded-md">
|
||||
<img
|
||||
src={item.icon}
|
||||
alt={item.title}
|
||||
className="h-10 w-10 object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-white/10">
|
||||
{renderIcon(item.kind)}
|
||||
</div>
|
||||
)}
|
||||
<div className="inline-flex h-16 w-full flex-col items-start justify-center gap-1">
|
||||
<h5 className="line-clamp-1 font-medium leading-none">{item.title}</h5>
|
||||
<p className="line-clamp-1 text-xs leading-none text-white/50">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[DefaultWidgets]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative h-full shrink-0 grow-0 basis-[400px] overflow-hidden bg-white/10">
|
||||
<TitleBar id={params.id} title="Add widget" />
|
||||
<div className="flex flex-col gap-6 px-3">
|
||||
{DefaultWidgets.map((row: WidgetGroup) => renderItem(row))}
|
||||
</div>
|
||||
<div className="mt-6 px-3">
|
||||
<div className="border-t border-white/5 pt-6">
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="inline-flex h-14 w-full items-center justify-center gap-2.5 rounded-xl bg-white/5 text-sm font-medium text-white/50"
|
||||
>
|
||||
Build your own widget{' '}
|
||||
<div className="-rotate-3 transform rounded-md border border-white/20 bg-white/10 px-1.5 py-1">
|
||||
<span className="bg-gradient-to-t from-fuchsia-200 via-red-200 to-orange-300 bg-clip-text text-xs text-transparent">
|
||||
Coming soon
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { NDKEvent, NDKFilter } from '@nostr-dev-kit/ndk';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { useNDK } from '@libs/ndk/provider';
|
||||
import { createReplyNote } from '@libs/storage';
|
||||
|
||||
export function useLiveThread(id: string) {
|
||||
const queryClient = useQueryClient();
|
||||
const now = useRef(Math.floor(Date.now() / 1000));
|
||||
|
||||
const { ndk } = useNDK();
|
||||
|
||||
const thread = useMutation({
|
||||
mutationFn: (data: NDKEvent) => {
|
||||
return createReplyNote(
|
||||
id,
|
||||
data.id,
|
||||
data.pubkey,
|
||||
data.kind,
|
||||
data.tags,
|
||||
data.content,
|
||||
data.created_at
|
||||
);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['replies', id] });
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const filter: NDKFilter = {
|
||||
kinds: [1],
|
||||
'#e': [id],
|
||||
since: now.current,
|
||||
};
|
||||
|
||||
const sub = ndk.subscribe(filter, { closeOnEose: false });
|
||||
|
||||
sub.addListener('event', (event: NDKEvent) => {
|
||||
thread.mutate(event);
|
||||
});
|
||||
|
||||
return () => {
|
||||
sub.stop();
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { NDKEvent, NDKFilter } from '@nostr-dev-kit/ndk';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { useNDK } from '@libs/ndk/provider';
|
||||
import { createNote } from '@libs/storage';
|
||||
|
||||
import { useAccount } from '@utils/hooks/useAccount';
|
||||
|
||||
export function useNewsfeed() {
|
||||
const sub = useRef(null);
|
||||
const now = useRef(Math.floor(Date.now() / 1000));
|
||||
|
||||
const { ndk } = useNDK();
|
||||
const { status, account } = useAccount();
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'success' && account) {
|
||||
const filter: NDKFilter = {
|
||||
kinds: [1, 6],
|
||||
authors: account.follows,
|
||||
since: now.current,
|
||||
};
|
||||
|
||||
sub.current = ndk.subscribe(filter, { closeOnEose: false });
|
||||
|
||||
sub.current.addListener('event', (event: NDKEvent) => {
|
||||
// add to db
|
||||
createNote(
|
||||
event.id,
|
||||
event.pubkey,
|
||||
event.kind,
|
||||
event.tags,
|
||||
event.content,
|
||||
event.created_at
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (sub.current) {
|
||||
sub.current.stop();
|
||||
}
|
||||
};
|
||||
}, [status]);
|
||||
}
|
||||
@@ -1,22 +1,34 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
|
||||
import { FeedBlock } from '@app/space/components/blocks/feed';
|
||||
import { HashtagBlock } from '@app/space/components/blocks/hashtag';
|
||||
import { ImageBlock } from '@app/space/components/blocks/image';
|
||||
import { NetworkBlock } from '@app/space/components/blocks/network';
|
||||
import { ThreadBlock } from '@app/space/components/blocks/thread';
|
||||
import { UserBlock } from '@app/space/components/blocks/user';
|
||||
import { FeedModal } from '@app/space/components/modals/feed';
|
||||
import { HashtagModal } from '@app/space/components/modals/hashtag';
|
||||
import { ImageModal } from '@app/space/components/modals/image';
|
||||
import { ToggleWidgetList } from '@app/space/components/toggle';
|
||||
import { WidgetList } from '@app/space/components/widgetList';
|
||||
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
import { LoaderIcon } from '@shared/icons';
|
||||
import {
|
||||
GlobalArticlesWidget,
|
||||
GlobalFilesWidget,
|
||||
GlobalHashtagWidget,
|
||||
LocalArticlesWidget,
|
||||
LocalFeedsWidget,
|
||||
LocalFilesWidget,
|
||||
LocalNetworkWidget,
|
||||
LocalThreadWidget,
|
||||
LocalUserWidget,
|
||||
TrendingAccountsWidget,
|
||||
TrendingNotesWidget,
|
||||
XfeedsWidget,
|
||||
XhashtagWidget,
|
||||
} from '@shared/widgets';
|
||||
|
||||
import { useWidgets } from '@stores/widgets';
|
||||
import { WidgetKinds, useWidgets } from '@stores/widgets';
|
||||
|
||||
import { Widget } from '@utils/types';
|
||||
|
||||
export function SpaceScreen() {
|
||||
const { db } = useStorage();
|
||||
|
||||
const [widgets, fetchWidgets] = useWidgets((state) => [
|
||||
state.widgets,
|
||||
state.fetchWidgets,
|
||||
@@ -24,17 +36,36 @@ export function SpaceScreen() {
|
||||
|
||||
const renderItem = useCallback(
|
||||
(widget: Widget) => {
|
||||
if (!widget) return;
|
||||
switch (widget.kind) {
|
||||
case 0:
|
||||
return <ImageBlock key={widget.id} params={widget} />;
|
||||
case 1:
|
||||
return <FeedBlock key={widget.id} params={widget} />;
|
||||
case 2:
|
||||
return <ThreadBlock key={widget.id} params={widget} />;
|
||||
case 3:
|
||||
return <HashtagBlock key={widget.id} params={widget} />;
|
||||
case 5:
|
||||
return <UserBlock key={widget.id} params={widget} />;
|
||||
case WidgetKinds.local.network:
|
||||
return <LocalNetworkWidget key={widget.id} />;
|
||||
case WidgetKinds.local.feeds:
|
||||
return <LocalFeedsWidget key={widget.id} params={widget} />;
|
||||
case WidgetKinds.local.files:
|
||||
return <LocalFilesWidget key={widget.id} params={widget} />;
|
||||
case WidgetKinds.local.articles:
|
||||
return <LocalArticlesWidget key={widget.id} params={widget} />;
|
||||
case WidgetKinds.local.user:
|
||||
return <LocalUserWidget key={widget.id} params={widget} />;
|
||||
case WidgetKinds.local.thread:
|
||||
return <LocalThreadWidget key={widget.id} params={widget} />;
|
||||
case WidgetKinds.global.hashtag:
|
||||
return <GlobalHashtagWidget key={widget.id} params={widget} />;
|
||||
case WidgetKinds.global.articles:
|
||||
return <GlobalArticlesWidget key={widget.id} params={widget} />;
|
||||
case WidgetKinds.global.files:
|
||||
return <GlobalFilesWidget key={widget.id} params={widget} />;
|
||||
case WidgetKinds.nostrBand.trendingAccounts:
|
||||
return <TrendingAccountsWidget key={widget.id} params={widget} />;
|
||||
case WidgetKinds.nostrBand.trendingNotes:
|
||||
return <TrendingNotesWidget key={widget.id} params={widget} />;
|
||||
case WidgetKinds.tmp.xfeed:
|
||||
return <XhashtagWidget key={widget.id} params={widget} />;
|
||||
case WidgetKinds.tmp.xhashtag:
|
||||
return <XfeedsWidget key={widget.id} params={widget} />;
|
||||
case WidgetKinds.tmp.list:
|
||||
return <WidgetList key={widget.id} params={widget} />;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -43,14 +74,13 @@ export function SpaceScreen() {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchWidgets();
|
||||
fetchWidgets(db);
|
||||
}, [fetchWidgets]);
|
||||
|
||||
return (
|
||||
<div className="scrollbar-hide flex h-full w-full flex-nowrap divide-x divide-white/5 overflow-x-auto overflow-y-hidden">
|
||||
<NetworkBlock />
|
||||
<div className="scrollbar-hide inline-flex h-full w-full min-w-full flex-nowrap items-start divide-x divide-white/5 overflow-x-auto overflow-y-hidden">
|
||||
{!widgets ? (
|
||||
<div className="flex w-[350px] shrink-0 flex-col">
|
||||
<div className="flex shrink-0 grow-0 basis-[400px] flex-col">
|
||||
<div className="flex w-full flex-1 items-center justify-center p-3">
|
||||
<LoaderIcon className="h-5 w-5 animate-spin text-white/10" />
|
||||
</div>
|
||||
@@ -58,14 +88,7 @@ export function SpaceScreen() {
|
||||
) : (
|
||||
widgets.map((widget) => renderItem(widget))
|
||||
)}
|
||||
<div className="flex w-[350px] shrink-0 flex-col">
|
||||
<div className="inline-flex h-full w-full flex-col items-center justify-center gap-1">
|
||||
<FeedModal />
|
||||
<ImageModal />
|
||||
<HashtagModal />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-[250px] shrink-0" />
|
||||
<ToggleWidgetList />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { message } from '@tauri-apps/api/dialog';
|
||||
import { invoke } from '@tauri-apps/api/tauri';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useNDK } from '@libs/ndk/provider';
|
||||
import { updateLastLogin } from '@libs/storage';
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
import { LoaderIcon } from '@shared/icons';
|
||||
|
||||
import { useAccount } from '@utils/hooks/useAccount';
|
||||
import { useNostr } from '@utils/hooks/useNostr';
|
||||
|
||||
export function SplashScreen() {
|
||||
const { ndk, relayUrls } = useNDK();
|
||||
const { status, account } = useAccount();
|
||||
const { fetchChats, fetchNotes } = useNostr();
|
||||
const { db } = useStorage();
|
||||
const { ndk } = useNDK();
|
||||
const { fetchUserData, prefetchEvents } = useNostr();
|
||||
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<null | string>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [errorMessage, setErrorMessage] = useState<null | string>(null);
|
||||
|
||||
const skip = async () => {
|
||||
await invoke('close_splashscreen');
|
||||
@@ -26,31 +26,36 @@ export function SplashScreen() {
|
||||
const step = JSON.parse(onboarding).state.step || null;
|
||||
if (step) await invoke('close_splashscreen');
|
||||
|
||||
const notes = await fetchNotes();
|
||||
const chats = await fetchChats();
|
||||
try {
|
||||
const user = await fetchUserData();
|
||||
const data = await prefetchEvents();
|
||||
|
||||
if (notes.status === 'ok' && chats.status === 'ok') {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
await updateLastLogin(now);
|
||||
invoke('close_splashscreen');
|
||||
} else {
|
||||
setLoading(false);
|
||||
setError(notes.message || chats.message);
|
||||
console.log('fetch notes failed, error: ', notes.message);
|
||||
console.log('fetch chats failed, error: ', chats.message);
|
||||
if (user.status === 'ok' && data.status === 'ok') {
|
||||
await db.updateLastLogin();
|
||||
await invoke('close_splashscreen');
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
setErrorMessage(user.message);
|
||||
console.log('fetch failed, error: ', user.message);
|
||||
}
|
||||
} catch (e) {
|
||||
setIsLoading(false);
|
||||
setErrorMessage(e);
|
||||
await message(`Something wrong: ${e}`, {
|
||||
title: 'Lume',
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'success' && !account) {
|
||||
invoke('close_splashscreen');
|
||||
}
|
||||
if (ndk) {
|
||||
if (!db.account) invoke('close_splashscreen');
|
||||
|
||||
if (ndk && account) {
|
||||
console.log('prefetching...');
|
||||
prefetch();
|
||||
}
|
||||
}, [ndk, account]);
|
||||
}, [ndk, db.account]);
|
||||
|
||||
return (
|
||||
<div className="relative flex h-screen w-screen items-center justify-center bg-black">
|
||||
@@ -58,12 +63,10 @@ export function SplashScreen() {
|
||||
<div className="flex min-h-0 w-full flex-1 items-center justify-center">
|
||||
<div className="flex flex-col items-center justify-center gap-4">
|
||||
<LoaderIcon className="h-6 w-6 animate-spin text-white" />
|
||||
{loading ? (
|
||||
<div className="mt-2 flex flex-col gap-1 text-center">
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col gap-1 text-center">
|
||||
<h3 className="text-lg font-semibold leading-none text-white">
|
||||
{!ndk
|
||||
? 'Connecting to relay...'
|
||||
: `Connected to ${relayUrls.length} relays`}
|
||||
{!ndk ? 'Connecting to relay...' : 'Fetching events from the last login.'}
|
||||
</h3>
|
||||
<p className="text-sm text-white/50">
|
||||
This may take a few seconds, please don't close app.
|
||||
@@ -74,7 +77,7 @@ export function SplashScreen() {
|
||||
<h3 className="text-lg font-semibold leading-none text-white">
|
||||
Something wrong!
|
||||
</h3>
|
||||
<p className="text-sm text-white/50">{error}</p>
|
||||
<p className="text-sm text-white/50">{errorMessage}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={skip}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { fetch } from '@tauri-apps/plugin-http';
|
||||
|
||||
import { NoteKind_1 } from '@shared/notes';
|
||||
import { NoteSkeleton } from '@shared/notes/skeleton';
|
||||
import { TitleBar } from '@shared/titleBar';
|
||||
|
||||
import { LumeEvent } from '@utils/types';
|
||||
|
||||
interface Response {
|
||||
notes: Array<{ event: LumeEvent }>;
|
||||
}
|
||||
|
||||
export function TrendingNotes() {
|
||||
const { status, data, error } = useQuery(
|
||||
['trending-notes'],
|
||||
async () => {
|
||||
const res = await fetch('https://api.nostr.band/v0/trending/notes');
|
||||
if (!res.ok) {
|
||||
throw new Error('Error');
|
||||
}
|
||||
const json: Response = await res.json();
|
||||
return json.notes;
|
||||
},
|
||||
{
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: Infinity,
|
||||
}
|
||||
);
|
||||
|
||||
console.log('notes: ', data);
|
||||
|
||||
return (
|
||||
<div className="scrollbar-hide relative h-full w-[400px] shrink-0 overflow-y-auto bg-white/10 pb-20">
|
||||
<TitleBar title="Trending Posts" />
|
||||
<div className="h-full">
|
||||
{error && <p>Failed to fetch</p>}
|
||||
{status === 'loading' ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-xl bg-white/10 px-3 py-3">
|
||||
<NoteSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative flex w-full flex-col">
|
||||
{data.map((item) => (
|
||||
<NoteKind_1 key={item.event.id} event={item.event} skipMetadata={true} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { fetch } from '@tauri-apps/plugin-http';
|
||||
|
||||
import { Profile } from '@app/trending/components/profile';
|
||||
|
||||
import { NoteSkeleton } from '@shared/notes/skeleton';
|
||||
import { TitleBar } from '@shared/titleBar';
|
||||
|
||||
interface Response {
|
||||
profiles: Array<{ pubkey: string }>;
|
||||
}
|
||||
|
||||
export function TrendingProfiles() {
|
||||
const { status, data, error } = useQuery(
|
||||
['trending-profiles'],
|
||||
async () => {
|
||||
const res = await fetch('https://api.nostr.band/v0/trending/profiles');
|
||||
if (!res.ok) {
|
||||
throw new Error('Error');
|
||||
}
|
||||
const json: Response = await res.json();
|
||||
return json.profiles;
|
||||
},
|
||||
{
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: Infinity,
|
||||
}
|
||||
);
|
||||
|
||||
console.log('profiles: ', data);
|
||||
|
||||
return (
|
||||
<div className="scrollbar-hide relative h-full w-[400px] shrink-0 overflow-y-auto bg-white/10 pb-20">
|
||||
<TitleBar title="Trending Profiles" />
|
||||
<div className="h-full">
|
||||
{error && <p>Failed to fetch</p>}
|
||||
{status === 'loading' ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-xl bg-white/10 px-3 py-3">
|
||||
<NoteSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative flex w-full flex-col gap-3 px-3 pt-1.5">
|
||||
{data?.map((item) => (
|
||||
<Profile key={item.pubkey} data={item} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { TrendingNotes } from '@app/trending/components/trendingNotes';
|
||||
import { TrendingProfiles } from '@app/trending/components/trendingProfiles';
|
||||
|
||||
export function TrendingScreen() {
|
||||
return (
|
||||
<div className="scrollbar-hide flex h-full w-full flex-nowrap divide-x divide-white/5 overflow-x-auto overflow-y-hidden">
|
||||
<TrendingProfiles />
|
||||
<TrendingNotes />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useRef } from 'react';
|
||||
|
||||
import { useNDK } from '@libs/ndk/provider';
|
||||
|
||||
import { NoteKind_1, NoteSkeleton } from '@shared/notes';
|
||||
|
||||
import { nHoursAgo } from '@utils/date';
|
||||
import { LumeEvent } from '@utils/types';
|
||||
|
||||
export function UserFeed({ pubkey }: { pubkey: string }) {
|
||||
const parentRef = useRef();
|
||||
|
||||
const { ndk } = useNDK();
|
||||
const { status, data } = useQuery(['user-feed', pubkey], async () => {
|
||||
const events = await ndk.fetchEvents({
|
||||
kinds: [1],
|
||||
authors: [pubkey],
|
||||
since: nHoursAgo(48),
|
||||
});
|
||||
return [...events] as unknown as LumeEvent[];
|
||||
});
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: data ? data.length : 0,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 400,
|
||||
});
|
||||
|
||||
const itemsVirtualizer = rowVirtualizer.getVirtualItems();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="scrollbar-hide flex h-full w-full flex-col justify-between gap-1.5 overflow-y-auto pb-20 pt-1.5"
|
||||
style={{ contain: 'strict' }}
|
||||
>
|
||||
{status === 'loading' ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="shadow-input rounded-md bg-zinc-900 px-3 py-3 shadow-black/20">
|
||||
<NoteSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
) : itemsVirtualizer.length === 0 ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-xl border-t border-zinc-800/50 bg-zinc-900 px-3 py-6">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<p className="text-center text-sm text-zinc-300">
|
||||
No new posts about this hashtag in 48 hours ago
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute left-0 top-0 w-full"
|
||||
style={{
|
||||
transform: `translateY(${
|
||||
itemsVirtualizer[0].start - rowVirtualizer.options.scrollMargin
|
||||
}px)`,
|
||||
}}
|
||||
>
|
||||
{itemsVirtualizer.map((virtualRow) => (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
data-index={virtualRow.index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
>
|
||||
<NoteKind_1 event={data[virtualRow.index]} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
309
src/app/users/components/modal.tsx
Normal file
309
src/app/users/components/modal.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
import { NDKEvent, NDKUserProfile } from '@nostr-dev-kit/ndk';
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { fetch } from '@tauri-apps/api/http';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
import { AvatarUploader } from '@shared/avatarUploader';
|
||||
import { BannerUploader } from '@shared/bannerUploader';
|
||||
import { CancelIcon, CheckCircleIcon, LoaderIcon, UnverifiedIcon } from '@shared/icons';
|
||||
import { Image } from '@shared/image';
|
||||
|
||||
import { useNostr } from '@utils/hooks/useNostr';
|
||||
|
||||
interface NIP05 {
|
||||
names: {
|
||||
[key: string]: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function EditProfileModal() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [picture, setPicture] = useState('https://void.cat/d/5VKmKyuHyxrNMf9bWSVPih');
|
||||
const [banner, setBanner] = useState(null);
|
||||
const [nip05, setNIP05] = useState({ verified: false, text: '' });
|
||||
|
||||
const { db } = useStorage();
|
||||
const { publish } = useNostr();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setError,
|
||||
formState: { isValid, errors },
|
||||
} = useForm({
|
||||
defaultValues: async () => {
|
||||
const res: NDKUserProfile = queryClient.getQueryData(['user', db.account.pubkey]);
|
||||
if (res.image) {
|
||||
setPicture(res.image);
|
||||
}
|
||||
if (res.banner) {
|
||||
setBanner(res.banner);
|
||||
}
|
||||
if (res.nip05) {
|
||||
setNIP05((prev) => ({ ...prev, text: res.nip05 }));
|
||||
}
|
||||
return res;
|
||||
},
|
||||
});
|
||||
|
||||
const verifyNIP05 = async (nip05: string) => {
|
||||
const localPath = nip05.split('@')[0];
|
||||
const service = nip05.split('@')[1];
|
||||
const verifyURL = `https://${service}/.well-known/nostr.json?name=${localPath}`;
|
||||
|
||||
const res = await fetch(verifyURL, {
|
||||
method: 'GET',
|
||||
timeout: 10,
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`Failed to fetch NIP-05 service: ${nip05}`);
|
||||
|
||||
const data = res.data as NIP05;
|
||||
if (data.names) {
|
||||
if (data.names[localPath] !== db.account.pubkey) return false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const onSubmit = async (data: NDKUserProfile) => {
|
||||
// start loading
|
||||
setLoading(true);
|
||||
|
||||
let event: NDKEvent;
|
||||
|
||||
const content = {
|
||||
...data,
|
||||
username: data.name,
|
||||
display_name: data.name,
|
||||
bio: data.about,
|
||||
image: data.picture,
|
||||
};
|
||||
|
||||
if (data.nip05) {
|
||||
const nip05IsVerified = await verifyNIP05(data.nip05);
|
||||
if (nip05IsVerified) {
|
||||
event = await publish({
|
||||
content: JSON.stringify({ ...content, nip05: data.nip05 }),
|
||||
kind: 0,
|
||||
tags: [],
|
||||
});
|
||||
} else {
|
||||
setNIP05((prev) => ({ ...prev, verified: false }));
|
||||
setError('nip05', {
|
||||
type: 'manual',
|
||||
message: "Can't verify your Lume ID / NIP-05, please check again",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
event = await publish({
|
||||
content: JSON.stringify(content),
|
||||
kind: 0,
|
||||
tags: [],
|
||||
});
|
||||
}
|
||||
|
||||
if (event.id) {
|
||||
// invalid cache
|
||||
queryClient.invalidateQueries(['user', db.account.pubkey]);
|
||||
// reset form
|
||||
reset();
|
||||
// reset state
|
||||
setLoading(false);
|
||||
setIsOpen(false);
|
||||
setPicture('https://void.cat/d/5VKmKyuHyxrNMf9bWSVPih');
|
||||
setBanner(null);
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!nip05.verified && /\S+@\S+\.\S+/.test(nip05.text)) {
|
||||
verifyNIP05(nip05.text);
|
||||
}
|
||||
}, [nip05.text]);
|
||||
|
||||
return (
|
||||
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Dialog.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-white/10 text-sm font-medium backdrop-blur-xl hover:bg-fuchsia-500"
|
||||
>
|
||||
Edit profile
|
||||
</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 items-center justify-between">
|
||||
<Dialog.Title className="text-lg font-semibold leading-none text-white">
|
||||
Edit profile
|
||||
</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>
|
||||
</div>
|
||||
<div className="flex h-full w-full flex-col overflow-y-auto">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="mb-0">
|
||||
<input type={'hidden'} {...register('picture')} value={picture} />
|
||||
<input type={'hidden'} {...register('banner')} value={banner} />
|
||||
<div className="relative">
|
||||
<div className="relative h-44 w-full">
|
||||
{banner ? (
|
||||
<img
|
||||
src={banner}
|
||||
alt="user's banner"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full bg-white" />
|
||||
)}
|
||||
<div className="absolute left-1/2 top-1/2 z-10 h-full w-full -translate-x-1/2 -translate-y-1/2 transform">
|
||||
<BannerUploader setBanner={setBanner} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-5 px-4">
|
||||
<div className="relative z-10 -mt-7 h-14 w-14">
|
||||
<Image
|
||||
src={picture}
|
||||
alt="user's avatar"
|
||||
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} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 px-4 pb-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="text-sm font-semibold uppercase tracking-wider text-white/50"
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type={'text'}
|
||||
{...register('name', {
|
||||
required: true,
|
||||
minLength: 4,
|
||||
})}
|
||||
spellCheck={false}
|
||||
className="relative h-11 w-full rounded-lg bg-white/10 px-3 py-1 !outline-none backdrop-blur-xl placeholder:text-white/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="nip05"
|
||||
className="text-sm font-semibold uppercase tracking-wider text-white/50"
|
||||
>
|
||||
NIP-05
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
{...register('nip05', {
|
||||
required: true,
|
||||
minLength: 4,
|
||||
})}
|
||||
spellCheck={false}
|
||||
className="relative h-11 w-full rounded-lg bg-white/10 px-3 py-1 !outline-none backdrop-blur-xl placeholder:text-white/50"
|
||||
/>
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 transform">
|
||||
{nip05.verified ? (
|
||||
<span className="inline-flex h-6 items-center gap-1 rounded bg-green-500 px-2 text-sm font-medium">
|
||||
<CheckCircleIcon className="h-4 w-4 text-white" />
|
||||
Verified
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex h-6 items-center gap-1 rounded bg-red-500 px-2 text-sm font-medium">
|
||||
<UnverifiedIcon className="h-4 w-4 text-white" />
|
||||
Unverified
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{errors.nip05 && (
|
||||
<p className="mt-1 text-sm text-red-400">
|
||||
{errors.nip05.message.toString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="about"
|
||||
className="text-sm font-semibold uppercase tracking-wider text-white/50"
|
||||
>
|
||||
Bio
|
||||
</label>
|
||||
<textarea
|
||||
{...register('about')}
|
||||
spellCheck={false}
|
||||
className="relative h-20 w-full resize-none rounded-lg bg-white/10 px-3 py-1 !outline-none backdrop-blur-xl placeholder:text-white/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="website"
|
||||
className="text-sm font-semibold uppercase tracking-wider text-white/50"
|
||||
>
|
||||
Website
|
||||
</label>
|
||||
<input
|
||||
type={'text'}
|
||||
{...register('website', { required: false })}
|
||||
spellCheck={false}
|
||||
className="relative h-11 w-full rounded-lg bg-white/10 px-3 py-1 !outline-none backdrop-blur-xl placeholder:text-white/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="website"
|
||||
className="text-sm font-semibold uppercase tracking-wider text-white/50"
|
||||
>
|
||||
Lightning address
|
||||
</label>
|
||||
<input
|
||||
type={'text'}
|
||||
{...register('lud16', { required: false })}
|
||||
spellCheck={false}
|
||||
className="relative h-11 w-full rounded-lg bg-white/10 px-3 py-1 !outline-none backdrop-blur-xl placeholder:text-white/50"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isValid}
|
||||
className="inline-flex h-11 w-full transform items-center justify-center gap-1 rounded-md bg-fuchsia-500 font-medium text-white hover:bg-fuchsia-600 focus:outline-none active:translate-y-1 disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-white" />
|
||||
) : (
|
||||
'Update'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +1,28 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { UserMetadata } from '@app/users/components/metadata';
|
||||
import { EditProfileModal } from '@app/users/components/modal';
|
||||
import { UserStats } from '@app/users/components/stats';
|
||||
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
import { EditProfileModal } from '@shared/editProfileModal';
|
||||
import { Image } from '@shared/image';
|
||||
import { NIP05 } from '@shared/nip05';
|
||||
|
||||
import { DEFAULT_AVATAR } from '@stores/constants';
|
||||
|
||||
import { useAccount } from '@utils/hooks/useAccount';
|
||||
import { useNostr } from '@utils/hooks/useNostr';
|
||||
import { useProfile } from '@utils/hooks/useProfile';
|
||||
import { useSocial } from '@utils/hooks/useSocial';
|
||||
import { shortenKey } from '@utils/shortenKey';
|
||||
import { displayNpub } from '@utils/shortenKey';
|
||||
|
||||
export function UserProfile({ pubkey }: { pubkey: string }) {
|
||||
const { db } = useStorage();
|
||||
const { user } = useProfile(pubkey);
|
||||
const { account } = useAccount();
|
||||
const { status, userFollows, follow, unfollow } = useSocial();
|
||||
const { addContact, removeContact } = useNostr();
|
||||
|
||||
const [followed, setFollowed] = useState(false);
|
||||
|
||||
const followUser = (pubkey: string) => {
|
||||
try {
|
||||
follow(pubkey);
|
||||
|
||||
addContact(pubkey);
|
||||
// update state
|
||||
setFollowed(true);
|
||||
} catch (error) {
|
||||
@@ -33,8 +32,7 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
|
||||
|
||||
const unfollowUser = (pubkey: string) => {
|
||||
try {
|
||||
unfollow(pubkey);
|
||||
|
||||
removeContact(pubkey);
|
||||
// update state
|
||||
setFollowed(false);
|
||||
} catch (error) {
|
||||
@@ -43,80 +41,91 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'success' && userFollows) {
|
||||
if (userFollows.includes(pubkey)) {
|
||||
setFollowed(true);
|
||||
}
|
||||
if (db.account.follows.includes(pubkey)) {
|
||||
setFollowed(true);
|
||||
}
|
||||
}, [status]);
|
||||
}, []);
|
||||
|
||||
if (!user) return <p>Loading...</p>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-56 w-full bg-white">
|
||||
<Image
|
||||
src={user?.banner}
|
||||
fallback="https://void.cat/d/QY1myro5tkHVs2nY7dy74b.jpg"
|
||||
alt={'banner'}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
<div className="h-56 w-full">
|
||||
{user.banner ? (
|
||||
<img
|
||||
src={user.banner}
|
||||
alt="user banner"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full bg-black" />
|
||||
)}
|
||||
</div>
|
||||
<div className="-mt-7 w-full px-5">
|
||||
<div className="-mt-7 flex w-full flex-col items-center px-5">
|
||||
<Image
|
||||
src={user?.picture || user?.image}
|
||||
fallback={DEFAULT_AVATAR}
|
||||
src={user.picture || user.image}
|
||||
alt={pubkey}
|
||||
className="h-14 w-14 rounded-md ring-2 ring-white/50"
|
||||
className="h-14 w-14 rounded-lg ring-2 ring-black"
|
||||
/>
|
||||
<div className="mt-2 flex flex-1 flex-col gap-4">
|
||||
<div className="flex items-center gap-16">
|
||||
<div className="inline-flex flex-col gap-1.5">
|
||||
<h5 className="text-lg font-semibold leading-none">
|
||||
{user?.displayName || user?.name || 'No name'}
|
||||
<div className="mt-2 flex flex-1 flex-col gap-6">
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div className="inline-flex flex-col items-center gap-1.5">
|
||||
<h5 className="text-center text-xl font-semibold leading-none">
|
||||
{user.display_name || user.displayName || user.name || 'No name'}
|
||||
</h5>
|
||||
<span className="max-w-[15rem] truncate text-sm leading-none text-white/50">
|
||||
{user?.nip05 || shortenKey(pubkey)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-2">
|
||||
{status === 'loading' ? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-white/10 text-sm font-medium hover:bg-fuchsia-500"
|
||||
>
|
||||
Loading...
|
||||
</button>
|
||||
) : followed ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => unfollowUser(pubkey)}
|
||||
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-white/10 text-sm font-medium hover:bg-fuchsia-500"
|
||||
>
|
||||
Unfollow
|
||||
</button>
|
||||
{user.nip05 ? (
|
||||
<NIP05
|
||||
pubkey={pubkey}
|
||||
nip05={user?.nip05}
|
||||
className="max-w-[15rem] truncate text-sm leading-none text-white/50"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => followUser(pubkey)}
|
||||
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-white/10 text-sm font-medium hover:bg-fuchsia-500"
|
||||
>
|
||||
Follow
|
||||
</button>
|
||||
<span className="max-w-[15rem] truncate text-sm leading-none text-white/50">
|
||||
{displayNpub(pubkey, 16)}
|
||||
</span>
|
||||
)}
|
||||
<Link
|
||||
to={`/chats/${pubkey}`}
|
||||
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-white/10 text-sm font-medium hover:bg-fuchsia-500"
|
||||
>
|
||||
Message
|
||||
</Link>
|
||||
<span className="mx-2 inline-flex h-4 w-px bg-white/10" />
|
||||
{account && account.pubkey === pubkey && <EditProfileModal />}
|
||||
</div>
|
||||
<div className="flex flex-col gap-6">
|
||||
{user.about || user.bio ? (
|
||||
<p className="mt-2 max-w-[500px] select-text break-words text-center text-white">
|
||||
{user.about || user.bio}
|
||||
</p>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<UserStats pubkey={pubkey} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-8">
|
||||
<p className="mt-2 max-w-[500px] select-text break-words text-white">
|
||||
{user?.about || user?.bio}
|
||||
</p>
|
||||
<UserMetadata pubkey={pubkey} />
|
||||
<div className="inline-flex items-center justify-center gap-2">
|
||||
{followed ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => unfollowUser(pubkey)}
|
||||
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-white/10 text-sm font-medium backdrop-blur-xl hover:bg-fuchsia-500"
|
||||
>
|
||||
Unfollow
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => followUser(pubkey)}
|
||||
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-white/10 text-sm font-medium backdrop-blur-xl hover:bg-fuchsia-500"
|
||||
>
|
||||
Follow
|
||||
</button>
|
||||
)}
|
||||
<Link
|
||||
to={`/chats/${pubkey}`}
|
||||
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-white/10 text-sm font-medium backdrop-blur-xl hover:bg-fuchsia-500"
|
||||
>
|
||||
Message
|
||||
</Link>
|
||||
{db.account.pubkey === pubkey && (
|
||||
<>
|
||||
<span className="mx-2 inline-flex h-4 w-px bg-white/10 backdrop-blur-xl" />
|
||||
<EditProfileModal />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { LoaderIcon } from '@shared/icons';
|
||||
|
||||
import { compactNumber } from '@utils/number';
|
||||
|
||||
export function UserMetadata({ pubkey }: { pubkey: string }) {
|
||||
export function UserStats({ pubkey }: { pubkey: string }) {
|
||||
const { status, data } = useQuery(['user-metadata', pubkey], async () => {
|
||||
const res = await fetch(`https://api.nostr.band/v0/stats/profile/${pubkey}`);
|
||||
if (!res.ok) {
|
||||
@@ -12,24 +14,32 @@ export function UserMetadata({ pubkey }: { pubkey: string }) {
|
||||
});
|
||||
|
||||
if (status === 'loading') {
|
||||
return <p>Loading...</p>;
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center">
|
||||
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'error') {
|
||||
return <div className="flex w-full items-center justify-center" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center gap-10">
|
||||
<div className="inline-flex flex-col gap-1">
|
||||
<div className="flex w-full items-center justify-center gap-10">
|
||||
<div className="inline-flex flex-col items-center gap-1">
|
||||
<span className="font-semibold leading-none text-white">
|
||||
{compactNumber.format(data.stats[pubkey].followers_pubkey_count) ?? 0}
|
||||
</span>
|
||||
<span className="text-sm leading-none text-white/50">Followers</span>
|
||||
</div>
|
||||
<div className="inline-flex flex-col gap-1">
|
||||
<div className="inline-flex flex-col items-center gap-1">
|
||||
<span className="font-semibold leading-none text-white">
|
||||
{compactNumber.format(data.stats[pubkey].pub_following_pubkey_count) ?? 0}
|
||||
</span>
|
||||
<span className="text-sm leading-none text-white/50">Following</span>
|
||||
</div>
|
||||
<div className="inline-flex flex-col gap-1">
|
||||
<div className="inline-flex flex-col items-center gap-1">
|
||||
<span className="font-semibold leading-none text-white">
|
||||
{data.stats[pubkey].zaps_received
|
||||
? compactNumber.format(data.stats[pubkey].zaps_received.msats / 1000)
|
||||
@@ -37,7 +47,7 @@ export function UserMetadata({ pubkey }: { pubkey: string }) {
|
||||
</span>
|
||||
<span className="text-sm leading-none text-white/50">Zaps received</span>
|
||||
</div>
|
||||
<div className="inline-flex flex-col gap-1">
|
||||
<div className="inline-flex flex-col items-center gap-1">
|
||||
<span className="font-semibold leading-none text-white">
|
||||
{data.stats[pubkey].zaps_sent
|
||||
? compactNumber.format(data.stats[pubkey].zaps_sent.msats / 1000)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user