Compare commits

..

29 Commits

Author SHA1 Message Date
Ren Amamiya
1bbfebc2b8 fix ci again 2023-09-19 16:31:38 +07:00
Ren Amamiya
d84e97b0d4 fix ci 2023-09-19 15:59:50 +07:00
Ren Amamiya
824aa8fa28 back to pnpm, bun is fun but cannot generate tauri build 2023-09-19 15:56:33 +07:00
Ren Amamiya
2b34ef3b7a fix build error 2023-09-19 15:29:26 +07:00
Ren Amamiya
4fa8f40e6a improve nwc 2023-09-19 15:06:12 +07:00
Ren Amamiya
c1bddeb6ed bump version 2023-09-19 11:20:15 +07:00
Ren Amamiya
5c2bfa0ea3 improve notification 2023-09-19 11:15:35 +07:00
Ren Amamiya
60e93965ea respect user's relay list (kind 10002) 2023-09-19 08:01:57 +07:00
Ren Amamiya
380d1fb930 temporary using default relays 2023-09-18 15:42:17 +07:00
Ren Amamiya
53aa13c8aa clean up messy code 2023-09-18 09:50:15 +07:00
Ren Amamiya
13f5190ba1 update dependencies and better handle repost 2023-09-17 16:14:04 +07:00
Ren Amamiya
c590e290e0 Merge pull request #87 from luminous-devs/feat/improve-onboarding
merge now, improve later
2023-09-17 08:44:16 +07:00
Ren Amamiya
cdf86a2613 polish 2023-09-17 08:43:42 +07:00
Ren Amamiya
8726e22b38 replace nostr.com with njump.me 2023-09-17 08:03:29 +07:00
Ren Amamiya
1206486016 partial support replaceable event 2023-09-16 16:06:01 +07:00
Ren Amamiya
11ad281d72 wip 2023-09-16 08:57:24 +07:00
Ren Amamiya
fe4bfa1699 wip: learn nostr widget 2023-09-16 07:47:44 +07:00
Ren Amamiya
c6a0636e8c add complete screen 2023-09-15 10:29:39 +07:00
Ren Amamiya
d3db6492d9 update onboarding 2023-09-15 08:58:09 +07:00
Ren Amamiya
8f8617d8f9 update import key flow 2023-09-14 16:51:38 +07:00
Ren Amamiya
8e513404c3 update create account flow 2023-09-14 09:20:36 +07:00
Ren Amamiya
5a6dd172b1 small fixes 2023-09-13 11:10:24 +07:00
Ren Amamiya
fa0d7cac31 hide nwc secret in frontend 2023-09-12 16:16:57 +07:00
Ren Amamiya
432b2ae185 polish nwc connection flow 2023-09-12 16:00:41 +07:00
Ren Amamiya
fb8a6581dd replace pnpm with bun 2023-09-12 08:43:12 +07:00
Ren Amamiya
a4f221f868 wip: redesign nwc 2023-09-12 08:27:29 +07:00
Ren Amamiya
602d012efe fix alby connection 2023-09-11 07:52:43 +07:00
Ren Amamiya
5bf816eba2 fully suport alby nostr-wallet-connect 2023-09-10 16:25:35 +07:00
Ren Amamiya
a33c9d3517 wip: integrate alby 2023-09-10 07:19:36 +07:00
96 changed files with 2977 additions and 2848 deletions

2
.gitignore vendored
View File

@@ -14,9 +14,9 @@ out
*.local
.next
.vscode
pnpm-lock.yaml
*.db
*.db-journal
bun.lockb
# Editor directories and files
.vscode/*

View File

@@ -8,6 +8,12 @@ Download Lume for your platform here: [https://github.com/luminous-devs/lume/rel
Supported platform: macOS, Windows and Linux
### Prerequisites
- Bun: https://bun.sh/docs/installation
- Tauri: https://tauri.app/v1/guides/getting-started/prerequisites#setting-up-macos
### Develop
Clone project
@@ -19,23 +25,17 @@ git clone https://github.com/luminous-devs/lume.git && cd lume
Install packages
```
pnpm install
bun install
```
Run dev
Run dev build
```
pnpm tauri dev
bun tauri dev
```
Build
Generate production build
```
pnpm tauri build
```
(Advance) - Generate SQLite migration
```
pnpm add-migrate <migrate_name>
```
bun tauri build
```

View File

@@ -2,7 +2,7 @@
"name": "lume",
"description": "the communication app",
"private": true,
"version": "1.2.4",
"version": "1.2.5",
"scripts": {
"dev": "vite",
"build": "vite build",
@@ -18,7 +18,6 @@
"**/*.{ts, tsx, css, md, html, json}": "prettier --cache --write"
},
"dependencies": {
"@ctrl/magnet-link": "^3.1.2",
"@getalby/sdk": "^2.4.0",
"@nostr-dev-kit/ndk": "^1.0.0",
"@nostr-fetch/adapter-ndk": "^0.12.2",
@@ -38,7 +37,6 @@
"@tiptap/react": "^2.1.8",
"@tiptap/starter-kit": "^2.1.8",
"@tiptap/suggestion": "^2.1.8",
"@void-cat/api": "^1.0.7",
"dayjs": "^1.11.9",
"destr": "^2.0.1",
"get-urls": "^12.1.0",
@@ -50,9 +48,9 @@
"nostr-tools": "^1.14.2",
"qrcode.react": "^3.1.0",
"react": "^18.2.0",
"react-currency-input-field": "^3.6.11",
"react-dom": "^18.2.0",
"react-hook-form": "^7.46.1",
"react-hotkeys-hook": "^4.4.1",
"react-markdown": "^8.0.7",
"react-player": "^2.13.0",
"react-router-dom": "^6.15.0",
@@ -63,7 +61,6 @@
"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": {
@@ -71,7 +68,7 @@
"@tauri-apps/cli": "^1.4.0",
"@trivago/prettier-plugin-sort-imports": "^4.2.0",
"@types/html-to-text": "^9.0.1",
"@types/node": "^20.5.9",
"@types/node": "^20.6.0",
"@types/react": "^18.2.21",
"@types/react-dom": "^18.2.7",
"@types/youtube-player": "^5.5.7",
@@ -83,7 +80,7 @@
"cross-env": "^7.0.3",
"csstype": "^3.1.2",
"encoding": "^0.1.13",
"eslint": "^8.48.0",
"eslint": "^8.49.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react": "^7.33.2",
@@ -98,6 +95,6 @@
"tailwindcss": "^3.3.3",
"typescript": "^5.2.2",
"vite": "^4.4.9",
"vite-tsconfig-paths": "^4.2.0"
"vite-tsconfig-paths": "^4.2.1"
}
}

2736
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

66
src-tauri/Cargo.lock generated
View File

@@ -401,9 +401,9 @@ checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
[[package]]
name = "base64"
version = "0.21.3"
version = "0.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "414dcefbc63d77c526a76b3afcf6fbb9b5e2791c19c3aa2297733208750c6e53"
checksum = "9ba43ea6f343b788c8764558649e08df62f86c6ef251fdaeb1ffd010a9ae50a2"
[[package]]
name = "base64ct"
@@ -614,7 +614,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "599aa35200ffff8f04c1925aa1acc92fa2e08874379ef42e210a80e527e60838"
dependencies = [
"serde",
"toml 0.7.6",
"toml 0.7.8",
]
[[package]]
@@ -654,9 +654,9 @@ dependencies = [
[[package]]
name = "cfg-expr"
version = "0.15.4"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b40ccee03b5175c18cde8f37e7d2a33bcef6f8ec8f7cc0d81090d1bb380949c9"
checksum = "03915af431787e6ffdcc74c645077518c6b6e01f80b761e0fbbfa288536311b3"
dependencies = [
"smallvec",
"target-lexicon",
@@ -1330,7 +1330,7 @@ checksum = "fd0a2c9b742a980060d22545a7a83b573acd6b73045b9de6370c9530ce652f27"
dependencies = [
"cc",
"rustc_version",
"toml 0.7.6",
"toml 0.7.8",
"vswhom",
"winreg 0.51.0",
]
@@ -1459,7 +1459,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef033ed5e9bad94e55838ca0ca906db0e043f517adda0c8b79c7a8c66c93c1b5"
dependencies = [
"cfg-if",
"rustix 0.38.11",
"rustix 0.38.12",
"windows-sys 0.48.0",
]
@@ -2590,9 +2590,9 @@ checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519"
[[package]]
name = "linux-raw-sys"
version = "0.4.5"
version = "0.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57bcfdad1b858c2db7c38303a6d2ad4dfaf5eb53dfeb0910128b2c26d6158503"
checksum = "1a9bad9f94746442c783ca431b22403b519cd7fbeed0533fdd6328b2f2212128"
[[package]]
name = "lock_api"
@@ -2627,7 +2627,7 @@ dependencies = [
[[package]]
name = "lume"
version = "1.2.4"
version = "1.2.5"
dependencies = [
"rust-argon2 1.0.0",
"serde",
@@ -3444,7 +3444,7 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdc0001cfea3db57a2e24bc0d818e9e20e554b5f97fabb9bc231dc240269ae06"
dependencies = [
"base64 0.21.3",
"base64 0.21.4",
"indexmap 1.9.3",
"line-wrap",
"quick-xml 0.29.0",
@@ -3823,7 +3823,7 @@ version = "0.11.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e9ad3fe7488d7e34558a2033d45a0c90b72d97b4f80705666fea71472e2e6a1"
dependencies = [
"base64 0.21.3",
"base64 0.21.4",
"bytes",
"encoding_rs",
"futures-core",
@@ -3936,7 +3936,7 @@ version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e71971821b3ae0e769e4a4328dbcb517607b434db7697e9aba17203ec14e46a"
dependencies = [
"base64 0.21.3",
"base64 0.21.4",
"blake2b_simd",
"constant_time_eq 0.3.0",
]
@@ -3972,14 +3972,14 @@ dependencies = [
[[package]]
name = "rustix"
version = "0.38.11"
version = "0.38.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0c3dde1fc030af041adc40e79c0e7fbcf431dd24870053d187d7c66e4b87453"
checksum = "bdf14a7a466ce88b5eac3da815b53aefc208ce7e74d1c263aabb04d88c4abeb1"
dependencies = [
"bitflags 2.4.0",
"errno",
"libc",
"linux-raw-sys 0.4.5",
"linux-raw-sys 0.4.7",
"windows-sys 0.48.0",
]
@@ -4000,7 +4000,7 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d3987094b1d07b653b7dfdc3f70ce9a1da9c51ac18c1b06b662e4f9a0e9f4b2"
dependencies = [
"base64 0.21.3",
"base64 0.21.4",
]
[[package]]
@@ -4169,9 +4169,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.105"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "693151e1ac27563d6dbcec9dee9fbd5da8539b20fa14ad3752b2e6d363ace360"
checksum = "2cc66a619ed80bf7a0f6b17dd063a84b88f6dea1813737cf469aef1d081142c2"
dependencies = [
"itoa 1.0.9",
"ryu",
@@ -4216,7 +4216,7 @@ version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ca3b16a3d82c4088f343b7480a93550b3eabe1a358569c2dfe38bbcead07237"
dependencies = [
"base64 0.21.3",
"base64 0.21.4",
"chrono",
"hex",
"indexmap 1.9.3",
@@ -4584,7 +4584,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ca69bf415b93b60b80dc8fda3cb4ef52b2336614d8da2de5456cc942a110482"
dependencies = [
"atoi",
"base64 0.21.3",
"base64 0.21.4",
"bitflags 2.4.0",
"byteorder",
"bytes",
@@ -4627,7 +4627,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0db2df1b8731c3651e204629dd55e52adbae0462fa1bdcbed56a2302c18181e"
dependencies = [
"atoi",
"base64 0.21.3",
"base64 0.21.4",
"bitflags 2.4.0",
"byteorder",
"crc",
@@ -4871,10 +4871,10 @@ version = "6.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30c2de8a4d8f4b823d634affc9cd2a74ec98c53a756f317e529a48046cbf71f3"
dependencies = [
"cfg-expr 0.15.4",
"cfg-expr 0.15.5",
"heck 0.4.1",
"pkg-config",
"toml 0.7.6",
"toml 0.7.8",
"version-compare 0.1.1",
]
@@ -5029,7 +5029,7 @@ version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54ad2d49fdeab4a08717f5b49a163bdc72efc3b1950b6758245fcde79b645e1a"
dependencies = [
"base64 0.21.3",
"base64 0.21.4",
"brotli",
"ico",
"json-patch",
@@ -5231,7 +5231,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5993dc129e544393574288923d1ec447c857f3f644187f4fbf7d9a875fbfc4fb"
dependencies = [
"embed-resource",
"toml 0.7.6",
"toml 0.7.8",
]
[[package]]
@@ -5253,7 +5253,7 @@ dependencies = [
"cfg-if",
"fastrand 2.0.0",
"redox_syscall 0.3.5",
"rustix 0.38.11",
"rustix 0.38.12",
"windows-sys 0.48.0",
]
@@ -5421,9 +5421,9 @@ dependencies = [
[[package]]
name = "toml"
version = "0.7.6"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c17e963a819c331dcacd7ab957d80bc2b9a9c1e71c804826d2f283dd65306542"
checksum = "dd79e69d3b627db300ff956027cc6c3798cef26d22526befdfcd12feeb6d2257"
dependencies = [
"serde",
"serde_spanned",
@@ -5442,9 +5442,9 @@ dependencies = [
[[package]]
name = "toml_edit"
version = "0.19.14"
version = "0.19.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8123f27e969974a3dfba720fdb560be359f57b44302d280ba72e76a74480e8a"
checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
dependencies = [
"indexmap 2.0.0",
"serde",
@@ -5972,8 +5972,8 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "window-vibrancy"
version = "0.4.0"
source = "git+https://github.com/tauri-apps/window-vibrancy?branch=dev#84b45368cb9aa8ce5107ae3d508092720226da22"
version = "0.4.1"
source = "git+https://github.com/tauri-apps/window-vibrancy?branch=dev#ce6e299a3bc98bd1d0f322c3b922fe8634f09e8e"
dependencies = [
"cocoa 0.25.0",
"objc",

View File

@@ -1,6 +1,6 @@
[package]
name = "lume"
version = "1.2.4"
version = "1.2.5"
description = "the communication app"
authors = ["Ren Amamiya"]
license = "GPL-3.0"
@@ -17,6 +17,10 @@ tauri-build = { version = "1.4", features = [] }
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
tauri = { version = "1.4", features = [
"window-close",
"window-print",
"window-create",
"macos-private-api",
"fs-read-dir",
"fs-read-file",
"window-start-dragging",

View File

@@ -0,0 +1,2 @@
-- Add migration script here
CREATE UNIQUE INDEX unique_relay ON relays (relay);

View File

@@ -211,6 +211,12 @@ fn main() {
sql: include_str!("../migrations/20230817014932_add_last_login_time_to_account.sql"),
kind: MigrationKind::Up,
},
Migration {
version: 20230918235335,
description: "add unique to relay",
sql: include_str!("../migrations/20230918235335_add_uniq_to_relay.sql"),
kind: MigrationKind::Up,
},
],
)
.build(),

View File

@@ -1,15 +1,15 @@
{
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
"build": {
"beforeBuildCommand": "pnpm build",
"beforeDevCommand": "pnpm dev",
"beforeBuildCommand": "pnpm run build",
"beforeDevCommand": "pnpm run dev",
"devPath": "http://localhost:3000",
"distDir": "../dist",
"withGlobalTauri": true
},
"package": {
"productName": "Lume",
"version": "1.2.4"
"version": "1.2.5"
},
"tauri": {
"allowlist": {
@@ -67,7 +67,10 @@
"center": true,
"setResizable": true,
"setSize": true,
"startDragging": true
"startDragging": true,
"create": true,
"close": true,
"print": true
},
"clipboard": {
"all": false,
@@ -124,7 +127,15 @@
"security": {
"csp": {
"content-security-policy": "upgrade-insecure-requests"
}
},
"dangerousRemoteDomainIpcAccess": [
{
"scheme": "https",
"domain": "nwc.getalby.com",
"windows": ["alby"],
"enableTauriAPI": true
}
]
},
"macOSPrivateApi": true
}

View File

@@ -80,6 +80,13 @@ const router = createBrowserRouter([
return { Component: NotificationScreen };
},
},
{
path: 'nwc',
async lazy() {
const { NWCScreen } = await import('@app/nwc');
return { Component: NWCScreen };
},
},
],
},
{
@@ -198,15 +205,15 @@ const router = createBrowserRouter([
return { Component: OnboardStep2Screen };
},
},
{
path: 'step-3',
async lazy() {
const { OnboardStep3Screen } = await import('@app/auth/onboarding/step-3');
return { Component: OnboardStep3Screen };
},
},
],
},
{
path: 'complete',
async lazy() {
const { CompleteScreen } = await import('@app/auth/complete');
return { Component: CompleteScreen };
},
},
{
path: 'unlock',
async lazy() {
@@ -228,13 +235,6 @@ const router = createBrowserRouter([
return { Component: ResetScreen };
},
},
{
path: 'hard-reset',
async lazy() {
const { HardResetScreen } = await import('@app/auth/hardReset');
return { Component: HardResetScreen };
},
},
],
},
{

42
src/app/auth/complete.tsx Normal file
View File

@@ -0,0 +1,42 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
export function CompleteScreen() {
const navigate = useNavigate();
const [count, setCount] = useState(5);
useEffect(() => {
let counter: NodeJS.Timeout;
if (count > 0) {
counter = setTimeout(() => setCount(count - 1), 1000);
}
if (count === 0) {
navigate('/', { replace: true });
}
return () => {
clearTimeout(counter);
};
}, [count]);
return (
<div className="relative flex h-full w-full flex-col items-center justify-center">
<div className="mx-auto flex max-w-xl flex-col gap-1.5 text-center">
<h1 className="text-2xl font-light leading-none text-white">
<span className="font-semibold">You&apos;re ready</span>, redirecting in {count}
</h1>
<p className="text-white/70">
Thank you for using Lume. Lume doesn&apos;t use telemetry. If you encounter any
problems, please submit a report via the &quot;Report Issue&quot; button.
<br />
You can find it while using the application.
</p>
</div>
<div className="absolute bottom-6 left-1/2 flex -translate-x-1/2 transform items-center justify-center">
<img src="/lume.png" alt="lume" className="h-auto w-1/5" />
</div>
</div>
);
}

View File

@@ -1,38 +0,0 @@
import { Image } from '@shared/image';
import { useProfile } from '@utils/hooks/useProfile';
import { displayNpub } from '@utils/shortenKey';
export function User({ pubkey, fallback }: { pubkey: string; fallback?: string }) {
const { status, user } = useProfile(pubkey, fallback);
if (status === 'loading') {
return (
<div className="flex items-center gap-2">
<div className="relative h-10 w-10 shrink-0 animate-pulse rounded-md bg-white/10 backdrop-blur-xl" />
<div className="flex w-full flex-1 flex-col items-start gap-1 text-start">
<span className="h-4 w-1/2 animate-pulse rounded bg-white/10 backdrop-blur-xl" />
<span className="h-3 w-1/3 animate-pulse rounded bg-white/10 backdrop-blur-xl" />
</div>
</div>
);
}
return (
<div className="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">
<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>
);
}

View File

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

View File

@@ -1,13 +1,14 @@
import { BaseDirectory, writeTextFile } from '@tauri-apps/api/fs';
import { writeText } from '@tauri-apps/api/clipboard';
import { message, save } from '@tauri-apps/api/dialog';
import { writeTextFile } from '@tauri-apps/api/fs';
import { downloadDir } from '@tauri-apps/api/path';
import { generatePrivateKey, getPublicKey, nip19 } from 'nostr-tools';
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider';
import { Button } from '@shared/button';
import { EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
import { CopyIcon } from '@shared/icons';
import { useOnboarding } from '@stores/onboarding';
import { useStronghold } from '@stores/stronghold';
@@ -21,8 +22,8 @@ export function CreateStep1Screen() {
const setPubkey = useOnboarding((state) => state.setPubkey);
const setStep = useOnboarding((state) => state.setStep);
const [privkeyInput, setPrivkeyInput] = useState('password');
const [loading, setLoading] = useState(false);
const [copied, setCopied] = useState(false);
const [downloaded, setDownloaded] = useState(false);
const privkey = useMemo(() => generatePrivateKey(), []);
@@ -30,27 +31,39 @@ export function CreateStep1Screen() {
const npub = nip19.npubEncode(pubkey);
const nsec = nip19.nsecEncode(privkey);
// toggle private key
const showPrivateKey = () => {
if (privkeyInput === 'password') {
setPrivkeyInput('text');
} else {
setPrivkeyInput('password');
const download = async () => {
try {
const downloadPath = await downloadDir();
const fileName = `nostr_keys_${new Date().toISOString()}.txt`;
const filePath = await save({
defaultPath: downloadPath + '/' + fileName,
});
if (filePath) {
await writeTextFile(
filePath,
`Generated by Lume (lume.nu)\nPublic key: ${npub}\nPrivate key: ${nsec}`
);
setDownloaded(true);
} // else { user cancel action }
} catch (e) {
await message(e, { title: 'Cannot download account keys', type: 'error' });
}
};
const download = async () => {
await writeTextFile(
`nostr_keys_${new Date().toISOString().slice(0, 10)}.txt`,
`Generated by Lume (lume.nu)\nPublic key: ${npub}\nPrivate key: ${nsec}`,
{
dir: BaseDirectory.Download,
}
);
setDownloaded(true);
const copyPrivkey = async () => {
try {
await writeText(nsec);
setCopied(true);
setTimeout(() => setCopied(false), 3000);
} catch (e) {
await message(e, { title: 'Cannot copy private key', type: 'error' });
}
};
const submit = () => {
const submit = async () => {
setLoading(true);
// update state
@@ -59,7 +72,7 @@ export function CreateStep1Screen() {
setPubkey(pubkey);
// save to database
db.createAccount(npub, pubkey);
await db.createAccount(npub, pubkey);
// redirect to next step
navigate('/auth/create/step-2', { replace: true });
@@ -72,76 +85,68 @@ export function CreateStep1Screen() {
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-white">Save your access key!</h1>
<div className="mb-4 border-b border-white/10 pb-4">
<h1 className="mb-2 text-center text-2xl font-semibold text-white">
This is your new Nostr account
</h1>
<p className="mb-2 text-white/70">
Your private key is your password. If you lose this key, you will lose access to
your account! Copy it and keep it in a safe place. There is no way to reset your
private key.
</p>
<p className="text-white/70">
Public key is used for sharing with other people so that they can find you using
the public key.
</p>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<span className="text-base font-semibold text-white/50">Public Key</span>
<input
readOnly
value={npub}
className="relative h-11 w-full rounded-lg bg-white/10 px-3.5 py-1 text-white !outline-none backdrop-blur-xl placeholder:text-white/50"
/>
</div>
<div className="flex flex-col gap-1">
<span className="text-base font-semibold text-white/50">Private Key</span>
<div className="relative">
<div className="flex flex-col gap-8">
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<span className="font-medium text-white">Private Key</span>
<div className="relative">
<input
readOnly
value={nsec.substring(0, 5) + '**************************************'}
className="relative h-12 w-full rounded-lg border-t border-white/10 bg-white/20 py-1 pl-3.5 pr-11 text-white !outline-none backdrop-blur-xl placeholder:text-white/70"
/>
<button
type="button"
onClick={() => copyPrivkey()}
className="group absolute right-2 top-1/2 inline-flex h-7 -translate-y-1/2 transform items-center gap-1.5 rounded-md bg-white/20 px-2.5 text-sm hover:bg-white/30"
>
<CopyIcon className="h-4 w-4 text-white/70 group-hover:text-white" />
{copied ? 'Copied' : 'Copy'}
</button>
</div>
</div>
<div className="flex flex-col gap-1">
<span className="font-medium text-white">Public Key</span>
<input
readOnly
type={privkeyInput}
value={nsec}
className="relative h-11 w-full rounded-lg bg-white/10 py-1 pl-3.5 pr-11 text-white !outline-none backdrop-blur-xl placeholder:text-white/50"
value={npub}
className="relative h-12 w-full rounded-lg border-t border-white/10 bg-white/20 px-3.5 py-1 text-white !outline-none backdrop-blur-xl placeholder:text-white/70"
/>
<button
type="button"
onClick={() => showPrivateKey()}
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 backdrop-blur-xl hover:bg-white/10"
>
{privkeyInput === 'password' ? (
<EyeOffIcon className="h-4 w-4 text-white/50 group-hover:text-white" />
) : (
<EyeOnIcon className="h-4 w-4 text-white/50 group-hover:text-white" />
)}
</button>
</div>
<div className="mt-2 text-sm text-white/50">
<p>
Your private key is your password. If you lose this key, you will lose
access to your account! Copy it and keep it in a safe place. There is no way
to reset your private key.
</p>
</div>
</div>
<div className="flex flex-col gap-2">
<button
type="button"
onClick={() => submit()}
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
onClick={() => download()}
className="inline-flex h-12 w-full items-center justify-center rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
>
{loading ? (
<>
<span className="w-5" />
<span>Creating...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
</>
) : (
<>
<span className="w-5" />
<span>I have saved my key, continue</span>
<ArrowRightCircleIcon className="h-5 w-5" />
</>
)}
{downloaded ? 'Downloaded' : 'Download account keys'}
</button>
{downloaded ? (
<span className="inline-flex h-11 w-full items-center justify-center text-sm text-white/50">
Saved in Download folder
</span>
) : (
<Button preset="large-alt" onClick={() => download()}>
Download
</Button>
)}
<button
type="button"
onClick={() => submit()}
className="inline-flex h-12 w-full items-center justify-center rounded-lg border-t border-white/10 bg-white/20 px-6 font-medium leading-none text-white hover:bg-white/30 focus:outline-none"
>
{loading ? 'Creating...' : 'Continue'}
</button>
<span className="text-center text-sm text-white/50">
By clicking &apos;Continue&apos;, you are ensuring that your keys are saved in
a safe place. You cannot recover these keys if they are lost.
</span>
</div>
</div>
</div>

View File

@@ -61,7 +61,7 @@ export function CreateStep2Screen() {
setLoading(true);
if (data.password.length > 3) {
const dir = await appConfigDir();
const stronghold = await Stronghold.load(`${dir}/lume.stronghold`, data.password);
const stronghold = await Stronghold.load(`${dir}lume.stronghold`, data.password);
if (!db.secureDB) db.secureDB = stronghold;
@@ -86,10 +86,16 @@ export function CreateStep2Screen() {
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-white">
<div className="mb-4 border-b border-white/10 pb-4">
<h1 className="mb-2 text-center text-2xl font-semibold text-white">
Set password to secure your key
</h1>
<p className="text-white/70">
Password is not related to your Nostr account. It is only used to secure your
keys stored on your local machine and to unlock the app (like unlocking your
phone with a passcode). When you move to other Nostr clients, you just need to
copy your private key.
</p>
</div>
<div className="flex flex-col gap-4">
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-3">
@@ -98,12 +104,13 @@ export function CreateStep2Screen() {
<input
{...register('password', { required: true })}
type={passwordInput}
className="relative h-11 w-full rounded-lg bg-white/10 px-3.5 py-1 text-center text-white !outline-none backdrop-blur-xl placeholder:text-white/50"
placeholder="Enter password"
className="relative h-12 w-full rounded-lg border-t border-white/10 bg-white/20 px-3.5 py-1 text-center tracking-widest text-white !outline-none backdrop-blur-xl placeholder:tracking-normal placeholder:text-white/70"
/>
<button
type="button"
onClick={() => showPassword()}
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 backdrop-blur-xl hover:bg-white/10"
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 backdrop-blur-xl hover:bg-white/20"
>
{passwordInput === 'password' ? (
<EyeOffIcon className="h-4 w-4 text-white/50 group-hover:text-white" />
@@ -112,13 +119,6 @@ export function CreateStep2Screen() {
)}
</button>
</div>
<div className="text-sm text-white/50">
<p>
Password is use to secure your key store in local machine, when you move
to other clients, you just need to copy your private key as nsec or
hexstring
</p>
</div>
<span className="text-sm text-red-400">
{errors.password && <p>{errors.password.message}</p>}
</span>
@@ -127,12 +127,12 @@ export function CreateStep2Screen() {
<button
type="submit"
disabled={!isDirty || !isValid}
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg border-t border-white/10 bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
>
{loading ? (
<>
<span className="w-5" />
<span>Creating...</span>
<span>Securing your account...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
</>
) : (

View File

@@ -3,6 +3,8 @@ import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider';
import { AvatarUploader } from '@shared/avatarUploader';
import { BannerUploader } from '@shared/bannerUploader';
import { LoaderIcon } from '@shared/icons';
@@ -10,6 +12,7 @@ import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
import { Image } from '@shared/image';
import { useOnboarding } from '@stores/onboarding';
import { WidgetKinds } from '@stores/widgets';
import { useNostr } from '@utils/hooks/useNostr';
@@ -21,6 +24,7 @@ export function CreateStep3Screen() {
const [picture, setPicture] = useState('https://void.cat/d/5VKmKyuHyxrNMf9bWSVPih');
const [banner, setBanner] = useState('');
const { db } = useStorage();
const { publish } = useNostr();
const {
register,
@@ -45,6 +49,9 @@ export function CreateStep3Screen() {
tags: [],
});
// create default widget
await db.createWidget(WidgetKinds.other.learnNostr, 'Learn Nostr', '');
if (event) {
navigate('/auth/onboarding', { replace: true });
}
@@ -61,15 +68,22 @@ export function CreateStep3Screen() {
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-white">Create your profile</h1>
<div className="mb-4 border-b border-white/10 pb-4">
<h1 className="mb-2 text-center text-2xl font-semibold text-white">
Personalize your Nostr profile
</h1>
<p className="text-white/70">
Nostr profile is synchronous across all Nostr clients. If you create a profile
on Lume, it will also work well with other Nostr clients. If you update your
profile on another Nostr client, it will also sync to Lume.
</p>
</div>
<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 backdrop-blur-xl">
<div className="relative h-36 w-full bg-white/10 backdrop-blur-xl">
{banner ? (
<Image
src={banner}
@@ -77,18 +91,18 @@ export function CreateStep3Screen() {
className="h-full w-full object-cover"
/>
) : (
<div className="h-full w-full bg-black/50" />
<div className="h-full w-full bg-white/20" />
)}
<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">
<div className="relative z-10 -mt-8 h-16 w-16">
<Image
src={picture}
alt="user's avatar"
className="h-14 w-14 rounded-lg object-cover ring-2 ring-white/10"
className="h-16 w-16 rounded-lg object-cover ring-2 ring-white/20"
/>
<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} />
@@ -98,55 +112,45 @@ export function CreateStep3Screen() {
</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"
>
<label htmlFor="name" className="font-medium text-white">
Name *
</label>
<input
type={'text'}
{...register('name', {
required: true,
minLength: 4,
minLength: 1,
})}
spellCheck={false}
className="relative h-11 w-full rounded-lg bg-white/10 px-3 py-1 text-white !outline-none backdrop-blur-xl placeholder:text-white/50"
className="relative h-12 w-full rounded-lg bg-white/20 px-3 py-1 text-white !outline-none backdrop-blur-xl placeholder:text-white/70"
/>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="about"
className="text-sm font-semibold uppercase tracking-wider text-white/50"
>
<label htmlFor="about" className="font-medium text-white">
Bio
</label>
<textarea
{...register('about')}
spellCheck={false}
className="relative h-20 w-full resize-none rounded-lg bg-white/10 px-3 py-1 text-white !outline-none backdrop-blur-xl placeholder:text-white/50"
className="relative h-20 w-full resize-none rounded-lg bg-white/20 px-3 py-2 text-white !outline-none backdrop-blur-xl placeholder:text-white/70"
/>
</div>
<div className="flex flex-col gap-1">
<label
htmlFor="website"
className="text-sm font-semibold uppercase tracking-wider text-white/50"
>
<label htmlFor="website" className="font-medium text-white">
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 text-white !outline-none backdrop-blur-xl placeholder:text-white/50"
className="relative h-12 w-full rounded-lg bg-white/20 px-3 py-1 text-white !outline-none backdrop-blur-xl placeholder:text-white/70"
/>
</div>
<button
type="submit"
disabled={!isDirty || !isValid}
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg border-t border-white/10 bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
>
{loading ? (
<>

View File

@@ -1,7 +0,0 @@
export function HardResetScreen() {
return (
<div>
<p>hard reset</p>
</div>
);
}

View File

@@ -5,7 +5,7 @@ import { useNavigate } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider';
import { LoaderIcon } from '@shared/icons';
import { EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
import { ArrowRightCircleIcon } from '@shared/icons/arrowRightCircle';
import { useOnboarding } from '@stores/onboarding';
@@ -37,6 +37,7 @@ export function ImportStep1Screen() {
const setStep = useOnboarding((state) => state.setStep);
const [loading, setLoading] = useState(false);
const [passwordInput, setPasswordInput] = useState('password');
const { db } = useStorage();
const {
@@ -64,12 +65,13 @@ export function ImportStep1Screen() {
setPubkey(pubkey);
// add account to local database
db.createAccount(npub, pubkey);
await db.createAccount(npub, pubkey);
// redirect to step 2
navigate('/auth/import/step-2', { replace: true });
// redirect to step 2 with delay 1.2s
setTimeout(() => navigate('/auth/import/step-2', { replace: true }), 1200);
}
} catch (error) {
setLoading(false);
setError('privkey', {
type: 'custom',
message: 'Private key is invalid, please check again',
@@ -77,6 +79,15 @@ export function ImportStep1Screen() {
}
};
// toggle private key
const showPassword = () => {
if (passwordInput === 'password') {
setPasswordInput('text');
} else {
setPasswordInput('password');
}
};
useEffect(() => {
// save current step, if user close app and reopen it
setStep('/auth/import');
@@ -84,20 +95,37 @@ export function ImportStep1Screen() {
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-white">Import your key</h1>
<div className="mb-4 pb-4">
<h1 className="text-center text-2xl font-semibold text-white">
Import your Nostr key
</h1>
</div>
<div className="flex flex-col gap-4">
<form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col gap-3">
<div className="flex flex-col gap-1">
<span className="text-base font-semibold text-white/50">Private key</span>
<input
{...register('privkey', { required: true, minLength: 32 })}
type={'password'}
placeholder="nsec or hexstring"
className="relative 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">
<label htmlFor="privkey" className="font-medium text-white">
Insert your nostr private key, in nsec or hex format
</label>
<div className="relative">
<input
{...register('privkey', { required: true, minLength: 32 })}
type={passwordInput}
placeholder="nsec1..."
className="relative h-12 w-full rounded-lg border-t border-white/10 bg-white/20 px-3 py-1 text-white backdrop-blur-xl placeholder:text-white/70 focus:outline-none"
/>
<button
type="button"
onClick={() => showPassword()}
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 backdrop-blur-xl hover:bg-white/20"
>
{passwordInput === 'password' ? (
<EyeOffIcon className="h-4 w-4 text-white/50 group-hover:text-white" />
) : (
<EyeOnIcon className="h-4 w-4 text-white/50 group-hover:text-white" />
)}
</button>
</div>
<span className="text-sm text-red-500">
{errors.privkey && <p>{errors.privkey.message}</p>}
</span>
</div>
@@ -105,12 +133,12 @@ export function ImportStep1Screen() {
<button
type="submit"
disabled={!isDirty || !isValid}
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
>
{loading ? (
<>
<span className="w-5" />
<span>Creating...</span>
<span>Importing...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
</>
) : (

View File

@@ -61,7 +61,7 @@ export function ImportStep2Screen() {
setLoading(true);
if (data.password.length > 3) {
const dir = await appConfigDir();
const stronghold = await Stronghold.load(`${dir}/lume.stronghold`, data.password);
const stronghold = await Stronghold.load(`${dir}lume.stronghold`, data.password);
if (!db.secureDB) db.secureDB = stronghold;
@@ -86,10 +86,16 @@ export function ImportStep2Screen() {
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-white">
<div className="mb-4 border-b border-white/10 pb-4">
<h1 className="mb-2 text-center text-2xl font-semibold text-white">
Set password to secure your key
</h1>
<p className="text-white/70">
Password is not related to your Nostr account. It is only used to secure your
keys stored on your local machine and to unlock the app (like unlocking your
phone with a passcode). When you move to other Nostr clients, you only need to
copy your private key.
</p>
</div>
<div className="flex flex-col gap-4">
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-3">
@@ -98,12 +104,13 @@ export function ImportStep2Screen() {
<input
{...register('password', { required: true })}
type={passwordInput}
className="relative h-11 w-full rounded-lg bg-white/10 px-3.5 py-1 text-center text-white !outline-none backdrop-blur-xl placeholder:text-white/50"
placeholder="Enter password"
className="relative h-12 w-full rounded-lg border-t border-white/10 bg-white/20 px-3.5 py-1 text-center tracking-widest text-white !outline-none backdrop-blur-xl placeholder:tracking-normal placeholder:text-white/70"
/>
<button
type="button"
onClick={() => showPassword()}
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 backdrop-blur-xl hover:bg-white/10"
className="group absolute right-2 top-1/2 -translate-y-1/2 transform rounded p-1 backdrop-blur-xl hover:bg-white/20"
>
{passwordInput === 'password' ? (
<EyeOffIcon className="h-4 w-4 text-white/50 group-hover:text-white" />
@@ -112,11 +119,6 @@ export function ImportStep2Screen() {
)}
</button>
</div>
<p className="text-sm text-white/50">
Password is use to unlock app and secure your key store in local machine.
When you move to other clients, you just need to copy your private key as
nsec or hexstring
</p>
<span className="text-sm text-red-400">
{errors.password && <p>{errors.password.message}</p>}
</span>
@@ -125,12 +127,12 @@ export function ImportStep2Screen() {
<button
type="submit"
disabled={!isDirty || !isValid}
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg border-t border-white/10 bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
>
{loading ? (
<>
<span className="w-5" />
<span>Creating...</span>
<span>Securing your account...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
</>
) : (

View File

@@ -1,13 +1,13 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { User } from '@app/auth/components/user';
import { useStorage } from '@libs/storage/provider';
import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
import { User } from '@shared/user';
import { useOnboarding } from '@stores/onboarding';
import { WidgetKinds } from '@stores/widgets';
import { useNostr } from '@utils/hooks/useNostr';
@@ -15,11 +15,11 @@ export function ImportStep3Screen() {
const navigate = useNavigate();
const setStep = useOnboarding((state) => state.setStep);
const [loading, setLoading] = useState(false);
const { db } = useStorage();
const { fetchUserData, prefetchEvents } = useNostr();
const [loading, setLoading] = useState(false);
const submit = async () => {
try {
// show loading indicator
@@ -29,6 +29,9 @@ export function ImportStep3Screen() {
const user = await fetchUserData();
const data = await prefetchEvents();
// create default widget
await db.createWidget(WidgetKinds.other.learnNostr, 'Learn Nostr', '');
// redirect to next step
if (user.status === 'ok' && data.status === 'ok') {
navigate('/auth/onboarding/step-2', { replace: true });
@@ -49,17 +52,19 @@ export function ImportStep3Screen() {
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold">
{loading ? 'Prefetching data...' : 'Continue with'}
<div className="mb-4 pb-4">
<h1 className="text-center text-2xl font-semibold text-white">
{loading ? 'Downloading...' : 'Your Nostr profile'}
</h1>
</div>
<div className="w-full rounded-xl bg-white/10 p-4 backdrop-blur-xl">
<div className="flex flex-col gap-3">
<User pubkey={db.account.pubkey} />
<div className="flex flex-col gap-3">
<div className="rounded-lg border-t border-white/10 bg-white/20 px-3 py-3">
<User pubkey={db.account.pubkey} variant="simple" />
</div>
<div className="flex flex-col gap-2">
<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"
className="inline-flex h-12 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 ? (
@@ -76,6 +81,10 @@ export function ImportStep3Screen() {
</>
)}
</button>
<span className="text-center text-sm text-white/50">
By clicking &apos;Continue&apos;, Lume will download your old relay list and
all events from the last 24 hours. It may take a bit
</span>
</div>
</div>
</div>

View File

@@ -2,11 +2,10 @@ import { useQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { User } from '@app/auth/components/user';
import { useStorage } from '@libs/storage/provider';
import { ArrowRightCircleIcon, CheckCircleIcon, LoaderIcon } from '@shared/icons';
import { User } from '@shared/user';
import { useOnboarding } from '@stores/onboarding';
@@ -19,7 +18,7 @@ export function OnboardStep1Screen() {
const { publish, fetchUserData, prefetchEvents } = useNostr();
const { db } = useStorage();
const { status, data } = useQuery(['trending-profiles'], async () => {
const { status, data } = useQuery(['trending-profiles-widget'], async () => {
const res = await fetch('https://api.nostr.band/v0/trending/profiles');
if (!res.ok) {
throw new Error('Error');
@@ -68,45 +67,51 @@ export function OnboardStep1Screen() {
}, []);
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-white">
<div className="flex h-full w-full flex-col justify-center">
<div className="mx-auto mb-4 w-full max-w-md border-b border-white/10 pb-4">
<h1 className="mb-2 text-center text-2xl font-semibold text-white">
{loading ? 'Prefetching data...' : 'Enrich your network'}
</h1>
<p className="text-sm text-white/50">Choose account you want to follow</p>
<p className="text-white/70">
Choose the account you want to follow. These accounts are trending in the last
24 hours. If none of the accounts interest you, you can explore more options and
add them later.
</p>
</div>
<div className="flex flex-col gap-4">
<div className="scrollbar-hide flex h-[500px] w-full flex-col overflow-y-auto rounded-xl bg-white/10 py-2 backdrop-blur-xl">
{status === 'loading' ? (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-4 w-4 animate-spin text-white" />
</div>
) : (
data?.profiles.map(
(item: { pubkey: string; profile: { content: string } }) => (
<button
key={item.pubkey}
type="button"
onClick={() => toggleFollow(item.pubkey)}
className="inline-flex transform items-center justify-between px-4 py-2 hover:bg-white/20"
>
<User pubkey={item.pubkey} fallback={item.profile?.content} />
{follows.includes(item.pubkey) && (
<div>
<CheckCircleIcon className="h-4 w-4 text-green-400" />
</div>
)}
</button>
)
)
)}
</div>
<div className="scrollbar-hide flex w-full flex-nowrap items-center gap-4 overflow-x-auto px-4">
{status === 'loading' ? (
<div className="flex h-full w-full items-center justify-center">
<LoaderIcon className="h-4 w-4 animate-spin text-white" />
</div>
) : (
data?.profiles.map((item: { pubkey: string; profile: { content: string } }) => (
<button
key={item.pubkey}
type="button"
onClick={() => toggleFollow(item.pubkey)}
className="relative h-[300px] shrink-0 grow-0 basis-[250px] rounded-lg border-t border-white/10 bg-white/20 px-4 py-4 hover:bg-white/30"
>
<User
pubkey={item.pubkey}
variant="large"
embedProfile={item.profile?.content}
/>
{follows.includes(item.pubkey) && (
<div className="absolute right-2 top-2">
<CheckCircleIcon className="h-4 w-4 text-green-400" />
</div>
)}
</button>
))
)}
</div>
<div className="mx-auto mt-4 w-full max-w-md">
<div className="flex flex-col gap-2">
<button
type="button"
onClick={submit}
disabled={loading || follows.length === 0}
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none disabled:opacity-50"
className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg border-t border-white/10 bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none disabled:opacity-50"
>
{loading ? (
<>
@@ -122,12 +127,19 @@ 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 backdrop-blur-xl hover:bg-white/10 focus:outline-none"
>
Skip, you can add later
</Link>
{!loading ? (
<Link
to="/auth/onboarding/step-2"
className="inline-flex h-12 w-full items-center justify-center rounded-lg border-t border-white/10 bg-white/20 px-6 font-medium leading-none text-white backdrop-blur-xl hover:bg-white/30 focus:outline-none"
>
Skip, you can add later
</Link>
) : (
<span className="text-center text-sm text-white/50">
By clicking &apos;Continue&apos;, Lume will download all events related to
your follows from the last 24 hours. It may take a bit
</span>
)}
</div>
</div>
</div>

View File

@@ -1,6 +1,6 @@
import { message } from '@tauri-apps/api/dialog';
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider';
@@ -12,6 +12,7 @@ import { WidgetKinds } from '@stores/widgets';
const data = [
{ hashtag: '#bitcoin' },
{ hashtag: '#nostr' },
{ hashtag: '#nostrdesign' },
{ hashtag: '#zap' },
{ hashtag: '#LFG' },
{ hashtag: '#zapchain' },
@@ -20,6 +21,10 @@ const data = [
{ hashtag: '#hodl' },
{ hashtag: '#stacksats' },
{ hashtag: '#nokyc' },
{ hashtag: '#meme' },
{ hashtag: '#memes' },
{ hashtag: '#memestr' },
{ hashtag: '#penisbutter' },
{ hashtag: '#anime' },
{ hashtag: '#waifu' },
{ hashtag: '#manga' },
@@ -29,8 +34,8 @@ const data = [
export function OnboardStep2Screen() {
const navigate = useNavigate();
const setStep = useOnboarding((state) => state.setStep);
const [setStep, clearStep] = useOnboarding((state) => [state.setStep, state.clearStep]);
const [loading, setLoading] = useState(false);
const [tags, setTags] = useState(new Set<string>());
@@ -48,6 +53,16 @@ export function OnboardStep2Screen() {
}
};
const skip = async () => {
// update last login
await db.updateLastLogin();
// clear local storage
clearStep();
navigate('/auth/complete', { replace: true });
};
const submit = async () => {
try {
setLoading(true);
@@ -56,9 +71,16 @@ export function OnboardStep2Screen() {
await db.createWidget(WidgetKinds.global.hashtag, tag, tag.replace('#', ''));
}
navigate('/auth/onboarding/step-3', { replace: true });
// update last login
await db.updateLastLogin();
// clear local storage
clearStep();
navigate('/auth/complete', { replace: true });
} catch (e) {
await message(e, { title: 'Error', type: 'error' });
setLoading(false);
await message(e, { title: 'Lume', type: 'error' });
}
};
@@ -69,20 +91,23 @@ export function OnboardStep2Screen() {
return (
<div className="mx-auto w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-white">
Choose {tags.size}/3 your favorite tags
<div className="mb-4 border-b border-white/10 pb-4">
<h1 className="mb-2 text-center text-2xl font-semibold text-white">
Choose {tags.size}/3 your favorite hashtags
</h1>
<p className="text-sm text-white/50">Customize your space which hashtag widget</p>
<p className="text-white/70">
Hashtags are an easy way to discover more content. By adding a hashtag, Lume
will show all related posts. You can always add more later.
</p>
</div>
<div className="flex flex-col gap-4">
<div className="scrollbar-hide flex h-[500px] w-full flex-col overflow-y-auto rounded-xl bg-white/10 backdrop-blur-xl">
<div className="scrollbar-hide flex h-[450px] w-full flex-col divide-y divide-white/5 overflow-y-auto rounded-xl bg-white/20 backdrop-blur-xl">
{data.map((item: { hashtag: string }) => (
<button
key={item.hashtag}
type="button"
onClick={() => toggleTag(item.hashtag)}
className="inline-flex transform items-center justify-between bg-white/10 px-4 py-2 backdrop-blur-xl hover:bg-white/20"
className="inline-flex transform items-center justify-between px-4 py-2 hover:bg-white/10"
>
<p className="text-white">{item.hashtag}</p>
{tags.has(item.hashtag) && (
@@ -98,7 +123,7 @@ export function OnboardStep2Screen() {
type="button"
onClick={submit}
disabled={loading || tags.size === 0 || tags.size > 3}
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none disabled:opacity-50"
className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg border-t border-white/10 bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none disabled:opacity-50"
>
{loading ? (
<>
@@ -114,12 +139,15 @@ 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 backdrop-blur-xl hover:bg-white/10 focus:outline-none"
>
Skip, you can add later
</Link>
{!loading ? (
<button
type="button"
onClick={() => skip()}
className="inline-flex h-12 w-full items-center justify-center rounded-lg border-t border-white/10 bg-white/20 font-medium leading-none text-white backdrop-blur-xl hover:bg-white/30 focus:outline-none"
>
Skip, you can add later
</button>
) : null}
</div>
</div>
</div>

View File

@@ -2,12 +2,11 @@ import { useQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { UserRelay } from '@app/auth/components/userRelay';
import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
import { ArrowRightCircleIcon, CheckCircleIcon, LoaderIcon } from '@shared/icons';
import { User } from '@shared/user';
import { FULL_RELAYS } from '@stores/constants';
import { useOnboarding } from '@stores/onboarding';
@@ -136,7 +135,7 @@ export function OnboardStep3Screen() {
>
<div className="flex flex-col items-start gap-1">
<p className="max-w-[15rem] truncate">{item.replace(/\/+$/, '')}</p>
<UserRelay pubkey={data.get(item)} />
<User pubkey={data.get(item)} variant="mention" />
</div>
{relays.has(item) && (
<div className="pt-1.5">

View File

@@ -116,28 +116,28 @@ export function ResetScreen() {
</div>
<form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label htmlFor="privkey" className="font-medium text-white/50">
<label htmlFor="privkey" className="font-medium text-white">
Private key
</label>
<div className="relative">
<input
{...register('privkey', { required: true })}
type="text"
placeholder="nsec..."
className="relative h-12 w-full rounded-lg bg-white/10 px-3.5 py-1 text-white !outline-none backdrop-blur-xl placeholder:text-white/50"
placeholder="nsec1..."
className="relative h-12 w-full rounded-lg border-t border-white/10 bg-white/20 px-3.5 py-1 text-white !outline-none backdrop-blur-xl placeholder:text-white/70"
/>
</div>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="password" className="font-medium text-white/50">
<label htmlFor="password" className="font-medium text-white">
Set a new password to protect your key
</label>
<div className="relative">
<input
{...register('password', { required: true })}
type={passwordInput}
placeholder="min. 4 characters"
className="relative h-12 w-full rounded-lg bg-white/10 px-3.5 py-1 text-white !outline-none backdrop-blur-xl placeholder:text-white/50"
placeholder="Min. 4 characters"
className="relative h-12 w-full rounded-lg border-t border-white/10 bg-white/20 px-3.5 py-1 text-white !outline-none backdrop-blur-xl placeholder:text-white/70"
/>
<button
type="button"
@@ -169,7 +169,7 @@ export function ResetScreen() {
</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"
className="mt-1 inline-flex h-12 w-full items-center justify-center rounded-lg text-center text-white/70 hover:bg-white/20"
>
Back
</Link>

View File

@@ -4,11 +4,10 @@ import { Resolver, useForm } from 'react-hook-form';
import { Link, useNavigate } from 'react-router-dom';
import { Stronghold } from 'tauri-plugin-stronghold-api';
import { User } from '@app/auth/components/user';
import { useStorage } from '@libs/storage/provider';
import { ArrowRightCircleIcon, EyeOffIcon, EyeOnIcon, LoaderIcon } from '@shared/icons';
import { User } from '@shared/user';
import { useStronghold } from '@stores/stronghold';
@@ -33,6 +32,7 @@ const resolver: Resolver<FormValues> = async (values) => {
export function UnlockScreen() {
const navigate = useNavigate();
const setPrivkey = useStronghold((state) => state.setPrivkey);
const setWalletConnectURL = useStronghold((state) => state.setWalletConnectURL);
const [showPassword, setShowPassword] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
@@ -55,8 +55,10 @@ export function UnlockScreen() {
if (!db.secureDB) db.secureDB = stronghold;
const privkey = await db.secureLoad(db.account.pubkey);
const uri = await db.secureLoad('walletConnectURL', 'nwc');
setPrivkey(privkey);
if (privkey) setPrivkey(privkey);
if (uri) setWalletConnectURL(uri);
// redirect to home
navigate('/', { replace: true });
} catch (e) {
@@ -71,20 +73,22 @@ export function UnlockScreen() {
return (
<div className="flex h-full w-full items-center justify-center">
<div className="mx-auto w-full max-w-md">
<div className="mb-6 text-center">
<h1 className="text-2xl font-semibold text-white">Enter password to unlock</h1>
<div className="mb-4 pb-4">
<h1 className="text-center text-2xl font-semibold text-white">
Enter password to unlock
</h1>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="mb-0 flex flex-col">
<div className="flex flex-col rounded-lg bg-white/5">
<div className="w-full rounded-t-lg border-b border-white/10 bg-white/5 p-4">
<User pubkey={db.account.pubkey} />
<User pubkey={db.account.pubkey} variant="simple" />
</div>
<div className="relative">
<input
{...register('password', { required: true, minLength: 4 })}
type={showPassword ? 'text' : 'password'}
placeholder="Password"
className="relative h-12 w-full rounded-b-lg bg-white/10 py-1 text-center text-white !outline-none backdrop-blur-xl placeholder:text-white/50"
className="relative h-12 w-full rounded-b-lg bg-white/10 py-1 text-center tracking-widest text-white !outline-none backdrop-blur-xl placeholder:tracking-normal placeholder:text-white/50"
/>
<button
type="button"
@@ -106,12 +110,12 @@ export function UnlockScreen() {
<button
type="submit"
disabled={!isDirty || !isValid}
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none disabled:opacity-50"
className="inline-flex h-12 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none disabled:opacity-50"
>
{loading ? (
<>
<span className="w-5" />
<span>Decryting...</span>
<span>Unlocking...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
</>
) : (
@@ -124,7 +128,7 @@ export function UnlockScreen() {
</button>
<Link
to="/auth/reset"
className="mt-1 inline-flex h-11 w-full items-center justify-center rounded-lg text-center text-white/50 hover:bg-white/10"
className="mt-1 inline-flex h-12 w-full items-center justify-center rounded-lg text-center text-white/70 hover:bg-white/20"
>
Reset password
</Link>

View File

@@ -2,7 +2,6 @@ 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() {
@@ -29,7 +28,7 @@ export function WelcomeScreen() {
}, []);
return (
<Frame className="flex h-screen w-full flex-col justify-between">
<div className="flex h-screen w-full flex-col justify-between">
<div className="flex flex-col gap-10 pt-16">
<div className="flex flex-col gap-1.5 text-center">
<h1 className="text-3xl font-semibold text-white">Welcome to Lume</h1>
@@ -38,10 +37,10 @@ export function WelcomeScreen() {
Nostr
</p>
</div>
<div className="inline-flex w-full flex-col items-center gap-2 px-4 pb-10">
<div className="inline-flex w-full flex-col items-center gap-3 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"
className="inline-flex h-12 w-3/4 items-center justify-between gap-2 rounded-lg border-t border-white/10 bg-fuchsia-500 px-4 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none"
>
<span className="w-5" />
<span>Login with private key</span>
@@ -49,15 +48,15 @@ export function WelcomeScreen() {
</Link>
<Link
to="/auth/create"
className="inline-flex h-11 w-2/3 items-center justify-center gap-2 rounded-lg bg-white/10 px-6 font-medium leading-none text-white backdrop-blur-xl hover:bg-white/20 focus:outline-none"
className="inline-flex h-12 w-3/4 items-center justify-center gap-2 rounded-lg border-t border-white/10 bg-white/20 font-medium leading-none text-white backdrop-blur-xl hover:bg-white/30 focus:outline-none"
>
Create new key
</Link>
</div>
</div>
<div className="flex flex-1 items-end justify-center pb-10">
<img src="/lume.png" alt="lume" className="h-auto w-1/3" />
<div className="flex flex-1 items-end justify-center pb-6">
<img src="/lume.png" alt="lume" className="h-auto w-1/4" />
</div>
</Frame>
</div>
);
}

View File

@@ -27,7 +27,7 @@ export function ChatsListItem({ pubkey }: { pubkey: string }) {
'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'
: 'border-transparent text-white/70'
)
}
>

View File

@@ -22,7 +22,7 @@ export function ChatMessageItem({
return (
<div className="flex h-min min-h-min w-full select-text flex-col px-5 py-3 backdrop-blur-xl hover:bg-white/10">
<div className="flex flex-col">
<User pubkey={message.pubkey} time={message.created_at} isChat={true} />
<User pubkey={message.pubkey} time={message.created_at} variant="chat" />
<div className="-mt-[20px] pl-[49px]">
<p className="select-text whitespace-pre-line break-words text-base text-white">
{message.content}

View File

@@ -3,14 +3,14 @@ import { Dispatch, SetStateAction, useState } from 'react';
import { LoaderIcon, MediaIcon } from '@shared/icons';
import { useImageUploader } from '@utils/hooks/useUploader';
import { useNostr } from '@utils/hooks/useNostr';
export function MediaUploader({
setState,
}: {
setState: Dispatch<SetStateAction<string>>;
}) {
const upload = useImageUploader();
const { upload } = useNostr();
const [loading, setLoading] = useState(false);
const uploadMedia = async () => {

View File

@@ -2,11 +2,10 @@ import * as Dialog from '@radix-ui/react-dialog';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { User } from '@app/auth/components/user';
import { useStorage } from '@libs/storage/provider';
import { CancelIcon, PlusIcon } from '@shared/icons';
import { User } from '@shared/user';
export function NewMessageModal() {
const navigate = useNavigate();
@@ -44,7 +43,7 @@ export function NewMessageModal() {
<Dialog.Title className="text-lg font-semibold leading-none text-white">
New chat
</Dialog.Title>
<Dialog.Close className="inline-flex h-6 w-6 items-center justify-center rounded-md backdrop-blur-xl hover:bg-white/10">
<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>
@@ -54,17 +53,17 @@ export function NewMessageModal() {
</div>
</div>
<div className="flex h-[500px] flex-col overflow-y-auto overflow-x-hidden pb-2 pt-2">
{db.account?.follows?.map((follow) => (
{db.account?.follows?.map((pubkey) => (
<div
key={follow}
className="group flex items-center justify-between px-4 py-2 backdrop-blur-xl hover:bg-white/10"
key={pubkey}
className="group flex items-center justify-between px-4 py-2 hover:bg-white/10"
>
<User pubkey={follow} />
<User pubkey={pubkey} variant="simple" />
<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"
onClick={() => openChat(pubkey)}
className="hidden w-max rounded bg-white/10 px-3 py-1 text-sm font-medium hover:bg-fuchsia-500 group-hover:inline-flex"
>
Chat
</button>

View File

@@ -2,9 +2,8 @@ import * as Dialog from '@radix-ui/react-dialog';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { User } from '@app/auth/components/user';
import { CancelIcon, StrangersIcon } from '@shared/icons';
import { User } from '@shared/user';
import { compactNumber } from '@utils/number';
@@ -44,7 +43,7 @@ export function UnknownsModal({ data }: { data: string[] }) {
<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 backdrop-blur-xl hover:bg-white/10">
<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>
@@ -57,14 +56,14 @@ export function UnknownsModal({ data }: { data: string[] }) {
{data.map((pubkey) => (
<div
key={pubkey}
className="group flex items-center justify-between px-4 py-2 backdrop-blur-xl hover:bg-white/10"
className="group flex items-center justify-between px-4 py-2 hover:bg-white/10"
>
<User pubkey={pubkey} />
<User pubkey={pubkey} variant="simple" />
<div>
<button
type="button"
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"
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>

View File

@@ -1,7 +1,7 @@
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 { AddressPointer, EventPointer } from 'nostr-tools/lib/nip19';
import { useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
@@ -13,11 +13,11 @@ import {
NoteActions,
NoteReplyForm,
NoteStats,
ThreadUser,
UnknownNote,
} from '@shared/notes';
import { RepliesList } from '@shared/notes/replies/list';
import { NoteSkeleton } from '@shared/notes/skeleton';
import { User } from '@shared/user';
import { useEvent } from '@utils/hooks/useEvent';
@@ -27,13 +27,15 @@ export function ArticleNoteScreen() {
const { id } = useParams();
const { db } = useStorage();
const { status, data } = useEvent(id);
const naddr = id.startsWith('naddr') ? (nip19.decode(id).data as AddressPointer) : null;
const { status, data } = useEvent(id, naddr);
const [isCopy, setIsCopy] = useState(false);
const share = async () => {
await writeText(
'https://nostr.com/' +
'https://njump.me/' +
nip19.neventEncode({ id: data.id, author: data.pubkey } as EventPointer)
);
// update state
@@ -98,21 +100,27 @@ export function ArticleNoteScreen() {
</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 className="h-min w-full px-3">
<div className="rounded-xl border-t border-white/10 bg-white/20 px-3 pt-3">
<User pubkey={data.pubkey} time={data.created_at} variant="thread" />
<div className="mt-2">{renderKind(data)}</div>
<div>
<NoteActions
id={data.id}
pubkey={data.pubkey}
extraButtons={false}
/>
<NoteStats id={data.id} />
</div>
</div>
</div>
</div>
<div ref={replyRef} className="px-3">
<NoteReplyForm id={data.id} pubkey={db.account.pubkey} />
<RepliesList id={data.id} />
</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>

View File

@@ -15,11 +15,11 @@ import {
NoteReplyForm,
NoteStats,
TextNote,
ThreadUser,
UnknownNote,
} from '@shared/notes';
import { RepliesList } from '@shared/notes/replies/list';
import { NoteSkeleton } from '@shared/notes/skeleton';
import { User } from '@shared/user';
import { useEvent } from '@utils/hooks/useEvent';
@@ -35,7 +35,7 @@ export function TextNoteScreen() {
const share = async () => {
await writeText(
'https://nostr.com/' +
'https://njump.me/' +
nip19.neventEncode({ id: data.id, author: data.pubkey } as EventPointer)
);
// update state
@@ -106,7 +106,7 @@ export function TextNoteScreen() {
) : (
<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} />
<User pubkey={data.pubkey} time={data.created_at} variant="thread" />
<div className="mt-2">{renderKind(data)}</div>
<div>
<NoteActions id={id} pubkey={data.pubkey} extraButtons={false} />

View File

@@ -48,7 +48,7 @@ export const SimpleNote = memo(function SimpleNote({ id }: { id: string }) {
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" />
<User pubkey={data.pubkey} time={data.created_at} variant="mention" />
<div className="markdown">
<p>
{data.content.length > 200

View File

@@ -5,6 +5,8 @@ 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';
@@ -13,7 +15,9 @@ import { useActivities } from '@stores/activities';
import { useNostr } from '@utils/hooks/useNostr';
export function NotificationScreen() {
const { db } = useStorage();
const { fetchActivities } = useNostr();
const [activities, setActivities, clearTotalNewActivities] = useActivities((state) => [
state.activities,
state.setActivities,
@@ -39,12 +43,13 @@ export function NotificationScreen() {
useEffect(() => {
async function getActivities() {
const events = await fetchActivities();
setActivities(events);
// clear total new activities
clearTotalNewActivities();
setActivities(events, db.account.last_login_at);
}
getActivities();
// clear total new activities
clearTotalNewActivities();
}, []);
return (
@@ -60,7 +65,7 @@ export function NotificationScreen() {
<p className="text-sm font-medium text-white/50">Loading</p>
</div>
</div>
) : activities.length < 1 ? (
) : activities.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">

View File

@@ -0,0 +1,152 @@
import { webln } from '@getalby/sdk';
import * as Dialog from '@radix-ui/react-dialog';
import { message } from '@tauri-apps/api/dialog';
import { WebviewWindow } from '@tauri-apps/api/window';
import { useState } from 'react';
import { useStorage } from '@libs/storage/provider';
import {
AlbyIcon,
ArrowRightCircleIcon,
CancelIcon,
CheckCircleIcon,
LoaderIcon,
} from '@shared/icons';
import { useStronghold } from '@stores/stronghold';
export function NWCAlby() {
const { db } = useStorage();
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsloading] = useState(false);
const [isConnected, setIsConnected] = useState(false);
const setWalletConnectURL = useStronghold((state) => state.setWalletConnectURL);
const initAlby = async () => {
try {
setIsloading(true);
const provider = webln.NostrWebLNProvider.withNewSecret();
const walletConnectURL = provider.getNostrWalletConnectUrl(true);
// get auth url
const authURL = provider.getAuthorizationUrl({ name: 'Lume' });
// open auth window
const webview = new WebviewWindow('alby', {
title: 'Connect Alby',
url: authURL.href,
center: true,
width: 400,
height: 650,
});
webview.listen('tauri://close-requested', async () => {
await db.secureSave('walletConnectURL', walletConnectURL, 'nwc');
setWalletConnectURL(walletConnectURL);
setIsConnected(true);
setIsloading(false);
});
} catch (e) {
setIsloading(false);
await message(e.toString(), { title: 'Connect Alby', type: 'error' });
}
};
return (
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
<div className="flex items-center justify-between">
<div className="inline-flex items-center gap-2.5">
<div className="inline-flex h-11 w-11 items-center justify-center rounded-md bg-orange-200">
<AlbyIcon className="h-8 w-8" />
</div>
<div>
<h5 className="font-semibold leading-tight text-white">Alby</h5>
<p className="text-sm leading-tight text-white/50">Require alby account</p>
</div>
</div>
<Dialog.Trigger asChild>
<button
type="button"
className="inline-flex h-9 w-min items-center justify-center rounded-md border-t border-white/10 bg-white/20 px-3 text-sm font-medium text-white hover:bg-green-500"
>
Connect
</button>
</Dialog.Trigger>
</div>
<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 rounded-t-xl 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">
Alby integration (Beta)
</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>
<div className="flex flex-col gap-3 px-5 py-5">
<div className="relative flex h-40 items-center justify-center gap-4">
<div className="inline-flex h-16 w-16 items-end justify-center rounded-lg bg-black pb-2">
<img src="/lume.png" className="w-1/3" alt="Lume Logo" />
</div>
<div className="w-20 border border-dashed border-white/5" />
<div className="inline-flex h-16 w-16 items-center justify-center rounded-lg bg-white">
<AlbyIcon className="h-8 w-8" />
</div>
{isConnected ? (
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform">
<CheckCircleIcon className="h-5 w-5 text-green-500" />
</div>
) : null}
</div>
<div className="flex flex-col gap-2">
<p className="text-sm text-white/50">
When you click &quot;Connect&quot;, a new window will open and you need
to click the &quot;Connect Wallet&quot; button to grant Lume permission
to integrate with your Alby account.
</p>
<p className="text-sm text-white/50">
All information will be encrypted and stored on the local machine.
</p>
</div>
<button
type="button"
onClick={() => initAlby()}
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"
>
{isLoading ? (
<>
<span className="w-5" />
<span>Connecting...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
</>
) : isConnected ? (
<>
<span className="w-5" />
<span>Connected</span>
<CheckCircleIcon className="h-5 w-5" />
</>
) : (
<>
<span className="w-5" />
<span>Connect</span>
<ArrowRightCircleIcon className="h-5 w-5" />
</>
)}
</button>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -0,0 +1,168 @@
import * as Dialog from '@radix-ui/react-dialog';
import { useState } from 'react';
import { Resolver, useForm } from 'react-hook-form';
import { useStorage } from '@libs/storage/provider';
import { ArrowRightCircleIcon, CancelIcon, LoaderIcon, WorldIcon } from '@shared/icons';
import { useStronghold } from '@stores/stronghold';
type FormValues = {
uri: string;
};
const resolver: Resolver<FormValues> = async (values) => {
return {
values: values.uri ? values : {},
errors: !values.uri
? {
uri: {
type: 'required',
message: 'This is required.',
},
}
: {},
};
};
export function NWCOther() {
const { db } = useStorage();
const {
register,
setError,
handleSubmit,
formState: { errors, isDirty, isValid },
} = useForm<FormValues>({ resolver });
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsloading] = useState(false);
const setWalletConnectURL = useStronghold((state) => state.setWalletConnectURL);
const onSubmit = async (data: { [x: string]: string }) => {
try {
if (!data.uri.startsWith('nostr+walletconnect:')) {
setError('uri', {
type: 'custom',
message:
'Connect URI is required and must start with format nostr+walletconnect:, please check again',
});
return;
}
setIsloading(true);
const uriObj = new URL(data.uri);
const params = new URLSearchParams(uriObj.search);
if (params.has('relay') && params.has('secret')) {
await db.secureSave('walletConnectURL', data.uri, 'nwc');
setWalletConnectURL(data.uri);
setIsloading(false);
setIsOpen(false);
}
} catch (e) {
setIsloading(false);
setError('uri', {
type: 'custom',
message:
'Connect URI is required and must start with format nostr+walletconnect:, please check again',
});
}
};
return (
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
<div className="flex items-center justify-between pt-4">
<div className="inline-flex items-center gap-2.5">
<div className="inline-flex h-11 w-11 items-center justify-center rounded-md bg-white/10">
<WorldIcon className="h-5 w-5" />
</div>
<div>
<h5 className="font-semibold leading-tight text-white">URI String</h5>
<p className="text-sm leading-tight text-white/50">
Using format nostr+walletconnect:
</p>
</div>
</div>
<Dialog.Trigger asChild>
<button
type="button"
className="inline-flex h-9 w-min items-center justify-center rounded-md border-t border-white/10 bg-white/20 px-3 text-sm font-medium text-white hover:bg-green-500"
>
Connect
</button>
</Dialog.Trigger>
</div>
<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 rounded-t-xl 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">
Nostr Wallet Connect
</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>
<form
onSubmit={handleSubmit(onSubmit)}
className="mb-0 flex flex-col gap-3 px-5 py-5"
>
<div className="flex flex-col gap-1">
<label
htmlFor="uri"
className="text-sm font-semibold uppercase tracking-wider text-white/50"
>
Connect URI
</label>
<input
{...register('uri', { required: true })}
placeholder="nostr+walletconnect:"
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
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.uri && <p>{errors.uri.message}</p>}
</span>
</div>
<div className="flex flex-col gap-1 text-center">
<button
type="submit"
disabled={!isDirty || !isValid}
className="inline-flex h-11 w-full items-center justify-between gap-2 rounded-lg bg-fuchsia-500 px-6 font-medium leading-none text-white hover:bg-fuchsia-600 focus:outline-none disabled:opacity-50"
>
{isLoading ? (
<>
<span className="w-5" />
<span>Connecting...</span>
<LoaderIcon className="h-5 w-5 animate-spin text-white" />
</>
) : (
<>
<span className="w-5" />
<span>Connect</span>
<ArrowRightCircleIcon className="h-5 w-5" />
</>
)}
</button>
<span className="text-sm text-white/50">
All information will be encrypted and stored on the local machine.
</span>
</div>
</form>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

124
src/app/nwc/index.tsx Normal file
View File

@@ -0,0 +1,124 @@
import { NWCAlby } from '@app/nwc/components/alby';
import { NWCOther } from '@app/nwc/components/other';
import { useStorage } from '@libs/storage/provider';
import { CheckCircleIcon } from '@shared/icons';
import { useStronghold } from '@stores/stronghold';
export function NWCScreen() {
const { db } = useStorage();
const [walletConnectURL, setWalletConnectURL] = useStronghold((state) => [
state.walletConnectURL,
state.setWalletConnectURL,
]);
const remove = async () => {
setWalletConnectURL('');
await db.secureSave('walletConnectURL', '', 'nwc');
};
return (
<div className="flex h-full w-full items-center justify-center">
<div className="flex w-full flex-col gap-5">
<div className="text-center">
<h3 className="text-2xl font-bold leading-tight">
Nostr Wallet Connect (Beta)
</h3>
<p className="leading-tight text-white/70">
Sending tips easily via Bitcoin Lightning.
</p>
</div>
<div className="mx-auto max-w-lg">
{!walletConnectURL ? (
<div className="flex w-full flex-col gap-4 divide-y divide-white/5 rounded-xl border-t border-white/10 bg-white/20 p-3">
<NWCAlby />
<NWCOther />
</div>
) : (
<div className="flex w-full flex-col rounded-xl border-t border-white/10 bg-white/20 p-3">
<div className="mb-1 inline-flex items-center gap-1.5 text-sm text-green-500">
<CheckCircleIcon className="h-4 w-4" />
<p>You&apos;re using nostr wallet connect</p>
</div>
<div className="flex flex-col gap-2">
<textarea
readOnly
value={walletConnectURL.substring(0, 120) + '****'}
className="relative h-40 w-full resize-none rounded-lg bg-white/20 px-3 py-1 text-white !outline-none backdrop-blur-xl placeholder:text-white/50"
/>
<button
type="button"
onClick={() => remove()}
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg bg-white/10 px-6 font-medium leading-none text-red-500 hover:bg-white/20 focus:outline-none disabled:opacity-50"
>
Remove connection
</button>
</div>
</div>
)}
<div className="mt-5 flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<h5 className="text-sm font-bold text-white">Introduction</h5>
<p className="text-sm text-white/70">
Nostr Wallet Connect (NWC) is a way for applications like Nostr clients to
access a remote Lightning wallet through a standardized protocol.
</p>
<p className="text-sm text-white/70">
To learn more about the details have a look at{' '}
<a
href="https://github.com/getAlby/nips/blob/7-wallet-connect-patch/47.md"
target="_blank"
className="text-fuchsia-300"
rel="noreferrer"
>
the specs (NIP47)
</a>
</p>
</div>
<div className="flex flex-col gap-1.5">
<h5 className="text-sm font-bold text-white">About tipping</h5>
<p className="text-sm text-white/70">
Also known as Zap in other Nostr client.
</p>
<p className="text-sm text-white/70">
Lume doesn&apos;t take any commission or platform fees when you tip
someone.
</p>
<p className="text-sm text-white/70">Lume doesn&apos;t hold your Bitcoin</p>
</div>
<div className="flex flex-col gap-1.5">
<h5 className="text-sm font-bold text-white">
Recommend wallet that support NWC
</h5>
<p className="text-sm text-white/70">
Mutiny Wallet:{' '}
<a
href="https://www.mutinywallet.com/"
target="_blank"
rel="noreferrer"
className="text-fuchsia-300"
>
website
</a>
</p>
<p className="text-sm text-white/70">
Self hosted NWC on Umbrel :{' '}
<a
href="https://apps.umbrel.com/app/alby-nostr-wallet-connect"
target="_blank"
rel="noreferrer"
className="text-fuchsia-300"
>
website
</a>
</p>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -8,6 +8,7 @@ import {
FollowsIcon,
GroupFeedsIcon,
HashtagIcon,
ThreadsIcon,
TrendingIcon,
} from '@shared/icons';
import { TitleBar } from '@shared/titleBar';
@@ -46,8 +47,10 @@ export function WidgetList({ params }: { params: Widget }) {
case WidgetKinds.nostrBand.trendingAccounts:
case WidgetKinds.nostrBand.trendingNotes:
return <TrendingIcon className="h-5 w-4 text-white" />;
case WidgetKinds.other.learnNostr:
return <ThreadsIcon className="h-5 w-4 text-white" />;
default:
return '';
return null;
}
},
[DefaultWidgets]
@@ -94,25 +97,25 @@ export function WidgetList({ params }: { params: Widget }) {
);
return (
<div className="relative h-full shrink-0 grow-0 basis-[400px] overflow-hidden bg-white/10">
<div className="relative h-full shrink-0 grow-0 basis-[400px] 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 className="scrollbar-hide h-full overflow-y-auto pb-20">
<div className="flex flex-col gap-6 px-3">
{DefaultWidgets.map((row: WidgetGroup) => renderItem(row))}
<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>
</div>

View File

@@ -10,9 +10,11 @@ import {
GlobalArticlesWidget,
GlobalFilesWidget,
GlobalHashtagWidget,
LearnNostrWidget,
LocalArticlesWidget,
LocalFeedsWidget,
LocalFilesWidget,
LocalFollowsWidget,
LocalNetworkWidget,
LocalThreadWidget,
LocalUserWidget,
@@ -21,7 +23,6 @@ import {
XfeedsWidget,
XhashtagWidget,
} from '@shared/widgets';
import { LocalFollowsWidget } from '@shared/widgets/local/follows';
import { WidgetKinds, useWidgets } from '@stores/widgets';
@@ -69,8 +70,10 @@ export function SpaceScreen() {
return <XfeedsWidget key={widget.id} params={widget} />;
case WidgetKinds.tmp.list:
return <WidgetList key={widget.id} params={widget} />;
case WidgetKinds.other.learnNostr:
return <LearnNostrWidget key={widget.id} params={widget} />;
default:
break;
return null;
}
},
[widgets]

View File

@@ -22,10 +22,9 @@ export function SplashScreen() {
const prefetch = async () => {
try {
const user = await fetchUserData();
const data = await prefetchEvents();
const [user, events] = await Promise.all([fetchUserData(), prefetchEvents()]);
if (user.status === 'ok' && data.status === 'ok') {
if (user.status === 'ok' && events.status === 'ok') {
// update last login = current time
await db.updateLastLogin();
// close splash screen and open main app screen

View File

@@ -289,7 +289,7 @@ export function EditProfileModal() {
<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"
className="inline-flex h-11 w-full transform items-center justify-center gap-1 rounded-lg 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" />

View File

@@ -2,25 +2,21 @@
@tailwind components;
@tailwind utilities;
html {
font-size: 14px;
}
input::-ms-reveal,
input::-ms-clear {
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
a {
@apply cursor-default no-underline !important;
}
button {
@apply cursor-default focus:outline-none;
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.markdown {
@apply prose prose-white max-w-none select-text hyphens-auto text-white prose-p:mb-0 prose-p:mt-0 prose-p:break-words prose-p:[word-break:break-word] prose-p:last:mb-0 prose-a:break-words prose-a:break-all prose-a:font-normal hover:prose-a:text-fuchsia-500 prose-blockquote:mb-1 prose-blockquote:mt-1 prose-blockquote:border-l-[2px] prose-blockquote:border-fuchsia-500 prose-blockquote:pl-2 prose-pre:whitespace-pre-wrap prose-pre:break-words prose-pre:break-all prose-ol:m-0 prose-ol:mb-1 prose-ul:mb-1 prose-ul:mt-1 prose-img:mb-2 prose-img:mt-3 prose-hr:mx-0 prose-hr:my-2;
@apply prose prose-white max-w-none select-text hyphens-auto text-white prose-headings:mb-1 prose-headings:mt-3 prose-p:mb-0 prose-p:mt-0 prose-p:break-words prose-p:[word-break:break-word] prose-p:last:mb-0 prose-a:break-words prose-a:break-all prose-a:font-normal hover:prose-a:text-fuchsia-500 prose-blockquote:mb-1 prose-blockquote:mt-1 prose-blockquote:border-l-[2px] prose-blockquote:border-fuchsia-500 prose-blockquote:pl-2 prose-pre:whitespace-pre-wrap prose-pre:break-words prose-pre:break-all prose-pre:bg-white/10 prose-ol:m-0 prose-ol:mb-1 prose-ul:mb-1 prose-ul:mt-1 prose-img:mb-2 prose-img:mt-3 prose-hr:mx-0 prose-hr:my-2;
}
.markdown-article {
@apply prose prose-white max-w-none select-text hyphens-auto text-white/80 prose-headings:mb-1 prose-headings:mt-3 prose-headings:text-white prose-p:mb-2 prose-p:mt-0 prose-p:break-words prose-p:[word-break:break-word] prose-a:break-words prose-a:break-all prose-a:font-normal hover:prose-a:text-fuchsia-500 prose-blockquote:mb-1 prose-blockquote:mt-1 prose-blockquote:border-l-[2px] prose-blockquote:border-fuchsia-500 prose-blockquote:pl-2 prose-pre:whitespace-pre-wrap prose-pre:break-words prose-pre:break-all prose-pre:bg-white/10 prose-ol:m-0 prose-ol:mb-1 prose-ul:mb-1 prose-ul:mt-1 prose-img:mb-2 prose-img:mt-3 prose-hr:mx-0 prose-hr:my-2;
}
.ProseMirror p.is-empty::before {
@@ -35,25 +31,31 @@ button {
@apply outline-fuchsia-500;
}
html {
font-size: 14px;
}
a {
@apply cursor-default no-underline !important;
}
button {
@apply cursor-default focus:outline-none;
}
iframe {
height: auto !important;
}
/* For Webkit-based browsers (Chrome, Safari and Opera) */
.scrollbar-hide::-webkit-scrollbar {
span[data-slate-placeholder] {
margin-top: 0;
}
input::-ms-reveal,
input::-ms-clear {
display: none;
}
/* For IE, Edge and Firefox */
.scrollbar-hide {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.border {
background-clip: padding-box;
}
span[data-slate-placeholder] {
@apply top-0;
::-webkit-input-placeholder {
line-height: normal;
}

View File

@@ -1,57 +1,52 @@
// inspire by: https://github.com/nostr-dev-kit/ndk-react/
import NDK from '@nostr-dev-kit/ndk';
import { message } from '@tauri-apps/api/dialog';
import { fetch } from '@tauri-apps/api/http';
import { useEffect, useMemo, useState } from 'react';
import TauriAdapter from '@libs/ndk/cache';
import { useStorage } from '@libs/storage/provider';
import { FULL_RELAYS } from '@stores/constants';
export const NDKInstance = () => {
const { db } = useStorage();
const [ndk, setNDK] = useState<NDK | undefined>(undefined);
const [relayUrls, setRelayUrls] = useState<string[]>([]);
const { db } = useStorage();
const cacheAdapter = useMemo(() => new TauriAdapter(), [ndk]);
// TODO: fully support NIP-11
async function getExplicitRelays() {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort('timeout'), 10000);
// get relays
const relays = (await db.getExplicitRelayUrls()) ?? FULL_RELAYS;
const relays = await db.getExplicitRelayUrls();
const requests = relays.map((relay) => {
const url = new URL(relay);
return fetch(`https://${url.hostname + url.pathname}`, {
headers: { Accept: 'application/nostr+json' },
signal: controller.signal,
method: 'GET',
timeout: 10,
headers: {
Accept: 'application/nostr+json',
},
});
});
const responses = await Promise.all(requests);
const errors = responses.filter((response) => !response.ok);
const successes = responses.filter((res) => res.ok);
if (errors.length > 0) throw errors.map((response) => Error(response.statusText));
const verifiedRelays: string[] = responses.map((res) => {
const url = new URL(res.url);
if (url.protocol === 'http:') return `ws://${url.hostname + url.pathname}`;
if (url.protocol === 'https:') return `wss://${url.hostname + url.pathname}`;
const verifiedRelays: string[] = successes.map((res) => {
// TODO: support payment
// @ts-expect-error, not have type yet
if (!res.data.limitation?.payment_required) {
const url = new URL(res.url);
if (url.protocol === 'http:') return `ws://${url.hostname + url.pathname}`;
if (url.protocol === 'https:') return `wss://${url.hostname + url.pathname}`;
}
});
// clear timeout
clearTimeout(timeoutId);
// return all validate relays
// return all validated relays
return verifiedRelays;
} catch (e) {
await message(e, { title: 'Cannot connect to relays', type: 'error' });
console.error(e);
}
}
@@ -60,8 +55,6 @@ export const NDKInstance = () => {
const instance = new NDK({
explicitRelayUrls,
cacheAdapter,
// outboxRelayUrls: ['wss://purplepag.es'],
// enableOutboxModel: true,
});
try {

View File

@@ -5,12 +5,12 @@ import { PropsWithChildren, createContext, useContext } from 'react';
import { NDKInstance } from '@libs/ndk/instance';
interface NDKContext {
ndk: NDK;
ndk: undefined | NDK;
relayUrls: string[];
}
const NDKContext = createContext<NDKContext>({
ndk: new NDK({}),
ndk: undefined,
relayUrls: [],
});

View File

@@ -4,6 +4,8 @@ import { Platform } from '@tauri-apps/api/os';
import Database from 'tauri-plugin-sql-api';
import { Stronghold } from 'tauri-plugin-stronghold-api';
import { FULL_RELAYS } from '@stores/constants';
import { Account, DBEvent, Relays, Widget } from '@utils/types';
export class LumeStorage {
@@ -19,29 +21,35 @@ export class LumeStorage {
this.account = null;
}
private async getSecureClient() {
private async getSecureClient(key?: string) {
try {
return await this.secureDB.loadClient('lume');
return await this.secureDB.loadClient(key ?? 'lume');
} catch {
return await this.secureDB.createClient('lume');
return await this.secureDB.createClient(key ?? 'lume');
}
}
public async secureSave(key: string, value: string) {
public async secureSave(key: string, value: string, clientKey?: string) {
if (!this.secureDB) throw new Error("Stronghold isn't initialize");
const client = await this.getSecureClient();
const client = await this.getSecureClient(clientKey);
const store = client.getStore();
console.log('insert key: ', key);
await store.insert(key, Array.from(new TextEncoder().encode(value)));
return await this.secureDB.save();
await this.secureDB.save();
}
public async secureLoad(key: string) {
public async secureLoad(key: string, clientKey?: string) {
if (!this.secureDB) throw new Error("Stronghold isn't initialize");
const client = await this.getSecureClient();
const client = await this.getSecureClient(clientKey);
const store = client.getStore();
console.log('get key: ', key);
const value = await store.get(key);
if (!value) return null;
const decoded = new TextDecoder().decode(new Uint8Array(value));
return decoded;
}
@@ -137,18 +145,29 @@ export class LumeStorage {
return await this.db.execute('DELETE FROM widgets WHERE id = $1;', [id]);
}
public async createEvent(
id: string,
event: string,
author: string,
kind: number,
root_id: string,
reply_id: string,
created_at: number
) {
public async createEvent(event: NDKEvent) {
let root: string;
let reply: string;
if (event.tags?.[0]?.[0] === 'e' && !event.tags?.[0]?.[3]) {
root = event.tags[0][1];
} else {
root = event.tags.find((el) => el[3] === 'root')?.[1];
reply = event.tags.find((el) => el[3] === 'reply')?.[1];
}
return await this.db.execute(
'INSERT OR IGNORE INTO events (id, account_id, event, author, kind, root_id, reply_id, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8);',
[id, this.account.id, event, author, kind, root_id, reply_id, created_at]
[
event.id,
this.account.id,
JSON.stringify(event),
event.pubkey,
event.kind,
root,
reply,
event.created_at,
]
);
}
@@ -264,13 +283,13 @@ export class LumeStorage {
}
public async getExplicitRelayUrls() {
if (!this.account) return null;
if (!this.account) return FULL_RELAYS;
const result: Relays[] = await this.db.select(
`SELECT * FROM relays WHERE account_id = "${this.account.id}" ORDER BY id DESC LIMIT 50;`
);
if (result.length < 1) return null;
if (!result || result.length < 1) return FULL_RELAYS;
return result.map((el) => el.relay);
}

View File

@@ -40,7 +40,7 @@ export function ActiveAccount() {
case NDKKind.Text:
return await sendNativeNotification('Mention');
case NDKKind.Contacts:
return await sendNativeNotification("You've new follower");
return await sendNativeNotification("You've a new follower");
case NDKKind.Repost:
return await sendNativeNotification('Repost');
case NDKKind.Reaction:
@@ -48,7 +48,6 @@ export function ActiveAccount() {
case NDKKind.Zap:
return await sendNativeNotification('Zap');
default:
console.log('[notify] new event: ', event);
break;
}
});
@@ -56,8 +55,8 @@ export function ActiveAccount() {
if (status === 'loading') {
return (
<div className="inline-flex h-10 items-center gap-2.5 rounded-md px-2">
<div className="relative h-7 w-7 shrink-0 animate-pulse rounded bg-white/10 backdrop-blur-xl" />
<div className="inline-flex h-16 items-center gap-2.5 border-l-2 border-transparent pb-2 pl-4 pr-2">
<div className="relative h-10 w-10 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>
);

View File

@@ -19,7 +19,7 @@ export function AccountMoreActions({ pubkey }: { pubkey: string }) {
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content className="flex w-[200px] flex-col overflow-hidden rounded-xl bg-white/10 p-2 backdrop-blur-3xl focus:outline-none">
<DropdownMenu.Content className="flex w-[200px] flex-col overflow-hidden rounded-xl border border-white/10 bg-white/20 p-2 backdrop-blur-3xl focus:outline-none">
<DropdownMenu.Item asChild>
<Link
to={`/users/${pubkey}`}

View File

@@ -2,14 +2,14 @@ import { Dispatch, SetStateAction, useState } from 'react';
import { LoaderIcon, PlusIcon } from '@shared/icons';
import { useImageUploader } from '@utils/hooks/useUploader';
import { useNostr } from '@utils/hooks/useNostr';
export function AvatarUploader({
setPicture,
}: {
setPicture: Dispatch<SetStateAction<string>>;
}) {
const upload = useImageUploader();
const { upload } = useNostr();
const [loading, setLoading] = useState(false);
const uploadAvatar = async () => {

View File

@@ -2,14 +2,14 @@ import { Dispatch, SetStateAction, useState } from 'react';
import { LoaderIcon, PlusIcon } from '@shared/icons';
import { useImageUploader } from '@utils/hooks/useUploader';
import { useNostr } from '@utils/hooks/useNostr';
export function BannerUploader({
setBanner,
}: {
setBanner: Dispatch<SetStateAction<string>>;
}) {
const upload = useImageUploader();
const { upload } = useNostr();
const [loading, setLoading] = useState(false);
const uploadBanner = async () => {
@@ -25,13 +25,14 @@ export function BannerUploader({
<button
type="button"
onClick={() => uploadBanner()}
className="inline-flex h-full w-full items-center justify-center bg-black/40 hover:bg-black/50"
className="inline-flex h-full w-full flex-col items-center justify-center gap-1 bg-black/40 hover:bg-black/50"
>
{loading ? (
<LoaderIcon className="h-8 w-8 animate-spin text-white" />
<LoaderIcon className="h-6 w-6 animate-spin text-white" />
) : (
<PlusIcon className="h-8 w-8 text-white" />
<PlusIcon className="h-6 w-6 text-white" />
)}
<p className="text-sm font-medium text-white/70">Add a banner image</p>
</button>
);
}

74
src/shared/icons/alby.tsx Normal file
View File

@@ -0,0 +1,74 @@
import { SVGProps } from 'react';
export function AlbyIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="400"
height="578"
fill="none"
viewBox="0 0 400 578"
{...props}
>
<path
fill="#000"
d="M201.283 577.511c54.122 0 97.998-8.1 97.998-18.092 0-9.992-43.876-18.092-97.998-18.092-54.123 0-97.998 8.1-97.998 18.092 0 9.992 43.875 18.092 97.998 18.092z"
opacity="0.1"
></path>
<path
fill="#fff"
stroke="#000"
strokeWidth="15.077"
d="M295.75 471.344c50.627 0 73.67-112.102 73.67-154.608 0-33.13-22.86-53.208-52.913-53.208-29.866 0-54.113 12.843-54.414 28.747-.001 41.971-7.388 179.069 33.657 179.069zM110.837 471.344c-50.627 0-73.67-112.102-73.67-154.608 0-33.13 22.86-53.208 52.913-53.208 29.866 0 54.113 12.843 54.414 28.747.001 41.971 7.388 179.069-33.657 179.069z"
></path>
<path
fill="#FFDF6F"
stroke="#000"
strokeWidth="15"
d="M68.83 303.262v-.002c-.054-.519.052-.82.16-1.016.127-.232.368-.508.773-.738.84-.477 2.014-.563 3.108.076 37.603 22.042 80.976 34.678 128.13 34.678 47.163 0 91.339-12.881 129.184-35.307 1.087-.645 2.26-.565 3.102-.091.407.229.65.504.779.737.109.197.216.499.163 1.019-5.854 58.014-37.322 105.977-79.618 128.054-13.969 7.293-23.576 19.962-32.013 31.089l-.452.597-.002.002c-6.857 9.046-13.063 17.147-20.648 23.116-7.584-5.969-13.791-14.07-20.648-23.116l-.001-.002-.452-.597c-8.437-11.127-18.043-23.796-32.013-31.089-42.135-21.992-73.523-69.677-79.551-127.41z"
></path>
<path
fill="#000"
stroke="#000"
strokeWidth="15.077"
d="M201.786 346.338c73.274 0 132.674-19.8 132.674-44.225s-59.4-44.225-132.674-44.225-132.674 19.8-132.674 44.225 59.4 44.225 132.674 44.225z"
></path>
<path
stroke="#000"
strokeLinecap="round"
strokeWidth="15.077"
d="M95.245 376.491s65.44 22.112 107.546 22.112c42.105 0 107.546-22.112 107.546-22.112"
></path>
<path
fill="#000"
d="M77 143c-16.569 0-30-13.431-30-30 0-16.569 13.431-30 30-30 16.569 0 30 13.431 30 30 0 16.569-13.431 30-30 30z"
></path>
<path stroke="#000" strokeWidth="15" d="M72 108.5l56 56"></path>
<path
fill="#000"
d="M322 143c16.569 0 30-13.431 30-30 0-16.569-13.431-30-30-30-16.569 0-30 13.431-30 30 0 16.569 13.431 30 30 30z"
></path>
<path stroke="#000" strokeWidth="15" d="M327.5 108.5l-56 56"></path>
<path
fill="#FFDF6F"
fillRule="evenodd"
d="M85.516 292.019c-16.17-7.698-25.58-24.983-22.427-42.612C76.618 173.747 133 117 200.5 117c67.663 0 124.155 57.023 137.509 132.958 3.106 17.66-6.381 34.937-22.605 42.572C280.687 308.868 241.91 318 201 318c-41.335 0-80.493-9.323-115.484-25.981z"
clipRule="evenodd"
></path>
<path
fill="#000"
d="M70.472 250.728C83.544 177.62 137.582 124.5 200.5 124.5v-15c-72.082 0-130.809 60.375-144.794 138.587l14.766 2.641zM200.5 124.5c63.069 0 117.218 53.379 130.122 126.757l14.774-2.598C331.592 170.166 272.758 109.5 200.5 109.5v15zm111.71 161.244C278.472 301.621 240.783 310.5 201 310.5v15c42.037 0 81.902-9.386 117.597-26.183l-6.387-13.573zM201 310.5c-40.196 0-78.255-9.064-112.26-25.253l-6.448 13.544C118.269 315.918 158.526 325.5 201 325.5v-15zm129.622-59.243c2.49 14.159-5.091 28.219-18.412 34.487l6.387 13.573c19.128-9.002 30.52-29.497 26.799-50.658l-14.774 2.598zm-274.916-3.17c-3.778 21.124 7.524 41.629 26.586 50.704l6.447-13.544c-13.276-6.32-20.795-20.387-18.267-34.519l-14.766-2.641z"
></path>
<path
fill="#000"
fillRule="evenodd"
d="M114.365 273.209c-13.015-5.301-20.736-19.149-16.226-32.459C112.047 199.704 152.618 170 200.5 170c47.882 0 88.453 29.704 102.361 70.75 4.51 13.31-3.211 27.158-16.226 32.459C260.053 284.035 230.973 290 200.5 290c-30.473 0-59.553-5.965-86.135-16.791z"
clipRule="evenodd"
></path>
<path
fill="#fff"
d="M235 254c13.807 0 25-8.954 25-20s-11.193-20-25-20-25 8.954-25 20 11.193 20 25 20zM163.432 254.012c13.807 0 25-8.954 25-20s-11.193-20-25-20-25 8.954-25 20 11.193 20 25 20z"
></path>
</svg>
);
}

View File

@@ -61,3 +61,6 @@ export * from './mention';
export * from './groupFeeds';
export * from './article';
export * from './follows';
export * from './alby';
export * from './stars';
export * from './nwc';

22
src/shared/icons/nwc.tsx Normal file
View File

@@ -0,0 +1,22 @@
import { SVGProps } from 'react';
export function NwcIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
{...props}
>
<path
fill="currentColor"
fillRule="evenodd"
d="M3 6.5A3.5 3.5 0 016.5 3h8.969C16.314 3 17 3.686 17 4.531V8h2.25c.966 0 1.75.784 1.75 1.75v9.5A1.75 1.75 0 0119.25 21h-7a.75.75 0 010-1.5h7a.25.25 0 00.25-.25v-9.5a.25.25 0 00-.25-.25H6a2.986 2.986 0 01-1.5-.401v2.151a.75.75 0 01-1.5 0V6.5zm1.5 0A1.5 1.5 0 006 8h9.5V4.531a.031.031 0 00-.031-.031H6.5a2 2 0 00-2 2zm-1 9.285v2.93L6 20.173l2.5-1.458v-2.93L6 14.327l-2.5 1.458zm2.122-2.975a.75.75 0 01.756 0l3.25 1.896a.75.75 0 01.372.648v3.792a.75.75 0 01-.372.648l-3.25 1.895a.75.75 0 01-.756 0l-3.25-1.895A.75.75 0 012 19.146v-3.792a.75.75 0 01.372-.648l3.25-1.896z"
clipRule="evenodd"
></path>
<path fill="currentColor" d="M15.5 15.5a1 1 0 100-2 1 1 0 000 2z"></path>
</svg>
);
}

View File

@@ -0,0 +1,19 @@
import { SVGProps } from 'react';
export function StarsIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="30"
height="30"
fill="none"
viewBox="0 0 30 30"
{...props}
>
<path
fill="currentColor"
d="M13.379 11.638a.999.999 0 00.759-.759l.886-3.986c.232-1.044 1.72-1.044 1.952 0l.886 3.986c.084.38.38.675.759.76l3.986.885c1.044.232 1.044 1.72 0 1.952l-3.986.886a.999.999 0 00-.759.76l-.886 3.985c-.232 1.044-1.72 1.044-1.952 0l-.886-3.986a.999.999 0 00-.759-.759l-3.986-.886c-1.044-.232-1.044-1.72 0-1.952l3.986-.886zM8.06 19.82a.999.999 0 00.76-.759l.27-1.22c.098-.438.722-.438.819 0l.271 1.22c.084.379.38.675.759.759l1.22.271c.438.097.438.721 0 .818l-1.22.271a.999.999 0 00-.759.759l-.271 1.22c-.097.438-.721.438-.818 0l-.271-1.22a.999.999 0 00-.76-.759l-1.22-.271c-.437-.097-.437-.721.001-.818l1.22-.271z"
></path>
</svg>
);
}

View File

@@ -9,7 +9,7 @@ import { useStorage } from '@libs/storage/provider';
import { ActiveAccount } from '@shared/accounts/active';
import { ComposerModal } from '@shared/composer';
import { Frame } from '@shared/frame';
import { BellIcon, NavArrowDownIcon, SpaceIcon } from '@shared/icons';
import { BellIcon, NavArrowDownIcon, NwcIcon, SpaceIcon } from '@shared/icons';
import { useActivities } from '@stores/activities';
import { useSidebar } from '@stores/sidebar';
@@ -21,6 +21,10 @@ export function Navigation() {
const [totalNewActivities] = useActivities((state) => [state.totalNewActivities]);
const [chats, toggleChats] = useSidebar((state) => [state.chats, state.toggleChats]);
const [integrations, toggleIntegrations] = useSidebar((state) => [
state.integrations,
state.toggleIntegrations,
]);
return (
<Frame className="relative flex h-full w-[232px] flex-col" lighter>
@@ -38,7 +42,7 @@ export function Navigation() {
'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'
: 'border-transparent text-white/70'
)
}
>
@@ -55,25 +59,65 @@ export function Navigation() {
'flex h-10 items-center justify-between rounded-r-lg border-l-2 pl-4 pr-2',
isActive
? 'border-fuchsia-500 bg-white/5 text-white'
: 'border-transparent text-white/80'
: 'border-transparent text-white/70'
)
}
>
<div className="flex items-center gap-2.5">
<span className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded bg-white/10 backdrop-blur-xl">
<BellIcon className="h-4 w-4 text-white" />
</span>
{totalNewActivities > 0 ? (
<div className="relative inline-flex h-7 w-7 shrink-0 items-center justify-center rounded bg-fuchsia-500/20 backdrop-blur-xl">
<p className="text-sm font-bold text-fuchsia-500">
{compactNumber.format(totalNewActivities)}
</p>
<span className="absolute right-0 top-0 block h-1 w-1 -translate-y-1/2 translate-x-1/2 transform rounded-full bg-fuchsia-500 ring-2 ring-black/80" />
</div>
) : (
<span className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded bg-white/10 backdrop-blur-xl">
<BellIcon className="h-4 w-4 text-white" />
</span>
)}
Notifications
</div>
{totalNewActivities > 0 ? (
<div className="inline-flex h-5 w-8 items-center justify-center rounded bg-fuchsia-500">
<span className="text-xs font-medium text-white">
{compactNumber.format(totalNewActivities)}
</span>
</div>
) : null}
</NavLink>
</div>
<Collapsible.Root open={integrations} onOpenChange={toggleIntegrations}>
<div className="flex flex-col gap-1 pr-2">
<Collapsible.Trigger asChild>
<button className="flex items-center gap-1 pl-[20px] pr-4">
<div
className={twMerge(
'inline-flex h-5 w-5 transform items-center justify-center transition-transform duration-150 ease-in-out',
integrations ? '' : 'rotate-180'
)}
>
<NavArrowDownIcon className="h-3 w-3 text-white/50" />
</div>
<h3 className="text-[11px] font-bold uppercase tracking-widest text-white/50">
Integrations
</h3>
</button>
</Collapsible.Trigger>
<Collapsible.Content>
<NavLink
to="/nwc"
preventScrollReset={true}
className={({ isActive }) =>
twMerge(
'flex h-10 items-center gap-2.5 rounded-r-lg border-l-2 pl-4 pr-2',
isActive
? 'border-fuchsia-500 bg-white/5 text-white'
: 'border-transparent text-white/70'
)
}
>
<span className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded bg-white/10 backdrop-blur-xl">
<NwcIcon className="h-4 w-4 text-white" />
</span>
Wallet Connect
</NavLink>
</Collapsible.Content>
</div>
</Collapsible.Root>
<Collapsible.Root open={chats} onOpenChange={toggleChats}>
<div className="flex flex-col gap-1 pr-2">
<Collapsible.Trigger asChild>
@@ -97,7 +141,7 @@ export function Navigation() {
</div>
</Collapsible.Root>
</div>
<div className="shrink-0">
<div className="relative shrink-0">
<ActiveAccount />
</div>
</Frame>

View File

@@ -1,5 +1,6 @@
import { useQuery } from '@tanstack/react-query';
import { fetch } from '@tauri-apps/api/http';
import { memo } from 'react';
import { twMerge } from 'tailwind-merge';
import { UnverifiedIcon, VerifiedIcon } from '@shared/icons';
@@ -10,7 +11,7 @@ interface NIP05 {
};
}
export function NIP05({
export const NIP05 = memo(function NIP05({
pubkey,
nip05,
className,
@@ -71,4 +72,4 @@ export function NIP05({
</div>
</div>
);
}
});

View File

@@ -33,7 +33,7 @@ export function NoteActions({
<NoteReply id={id} pubkey={pubkey} root={root} />
<NoteReaction id={id} pubkey={pubkey} />
<NoteRepost id={id} pubkey={pubkey} />
<NoteZap id={id} />
<NoteZap id={id} pubkey={pubkey} />
</div>
{extraButtons && (
<>

View File

@@ -18,8 +18,7 @@ export function MoreActions({ id, pubkey }: { id: string; pubkey: string }) {
const copyLink = async () => {
await writeText(
'https://nostr.com/' +
nip19.neventEncode({ id: id, author: pubkey } as EventPointer)
'https://njump.me/' + nip19.neventEncode({ id: id, author: pubkey } as EventPointer)
);
setOpen(false);
};
@@ -45,7 +44,7 @@ export function MoreActions({ id, pubkey }: { id: string; pubkey: string }) {
</Tooltip.Portal>
</Tooltip.Root>
<DropdownMenu.Portal>
<DropdownMenu.Content className="flex w-[200px] flex-col overflow-hidden rounded-xl bg-white/10 p-2 backdrop-blur-3xl focus:outline-none">
<DropdownMenu.Content className="flex w-[200px] flex-col overflow-hidden rounded-xl border border-white/10 bg-white/20 p-2 backdrop-blur-3xl focus:outline-none">
<DropdownMenu.Item asChild>
<button
type="button"

View File

@@ -1,3 +1,4 @@
import { NDKKind } from '@nostr-dev-kit/ndk';
import * as Popover from '@radix-ui/react-popover';
import { useState } from 'react';
@@ -44,7 +45,7 @@ export function NoteReaction({ id, pubkey }: { id: string; pubkey: string }) {
const event = await publish({
content: content,
kind: 7,
kind: NDKKind.Reaction,
tags: [
['e', id],
['p', pubkey],

View File

@@ -1,25 +1,39 @@
import { NDKKind } from '@nostr-dev-kit/ndk';
import * as AlertDialog from '@radix-ui/react-alert-dialog';
import * as Tooltip from '@radix-ui/react-tooltip';
import { message } from '@tauri-apps/api/dialog';
import { useState } from 'react';
import { twMerge } from 'tailwind-merge';
import { RepostIcon } from '@shared/icons';
import { useNDK } from '@libs/ndk/provider';
import { LoaderIcon, RepostIcon } from '@shared/icons';
import { useNostr } from '@utils/hooks/useNostr';
import { sendNativeNotification } from '@utils/notification';
export function NoteRepost({ id, pubkey }: { id: string; pubkey: string }) {
const { publish } = useNostr();
const { relayUrls } = useNDK();
const [open, setOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isRepost, setIsRepost] = useState(false);
const submit = async () => {
setIsLoading(true);
const tags = [
['e', id, 'wss://relayable.org', 'root'],
['e', id, relayUrls[0], 'root'],
['p', pubkey],
];
const event = await publish({ content: '', kind: 6, tags: tags });
const event = await publish({ content: '', kind: NDKKind.Repost, tags: tags });
if (event) {
setOpen(false);
setIsRepost(true);
await sendNativeNotification('Reposted successfully', 'Lume');
} else {
console.log('failed reposting');
setIsLoading(false);
await message('Repost failed, try again later', { title: 'Lume', type: 'error' });
}
};
@@ -32,7 +46,12 @@ export function NoteRepost({ id, pubkey }: { id: string; pubkey: string }) {
type="button"
className="group inline-flex h-7 w-7 items-center justify-center"
>
<RepostIcon className="h-5 w-5 text-white group-hover:text-blue-400" />
<RepostIcon
className={twMerge(
'h-5 w-5 group-hover:text-blue-400',
isRepost ? 'text-blue-400' : 'text-white'
)}
/>
</button>
</AlertDialog.Trigger>
</Tooltip.Trigger>
@@ -56,18 +75,22 @@ export function NoteRepost({ id, pubkey }: { id: string; pubkey: string }) {
action.
</AlertDialog.Description>
</div>
<div className="flex justify-end gap-2 px-5 py-3">
<div className="flex justify-end gap-2 px-3 py-3">
<AlertDialog.Cancel asChild>
<button className="inline-flex h-9 items-center justify-center rounded-md px-4 text-sm font-medium leading-none text-white outline-none hover:bg-white/10 hover:backdrop-blur-xl">
<button className="inline-flex h-9 w-20 items-center justify-center rounded-md text-sm font-medium leading-none text-white outline-none hover:bg-white/10 hover:backdrop-blur-xl">
Cancel
</button>
</AlertDialog.Cancel>
<button
type="button"
onClick={() => submit()}
className="inline-flex h-9 items-center justify-center rounded-md bg-white/10 px-4 text-sm font-medium leading-none text-white outline-none hover:bg-fuchsia-500"
className="inline-flex h-9 w-28 items-center justify-center rounded-md bg-white/10 text-sm font-medium leading-none text-white outline-none hover:bg-fuchsia-500"
>
Yes, repost
{isLoading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-white" />
) : (
'Yes, repost'
)}
</button>
</div>
</div>

View File

@@ -1,34 +1,96 @@
import { NostrEvent } from '@nostr-dev-kit/ndk';
import { webln } from '@getalby/sdk';
import { SendPaymentResponse } from '@getalby/sdk/dist/types';
import * as Dialog from '@radix-ui/react-dialog';
import { message } from '@tauri-apps/api/dialog';
import { QRCodeSVG } from 'qrcode.react';
import { useState } from 'react';
import { twMerge } from 'tailwind-merge';
import { useEffect, useRef, useState } from 'react';
import CurrencyInput from 'react-currency-input-field';
import TextareaAutosize from 'react-textarea-autosize';
import { CancelIcon, ZapIcon } from '@shared/icons';
import { useStronghold } from '@stores/stronghold';
import { useEvent } from '@utils/hooks/useEvent';
import { useNostr } from '@utils/hooks/useNostr';
import { useProfile } from '@utils/hooks/useProfile';
import { sendNativeNotification } from '@utils/notification';
import { compactNumber } from '@utils/number';
export function NoteZap({ id }: { id: string }) {
export function NoteZap({ id, pubkey }: { id: string; pubkey: string }) {
const { createZap } = useNostr();
const { user } = useProfile(pubkey);
const { data: event } = useEvent(id);
const [amount, setAmount] = useState<null | number>(null);
const [amount, setAmount] = useState<string>('21');
const [zapMessage, setZapMessage] = useState<string>('');
const [invoice, setInvoice] = useState<null | string>(null);
const [isOpen, setIsOpen] = useState(false);
const [isCompleted, setIsCompleted] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const selected = (num: number) => {
if (amount === num) return true;
return false;
};
const walletConnectURL = useStronghold((state) => state.walletConnectURL);
const nwc = useRef(null);
const createZapRequest = async () => {
// @ts-expect-error, todo: fix this
const res = await createZap(event as unknown as NostrEvent, amount);
if (res) setInvoice(res);
try {
const zapAmount = parseInt(amount) * 1000;
const res = await createZap(event, zapAmount, zapMessage);
if (!res)
return await message('Cannot create zap request', {
title: 'Zap',
type: 'error',
});
// user don't connect nwc, create QR Code for invoice
if (!walletConnectURL) return setInvoice(res);
// user connect nwc
nwc.current = new webln.NostrWebLNProvider({
nostrWalletConnectUrl: walletConnectURL,
});
await nwc.current.enable();
// start loading
setIsLoading(true);
// send payment via nwc
const send: SendPaymentResponse = await nwc.current.sendPayment(res);
if (send) {
await sendNativeNotification(
`You've tipped ${compactNumber.format(send.amount)} sats to ${
user?.display_name || user?.name
}`
);
// eose
nwc.current.close();
setIsCompleted(true);
setIsLoading(false);
// reset after 3 secs
const timeout = setTimeout(() => setIsCompleted(false), 3000);
clearTimeout(timeout);
}
} catch (e) {
nwc.current.close();
setIsLoading(false);
await message(JSON.stringify(e), { title: 'Zap', type: 'error' });
}
};
useEffect(() => {
return () => {
setAmount('21');
setZapMessage('');
setIsCompleted(false);
setIsLoading(false);
};
}, []);
return (
<Dialog.Root>
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
<Dialog.Trigger asChild>
<button
type="button"
@@ -41,110 +103,120 @@ export function NoteZap({ id }: { id: string }) {
<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="relative h-min w-full shrink-0 border-b border-white/5 bg-white/5 px-5 py-5">
<div className="flex flex-col items-center gap-1.5">
<Dialog.Title className="font-medium leading-none text-white">
Zap (Beta)
</Dialog.Title>
<Dialog.Description className="text-sm leading-none text-white/50">
Send tip with Bitcoin via Lightning
</Dialog.Description>
</div>
<Dialog.Close className="absolute right-3 top-3 inline-flex h-6 w-6 items-center justify-center rounded-md backdrop-blur-xl hover:bg-white/10">
<div className="inline-flex w-full shrink-0 items-center justify-between px-5 py-3">
<div className="w-6" />
<Dialog.Title className="text-center text-sm font-semibold leading-none text-white">
Send tip to {user?.display_name || user?.name}
</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 className="overflow-y-auto overflow-x-hidden px-5 py-5">
<div className="overflow-y-auto overflow-x-hidden px-5 pb-5">
{!invoice ? (
<>
<div className="grid grid-cols-3 gap-3">
<button
type="button"
onClick={() => setAmount(21000)}
className={twMerge(
'inline-flex flex-col items-center justify-center gap-2 rounded-lg px-2 py-2 backdrop-blur-xl hover:bg-white/10',
`${selected(21000) && 'bg-white/10 backdrop-blur-xl'}`
)}
>
<img className="h-12 w-12" src="/zap.png" alt="High Voltage" />
<span className="text-sm font-medium leading-none text-white/80">
21 sats
<div className="relative flex h-40 flex-col">
<div className="inline-flex h-full flex-1 items-center justify-center gap-1">
<CurrencyInput
placeholder="0"
defaultValue={'21'}
value={amount}
decimalsLimit={2}
min={0} // 0 sats
max={10000} // 1M sats
maxLength={10000} // 1M sats
onValueChange={(value) => setAmount(value)}
className="w-full flex-1 bg-transparent text-right text-4xl font-semibold text-white placeholder:text-white/50 focus:outline-none"
/>
<span className="w-full flex-1 text-left text-4xl font-semibold text-white/50">
sats
</span>
</button>
<button
type="button"
onClick={() => setAmount(69000)}
className={twMerge(
'inline-flex flex-col items-center justify-center gap-2 rounded-lg px-2 py-2 backdrop-blur-xl hover:bg-white/10',
`${selected(69000) && 'bg-white/10 backdrop-blur-xl'}`
)}
>
<img className="h-12 w-12" src="/zap.png" alt="High Voltage" />
<span className="text-sm font-medium leading-none text-white/80">
</div>
<div className="inline-flex items-center justify-center gap-2">
<button
type="button"
onClick={() => setAmount('69')}
className="w-max rounded-full border border-white/5 bg-white/5 px-2.5 py-1 text-sm font-medium hover:bg-white/10"
>
69 sats
</span>
</button>
<button
type="button"
onClick={() => setAmount(100000)}
className={twMerge(
'inline-flex flex-col items-center justify-center gap-2 rounded-lg px-2 py-2 backdrop-blur-xl hover:bg-white/10',
`${selected(100000) && 'bg-white/10 backdrop-blur-xl'}`
)}
>
<img className="h-12 w-12" src="/zap.png" alt="High Voltage" />
<span className="text-sm font-medium leading-none text-white/80">
</button>
<button
type="button"
onClick={() => setAmount('100')}
className="w-max rounded-full border border-white/5 bg-white/5 px-2.5 py-1 text-sm font-medium hover:bg-white/10"
>
100 sats
</span>
</button>
<button
type="button"
onClick={() => setAmount(200000)}
className={twMerge(
'inline-flex flex-col items-center justify-center gap-2 rounded-lg px-2 py-2 backdrop-blur-xl hover:bg-white/10',
`${selected(200000) && 'bg-white/10 backdrop-blur-xl'}`
)}
>
<img className="h-12 w-12" src="/zap.png" alt="High Voltage" />
<span className="text-sm font-medium leading-none text-white/80">
</button>
<button
type="button"
onClick={() => setAmount('200')}
className="w-max rounded-full border border-white/5 bg-white/5 px-2.5 py-1 text-sm font-medium hover:bg-white/10"
>
200 sats
</span>
</button>
<button
type="button"
onClick={() => setAmount(500000)}
className={twMerge(
'inline-flex flex-col items-center justify-center gap-2 rounded-lg px-2 py-2 backdrop-blur-xl hover:bg-white/10',
`${selected(500000) && 'bg-white/10 backdrop-blur-xl'}`
)}
>
<img className="h-12 w-12" src="/zap.png" alt="High Voltage" />
<span className="text-sm font-medium leading-none text-white/80">
</button>
<button
type="button"
onClick={() => setAmount('500')}
className="w-max rounded-full border border-white/5 bg-white/5 px-2.5 py-1 text-sm font-medium hover:bg-white/10"
>
500 sats
</span>
</button>
<button
type="button"
onClick={() => setAmount(1000000)}
className={twMerge(
'inline-flex flex-col items-center justify-center gap-2 rounded-lg px-2 py-2 backdrop-blur-xl hover:bg-white/10',
`${selected(1000000) && 'bg-white/10 backdrop-blur-xl'}`
)}
>
<img className="h-12 w-12" src="/zap.png" alt="High Voltage" />
<span className="text-sm font-medium leading-none text-white/80">
1000 sats
</span>
</button>
</button>
<button
type="button"
onClick={() => setAmount('1000')}
className="w-max rounded-full border border-white/5 bg-white/5 px-2.5 py-1 text-sm font-medium hover:bg-white/10"
>
1K sats
</button>
</div>
</div>
<div className="mt-4 flex w-full">
<button
type="button"
onClick={() => createZapRequest()}
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-fuchsia-500 px-4 hover:bg-fuchsia-600"
>
Create invoice
</button>
<div className="mt-4 flex w-full flex-col gap-2">
<TextareaAutosize
name="zapMessage"
value={zapMessage}
onChange={(e) => setZapMessage(e.target.value)}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
placeholder="Enter message (optional)"
className="relative min-h-[56px] w-full resize-none rounded-lg bg-white/10 px-3 py-2 !outline-none backdrop-blur-xl placeholder:text-white/50"
/>
<div className="flex flex-col gap-2">
{walletConnectURL ? (
<button
type="button"
onClick={() => createZapRequest()}
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-fuchsia-500 px-4 font-medium text-white hover:bg-fuchsia-600"
>
{isCompleted ? (
<p>Successfully tipped</p>
) : isLoading ? (
<span className="flex flex-col">
<p className="mb-px leading-none">Waiting for approval</p>
<p className="text-xs leading-none text-white/70">
Go to your wallet and approve payment request
</p>
</span>
) : (
<span className="flex flex-col">
<p className="mb-px leading-none">Send tip</p>
<p className="text-xs leading-none text-white/70">
You&apos;re using nostr wallet connect
</p>
</span>
)}
</button>
) : (
<button
type="button"
onClick={() => createZapRequest()}
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-fuchsia-500 px-4 font-medium hover:bg-fuchsia-600"
>
<p>Create Lightning invoice</p>
</button>
)}
</div>
</div>
</>
) : (

View File

@@ -43,7 +43,7 @@ export function ChildNote({ id, root }: { id: string; root?: string }) {
}
if (status === 'error') {
const noteLink = `https://nostr.com/${nip19.noteEncode(id)}`;
const noteLink = `https://njump.me/${nip19.noteEncode(id)}`;
return (
<>
<div className="absolute bottom-0 left-[18px] h-[calc(100%-3.4rem)] w-0.5 bg-gradient-to-t from-white/20 to-white/10" />
@@ -62,7 +62,7 @@ export function ChildNote({ id, root }: { id: string; root?: string }) {
<div className="relative z-20 mt-1 flex-1 select-text">
<div className="mb-1 select-text rounded-lg bg-white/5 p-1.5 text-sm">
Lume cannot find this post with your current relays, but you can view it
via nostr.com.{' '}
via njump.me.{' '}
<Link to={noteLink} className="text-fuchsia-500">
Learn more
</Link>

View File

@@ -17,9 +17,6 @@ export * from './kinds/article';
export * from './kinds/articleDetail';
export * from './kinds/unknown';
export * from './metadata';
export * from './users/mini';
export * from './users/repost';
export * from './users/thread';
export * from './kinds/repost';
export * from './child';
export * from './skeleton';

View File

@@ -26,7 +26,7 @@ export function ArticleDetailNote({ event }: { event: NDKEvent }) {
}, [event.id]);*/
return (
<ReactMarkdown className="markdown" remarkPlugins={[remarkGfm]}>
<ReactMarkdown className="markdown-article" remarkPlugins={[remarkGfm]}>
{event.content}
</ReactMarkdown>
);

View File

@@ -1,42 +1,77 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query';
import { nip19 } from 'nostr-tools';
import { useCallback } from 'react';
import { Link } from 'react-router-dom';
import { useNDK } from '@libs/ndk/provider';
import {
ArticleNote,
FileNote,
LinkPreview,
NoteActions,
NoteSkeleton,
RepostUser,
TextNote,
UnknownNote,
} from '@shared/notes';
import { User } from '@shared/user';
import { useEvent } from '@utils/hooks/useEvent';
export function Repost({ event }: { event: NDKEvent }) {
// @ts-expect-error, root_id isn't exist on NDKEvent
const { status, data } = useEvent(event.root_id ?? event.id, event.content);
const embedEvent: null | NDKEvent =
event.content.length > 0 ? JSON.parse(event.content) : null;
const renderKind = useCallback(
(repostEvent: NDKEvent) => {
switch (repostEvent.kind) {
case NDKKind.Text:
return <TextNote content={repostEvent.content} />;
case NDKKind.Article:
return <ArticleNote event={repostEvent} />;
case 1063:
return <FileNote event={repostEvent} />;
default:
return <UnknownNote event={repostEvent} />;
}
const { ndk } = useNDK();
const { status, isError, data } = useQuery(
['repost', event.id],
async () => {
const id = event.tags.find((el) => el[0] === 'e')[1];
if (!id) throw new Error('wrong id');
const ndkEvent = await ndk.fetchEvent(id);
if (!ndkEvent) return Promise.reject(new Error('event not found'));
return ndkEvent;
},
[data]
{
enabled: embedEvent === null,
refetchOnWindowFocus: false,
}
);
const renderKind = useCallback((repostEvent: NDKEvent) => {
switch (repostEvent.kind) {
case NDKKind.Text:
return <TextNote content={repostEvent.content} />;
case NDKKind.Article:
return <ArticleNote event={repostEvent} />;
case 1063:
return <FileNote event={repostEvent} />;
default:
return <UnknownNote event={repostEvent} />;
}
}, []);
if (embedEvent) {
return (
<div className="h-min w-full px-3 pb-3">
<div className="relative flex flex-col gap-10 overflow-hidden rounded-xl bg-white/10 px-3 py-3 backdrop-blur-xl">
<User pubkey={event.pubkey} time={event.created_at} variant="repost" />
<div className="relative flex flex-col">
<User pubkey={embedEvent.pubkey} time={embedEvent.created_at} />
<div className="-mt-6 flex items-start gap-3">
<div className="w-11 shrink-0" />
<div className="relative z-20 flex-1">
{renderKind(embedEvent)}
<NoteActions id={embedEvent.id} pubkey={embedEvent.pubkey} />
</div>
</div>
</div>
</div>
</div>
);
}
if (status === 'loading') {
return (
<div className="h-min w-full px-3 pb-3">
@@ -47,32 +82,38 @@ export function Repost({ event }: { event: NDKEvent }) {
);
}
if (status === 'error') {
// @ts-expect-error, root_id isn't exist on NDKEvent
const noteLink = `https://nostr.com/${nip19.noteEncode(event.root_id)}`;
if (isError) {
const noteLink = `https://njump.me/${nip19.noteEncode(
event.tags.find((el) => el[0] === 'e')[1]
)}`;
return (
<div className="relative mb-5 flex flex-col">
<div className="relative z-10 flex items-start gap-3">
<div className="inline-flex h-11 w-11 items-end justify-center rounded-lg bg-black pb-1">
<img src="/lume.png" alt="lume" className="h-auto w-1/3" />
</div>
<h5 className="truncate font-semibold leading-none text-white">
Lume <span className="text-green-500">(System)</span>
</h5>
</div>
<div className="-mt-6 flex items-start gap-3">
<div className="w-11 shrink-0" />
<div>
<div className="relative z-20 mt-1 flex-1 select-text">
<div className="mb-1 select-text rounded-lg bg-white/5 p-1.5 text-sm">
Lume cannot find this post with your current relays, but you can view it
via nostr.com.{' '}
<Link to={noteLink} className="text-fuchsia-500">
Learn more
</Link>
<div className="h-min w-full px-3 pb-3">
<div className="relative overflow-hidden rounded-xl bg-white/10 px-3 py-3 backdrop-blur-xl">
<div className="relative flex flex-col">
<div className="relative z-10 flex items-start gap-3">
<div className="inline-flex h-11 w-11 items-end justify-center rounded-lg bg-black pb-1">
<img src="/lume.png" alt="lume" className="h-auto w-1/3" />
</div>
<h5 className="truncate font-semibold leading-none text-white">
Lume <span className="text-green-500">(System)</span>
</h5>
</div>
<div className="-mt-6 flex items-start gap-3">
<div className="w-11 shrink-0" />
<div>
<div className="relative z-20 mt-1 flex-1 select-text">
<div className="mb-1 select-text rounded-lg bg-white/5 p-1.5 text-sm">
Lume cannot find this post with your current relays, but you can view
it via njump.me.{' '}
<Link to={noteLink} className="text-fuchsia-500">
Learn more
</Link>
</div>
</div>
<LinkPreview urls={[noteLink]} />
</div>
</div>
<LinkPreview urls={[noteLink]} />
</div>
</div>
</div>
@@ -81,12 +122,10 @@ export function Repost({ event }: { event: NDKEvent }) {
return (
<div className="h-min w-full px-3 pb-3">
<div className="relative overflow-hidden rounded-xl bg-white/10 px-3 py-3 backdrop-blur-xl">
<div className="relative flex flex-col gap-10 overflow-hidden rounded-xl bg-white/10 px-3 py-3 backdrop-blur-xl">
<User pubkey={event.pubkey} time={event.created_at} variant="repost" />
<div className="relative flex flex-col">
<div className="isolate flex flex-col -space-y-4">
<RepostUser pubkey={event.pubkey} />
<User pubkey={data.pubkey} time={data.created_at} isRepost={true} />
</div>
<User pubkey={data.pubkey} time={data.created_at} />
<div className="-mt-2 flex items-start gap-3">
<div className="w-11 shrink-0" />
<div className="relative z-20 flex-1">

View File

@@ -56,7 +56,7 @@ export const MentionNote = memo(function MentionNote({ id }: { id: string }) {
}
if (status === 'error') {
const noteLink = `https://nostr.com/${nip19.noteEncode(id)}`;
const noteLink = `https://njump.me/${nip19.noteEncode(id)}`;
return (
<div className="relative mt-3 flex flex-col">
<div className="relative z-10 flex items-center gap-3">
@@ -70,7 +70,7 @@ export const MentionNote = memo(function MentionNote({ id }: { id: string }) {
<div className="mt-1">
<div className="mb-1 select-text rounded-lg bg-white/5 p-1.5 text-sm">
Lume cannot find this post with your current relays, but you can view it via
nostr.com.{' '}
njump.me.{' '}
<Link to={noteLink} className="text-fuchsia-500">
Learn more
</Link>
@@ -89,7 +89,7 @@ export const MentionNote = memo(function MentionNote({ id }: { id: string }) {
tabIndex={0}
className="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" />
<User pubkey={data.pubkey} time={data.created_at} variant="mention" />
<div className="mt-1">{renderKind(data)}</div>
</div>
);

View File

@@ -6,7 +6,7 @@ import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
import { LoaderIcon } from '@shared/icons';
import { MiniUser } from '@shared/notes/users/mini';
import { User } from '@shared/user';
import { WidgetKinds, useWidgets } from '@stores/widgets';
@@ -86,7 +86,7 @@ export function NoteMetadata({ id }: { id: string }) {
<div className="mt-2 inline-flex h-6 w-11 shrink-0 items-center justify-center">
<div className="isolate flex -space-x-1">
{data.users?.map((user, index) => (
<MiniUser key={user + index} pubkey={user} />
<User key={user + index} pubkey={user} />
))}
</div>
</div>

View File

@@ -12,6 +12,13 @@ export function VideoPreview({ urls }: { urls: string[] }) {
className="!h-auto overflow-hidden rounded-lg object-fill"
controls={true}
pip={true}
light={
<img
src={`https://thumbnail.video/api/get?url=${url}&seconds=1`}
alt={url}
className="h-auto w-full bg-white object-cover"
/>
}
/>
))}
</div>

View File

@@ -1,4 +1,3 @@
import { NDKKind } from '@nostr-dev-kit/ndk';
import { useEffect, useState } from 'react';
import { NoteSkeleton, Reply } from '@shared/notes';
@@ -21,7 +20,6 @@ export function RepliesList({ id }: { id: string }) {
// subscribe for new replies
sub(
{
kinds: [NDKKind.Text],
'#e': [id],
since: Math.floor(Date.now() / 1000),
},
@@ -50,7 +48,7 @@ export function RepliesList({ id }: { id: string }) {
return (
<div className="mt-5 pb-10">
<h5 className="mb-5 text-lg font-semibold text-white">
<h5 className="mb-2 text-lg font-semibold text-white">
{data?.length || 0} replies
</h5>
<div className="flex flex-col gap-3">

View File

@@ -1,19 +0,0 @@
import { Image } from '@shared/image';
import { useProfile } from '@utils/hooks/useProfile';
export function MiniUser({ pubkey }: { pubkey: string }) {
const { status, user } = useProfile(pubkey);
if (status === 'loading') {
return <div className="h-4 w-4 animate-pulse rounded bg-white/10"></div>;
}
return (
<Image
src={user?.picture || user?.image}
alt={pubkey}
className="relative z-20 inline-block h-4 w-4 rounded ring-1 ring-black"
/>
);
}

View File

@@ -1,28 +0,0 @@
import { Image } from '@shared/image';
import { useProfile } from '@utils/hooks/useProfile';
import { shortenKey } from '@utils/shortenKey';
export function RepostUser({ pubkey }: { pubkey: string }) {
const { status, user } = useProfile(pubkey);
if (status === 'loading') {
return <div className="h-4 w-4 animate-pulse rounded bg-white/10"></div>;
}
return (
<div className="flex gap-2 pl-6">
<Image
src={user?.picture || user?.image}
alt={pubkey}
className="relative z-20 inline-block h-6 w-6 rounded bg-white ring-1 ring-black"
/>
<div className="inline-flex items-baseline gap-1">
<h5 className="max-w-[18rem] truncate text-white/50">
{user?.name || user?.display_name || shortenKey(pubkey)}
</h5>
<span className="text-white/50">reposted</span>
</div>
</div>
);
}

View File

@@ -1,34 +0,0 @@
import { Image } from '@shared/image';
import { formatCreatedAt } from '@utils/createdAt';
import { useProfile } from '@utils/hooks/useProfile';
import { displayNpub } from '@utils/shortenKey';
export function ThreadUser({ pubkey, time }: { pubkey: string; time: number }) {
const { status, user } = useProfile(pubkey);
const createdAt = formatCreatedAt(time);
if (status === 'loading') {
return <div className="h-4 w-4 animate-pulse rounded bg-white/10"></div>;
}
return (
<div className="flex items-center gap-3">
<Image
src={user.picture || user.image}
alt={pubkey}
className="relative z-20 inline-block h-11 w-11 rounded-lg"
/>
<div className="flex flex-1 flex-col gap-2">
<h5 className="max-w-[15rem] truncate font-semibold leading-none text-white">
{user.display_name || user.name}
</h5>
<div className="inline-flex items-center gap-2">
<span className="leading-none text-white/50">{createdAt}</span>
<span className="leading-none text-white/50">·</span>
<span className="leading-none text-white/50">{displayNpub(pubkey, 16)}</span>
</div>
</div>
</div>
);
}

View File

@@ -1,7 +1,8 @@
import * as Popover from '@radix-ui/react-popover';
import { memo } from 'react';
import { Link } from 'react-router-dom';
import { twMerge } from 'tailwind-merge';
import { WorldIcon } from '@shared/icons';
import { Image } from '@shared/image';
import { NIP05 } from '@shared/nip05';
@@ -9,77 +10,175 @@ import { formatCreatedAt } from '@utils/createdAt';
import { useProfile } from '@utils/hooks/useProfile';
import { displayNpub } from '@utils/shortenKey';
export function User({
export const User = memo(function User({
pubkey,
time,
size,
isRepost = false,
isChat = false,
variant = 'default',
embedProfile,
}: {
pubkey: string;
time: number;
size?: string;
isRepost?: boolean;
isChat?: boolean;
time?: number;
variant?: 'default' | 'simple' | 'mention' | 'repost' | 'chat' | 'large' | 'thread';
embedProfile?: string;
}) {
const { status, user } = useProfile(pubkey);
const createdAt = formatCreatedAt(time, isChat);
const avatarWidth = size === 'small' ? 'w-6' : 'w-11';
const avatarHeight = size === 'small' ? 'h-6' : 'h-11';
const { status, user } = useProfile(pubkey, embedProfile);
const createdAt = time ? formatCreatedAt(time, variant === 'chat') : 0;
if (status === 'loading') {
return (
<div
className={`relative flex gap-3 ${
size === 'small' ? 'items-center' : 'items-start'
}`}
>
<div
className={`${avatarWidth} ${avatarHeight} ${
size === 'small' ? 'rounded' : 'rounded-lg'
} relative z-10 shrink-0 animate-pulse overflow-hidden bg-white/10 backdrop-blur-xl`}
/>
<div className="flex flex-wrap items-baseline gap-1">
if (variant === 'mention') {
return (
<div className="relative flex items-center gap-3">
<div className="relative z-10 h-6 w-6 shrink-0 animate-pulse overflow-hidden rounded bg-white/10 backdrop-blur-xl" />
<div className="h-3.5 w-36 animate-pulse rounded bg-white/10 backdrop-blur-xl" />
</div>
);
}
return (
<div className="relative flex items-start gap-3">
<div className="relative z-10 h-11 w-11 shrink-0 animate-pulse overflow-hidden rounded-lg bg-white/10 backdrop-blur-xl" />
<div className="h-3.5 w-36 animate-pulse rounded bg-white/10 backdrop-blur-xl" />
</div>
);
}
if (variant === 'mention') {
return (
<div className="relative z-10 flex items-center gap-2">
<button type="button" className="relative z-40 h-6 w-6 shrink-0 overflow-hidden">
<Image
src={user?.picture || user?.image}
alt={pubkey}
className="h-6 w-6 rounded object-cover"
/>
</button>
<div className="flex flex-1 items-baseline gap-2">
<h5 className="max-w-[10rem] truncate font-semibold leading-none text-white">
{user?.display_name || user?.name || displayNpub(pubkey, 16)}
</h5>
<span className="leading-none text-white/50">·</span>
<span className="leading-none text-white/50">{createdAt}</span>
</div>
</div>
);
}
if (variant === 'large') {
return (
<div className="flex h-full w-full flex-col gap-2.5">
<Image
src={user?.picture || user?.image}
alt={pubkey}
className="h-14 w-14 shrink-0 rounded-lg object-cover"
/>
<div className="flex h-full flex-col items-start justify-between">
<div className="flex flex-col items-start gap-1 text-start">
<p className="max-w-[15rem] truncate text-lg font-semibold leading-none text-white">
{user?.name || user?.display_name}
</p>
<p className="line-clamp-6 break-all text-white/70">
{user?.about || user?.bio || 'No bio'}
</p>
</div>
<div className="flex flex-col gap-2">
{user?.website ? (
<Link
to={user?.website}
target="_blank"
className="inline-flex items-center gap-2 text-sm text-white/70"
>
<WorldIcon className="h-4 w-4" />
<p className="max-w-[10rem] truncate">{user.website}</p>
</Link>
) : null}
</div>
</div>
</div>
);
}
if (variant === 'simple') {
return (
<div className="flex items-center gap-2.5">
<Image
src={user?.picture || user?.image}
alt={pubkey}
className="h-12 w-12 shrink-0 rounded-lg object-cover"
/>
<div className="flex w-full flex-col gap-1">
<h3 className="max-w-[15rem] truncate font-medium leading-none text-white">
{user?.name || user?.display_name}
</h3>
<p className="text-sm leading-none text-white/70">
{user?.nip05 || user?.username || displayNpub(pubkey, 16)}
</p>
</div>
</div>
);
}
if (variant === 'repost') {
return (
<>
<div className="flex gap-3">
<Image
src={user?.picture || user?.image}
alt={pubkey}
className="relative z-20 inline-block h-11 w-11 rounded-lg"
/>
<div className="inline-flex items-baseline gap-1">
<h5 className="max-w-[15rem] truncate font-semibold leading-none text-white">
{user?.display_name || user?.name || displayNpub(pubkey, 16)}
</h5>
<span className="font-semibold text-fuchsia-500">reposted</span>
<span className="leading-none text-white/50">·</span>
<span className="leading-none text-white/50">{createdAt}</span>
</div>
</div>
<div className="absolute left-[28px] top-16 h-6 w-0.5 bg-gradient-to-t from-white/20 to-white/10" />
</>
);
}
if (variant === 'thread') {
return (
<div className="flex items-center gap-3">
<Image
src={user?.picture || user?.image}
alt={pubkey}
className="relative z-20 inline-block h-11 w-11 rounded-lg"
/>
<div className="flex flex-1 flex-col gap-2">
<h5 className="max-w-[15rem] truncate font-semibold leading-none text-white">
{user?.display_name || user?.name}
</h5>
<div className="inline-flex items-center gap-2">
<span className="leading-none text-white/50">{createdAt}</span>
<span className="leading-none text-white/50">·</span>
<span className="leading-none text-white/50">{displayNpub(pubkey, 16)}</span>
</div>
</div>
</div>
);
}
return (
<Popover.Root>
<div
className={twMerge(
'relative z-10 flex',
size === 'small' ? 'items-center gap-2' : 'items-start gap-3'
)}
>
<div className="relative z-10 flex items-start gap-3">
<Popover.Trigger asChild>
<button
type="button"
className={`${avatarWidth} ${avatarHeight} relative z-40 shrink-0 overflow-hidden`}
className="relative z-40 h-11 w-11 shrink-0 overflow-hidden"
>
<Image
src={user?.picture || user?.image}
alt={pubkey}
className={twMerge(
`object-cover ${avatarWidth} ${avatarHeight}`,
size === 'small' ? 'rounded' : 'rounded-lg',
isRepost ? 'ring-1 ring-black' : ''
)}
className="h-11 w-11 rounded-lg object-cover"
/>
</button>
</Popover.Trigger>
<div
className={twMerge('flex flex-1 items-baseline gap-2', isRepost ? 'mt-4' : '')}
>
<h5
className={twMerge(
'truncate font-semibold leading-none text-white',
size === 'small' ? 'max-w-[10rem]' : 'max-w-[15rem]'
)}
>
<div className="flex flex-1 items-baseline gap-2">
<h5 className="max-w-[15rem] truncate font-semibold leading-none text-white">
{user?.display_name || user?.name || displayNpub(pubkey, 16)}
</h5>
<span className="leading-none text-white/50">·</span>
@@ -88,7 +187,7 @@ export function User({
</div>
<Popover.Portal>
<Popover.Content
className="w-[300px] overflow-hidden rounded-lg bg-white/10 backdrop-blur-3xl focus:outline-none"
className="w-[300px] overflow-hidden rounded-xl border border-white/10 bg-white/10 backdrop-blur-3xl focus:outline-none"
sideOffset={5}
>
<div className="flex gap-2.5 border-b border-white/5 px-3 py-3">
@@ -115,7 +214,7 @@ export function User({
)}
</div>
<div>
<p className="line-clamp-3 break-all leading-tight text-white">
<p className="line-clamp-3 break-all text-sm leading-tight text-white">
{user?.about}
</p>
</div>
@@ -124,13 +223,13 @@ export function User({
<div className="flex items-center gap-2 px-3 py-3">
<Link
to={`/users/${pubkey}`}
className="inline-flex h-10 flex-1 items-center justify-center rounded-md bg-white/10 text-sm font-medium backdrop-blur-xl hover:bg-fuchsia-500"
className="inline-flex h-10 flex-1 items-center justify-center rounded-md bg-white/10 text-sm font-semibold backdrop-blur-xl hover:bg-fuchsia-500"
>
View profile
</Link>
<Link
to={`/chats/${pubkey}`}
className="inline-flex h-10 flex-1 items-center justify-center rounded-md bg-white/10 text-sm font-medium backdrop-blur-xl hover:bg-fuchsia-500"
className="inline-flex h-10 flex-1 items-center justify-center rounded-md bg-white/10 text-sm font-semibold backdrop-blur-xl hover:bg-fuchsia-500"
>
Message
</Link>
@@ -139,4 +238,4 @@ export function User({
</Popover.Portal>
</Popover.Root>
);
}
});

View File

@@ -13,7 +13,7 @@ import { Widget } from '@utils/types';
export function GlobalArticlesWidget({ params }: { params: Widget }) {
const { ndk } = useNDK();
const { status, data } = useQuery(
['global-articles-widget'],
[params.id + '-' + params.title],
async () => {
const events = await ndk.fetchEvents({
kinds: [NDKKind.Article],

View File

@@ -13,7 +13,7 @@ import { Widget } from '@utils/types';
export function GlobalFilesWidget({ params }: { params: Widget }) {
const { ndk } = useNDK();
const { status, data } = useQuery(
['global-files-widget'],
[params.id + '-' + params.title],
async () => {
const events = await ndk.fetchEvents({
// @ts-expect-error, NDK not support file metadata yet

View File

@@ -22,7 +22,7 @@ import { Widget } from '@utils/types';
export function GlobalHashtagWidget({ params }: { params: Widget }) {
const { ndk } = useNDK();
const { status, data } = useQuery(
['global-hashtag-widget', params.content],
[params.id + '-' + params.title],
async () => {
const events = await ndk.fetchEvents({
kinds: [NDKKind.Text, NDKKind.Repost, NDKKind.Article],

View File

@@ -4,6 +4,7 @@ export * from './local/user';
export * from './local/thread';
export * from './local/files';
export * from './local/articles';
export * from './local/follows';
export * from './global/articles';
export * from './global/files';
export * from './global/hashtag';
@@ -11,3 +12,4 @@ export * from './nostrBand/trendingNotes';
export * from './nostrBand/trendingAccounts';
export * from './tmp/feeds';
export * from './tmp/hashtag';
export * from './other/learnNostr';

View File

@@ -15,7 +15,7 @@ export function LocalArticlesWidget({ params }: { params: Widget }) {
const { db } = useStorage();
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: ['local-articles-widget'],
queryKey: [params.id + '-' + params.title],
queryFn: async ({ pageParam = 0 }) => {
return await db.getAllEventsByKinds([NDKKind.Article], 20, pageParam);
},

View File

@@ -23,7 +23,7 @@ export function LocalFeedsWidget({ params }: { params: Widget }) {
const { db } = useStorage();
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: ['local-feeds-widget', params.content],
queryKey: [params.id + '-' + params.title],
queryFn: async ({ pageParam = 0 }) => {
const authors = JSON.parse(params.content);
return await db.getAllEventsByAuthors(authors, 20, pageParam);

View File

@@ -15,7 +15,7 @@ export function LocalFilesWidget({ params }: { params: Widget }) {
const { db } = useStorage();
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: ['local-files-widget'],
queryKey: [params.id + '-' + params.title],
queryFn: async ({ pageParam = 0 }) => {
return await db.getAllEventsByKinds([1063], 20, pageParam);
},

View File

@@ -23,7 +23,7 @@ export function LocalFollowsWidget({ params }: { params: Widget }) {
const { db } = useStorage();
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
useInfiniteQuery({
queryKey: ['local-follows-widget'],
queryKey: [params.id + '-' + params.title],
queryFn: async ({ pageParam = 0 }) => {
return await db.getAllEventsByAuthors(db.account.follows, 20, pageParam);
},

View File

@@ -130,27 +130,8 @@ export function LocalNetworkWidget() {
sub(
filter,
async (event) => {
let root: string;
let reply: string;
if (event.tags?.[0]?.[0] === 'e' && !event.tags?.[0]?.[3]) {
root = event.tags[0][1];
} else {
root = event.tags.find((el) => el[3] === 'root')?.[1];
reply = event.tags.find((el) => el[3] === 'reply')?.[1];
}
const rawEvent = toRawEvent(event);
await db.createEvent(
event.id,
JSON.stringify(rawEvent),
event.pubkey,
event.kind,
root,
reply,
event.created_at
);
await db.createEvent(rawEvent);
},
false // don't close sub on eose
);

View File

@@ -10,12 +10,12 @@ import {
NoteReplyForm,
NoteStats,
TextNote,
ThreadUser,
UnknownNote,
} from '@shared/notes';
import { RepliesList } from '@shared/notes/replies/list';
import { NoteSkeleton } from '@shared/notes/skeleton';
import { TitleBar } from '@shared/titleBar';
import { User } from '@shared/user';
import { useEvent } from '@utils/hooks/useEvent';
import { Widget } from '@utils/types';
@@ -53,7 +53,7 @@ export function LocalThreadWidget({ params }: { params: Widget }) {
) : (
<div className="h-min w-full px-3 pt-1.5">
<div className="rounded-xl bg-white/10 px-3 pt-3 backdrop-blur-xl">
<ThreadUser pubkey={data.pubkey} time={data.created_at} />
<User pubkey={data.pubkey} time={data.created_at} variant="thread" />
<div className="mt-2">{renderKind(data)}</div>
<NoteActions
id={params.content}

View File

@@ -23,7 +23,7 @@ import { Widget } from '@utils/types';
export function LocalUserWidget({ params }: { params: Widget }) {
const { ndk } = useNDK();
const { status, data } = useQuery(
['local-user-widget', params.content],
[params.id + '-' + params.title],
async () => {
const events = await ndk.fetchEvents({
kinds: [1, 6],

View File

@@ -0,0 +1,63 @@
import { useNavigate } from 'react-router-dom';
import { ArrowRightIcon } from '@shared/icons';
import { TitleBar } from '@shared/titleBar';
import { useResources } from '@stores/resources';
import { Widget } from '@utils/types';
export function LearnNostrWidget({ params }: { params: Widget }) {
const navigate = useNavigate();
const openResource = useResources((state) => state.openResource);
const resources = useResources((state) => state.resources);
const seens = useResources((state) => state.seens);
const open = (naddr: string) => {
// add resource to seen list
openResource(naddr);
// redirect
navigate(`/notes/article/${naddr}`);
};
return (
<div className="relative shrink-0 grow-0 basis-[400px] bg-white/10 backdrop-blur-xl">
<TitleBar id={params.id} title="The Joy of Nostr" />
<div className="scrollbar-hide h-full overflow-y-auto px-3 pb-20">
{resources.map((resource, index) => (
<div key={index} className="mb-6">
<h3 className="mb-2 font-medium text-white/50">{resource.title}</h3>
<div className="flex flex-col gap-2">
{resource.data.length ? (
resource.data.map((item, index) => (
<button
key={index}
type="button"
onClick={() => open(item.id)}
className="flex items-center justify-between rounded-xl bg-white/10 px-4 py-3 hover:bg-white/20"
>
<div className="flex flex-col items-start gap-1">
<h5 className="font-semibold leading-none">{item.title}</h5>
{seens.has(item.id) ? (
<p className="text-sm leading-none text-green-500">Readed</p>
) : (
<p className="text-sm leading-none text-white/70">Unread</p>
)}
</div>
<ArrowRightIcon className="h-5 w-5 text-white" />
</button>
))
) : (
<div className="flex h-14 items-center justify-center rounded-xl bg-white/10 px-3 py-3">
<p className="text-sm font-medium text-white">
More resources are coming, stay tuned.
</p>
</div>
)}
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -1,10 +1,9 @@
import { useState } from 'react';
import { User } from '@app/auth/components/user';
import { useStorage } from '@libs/storage/provider';
import { ArrowRightCircleIcon, CheckCircleIcon } from '@shared/icons';
import { User } from '@shared/user';
import { WidgetKinds, useWidgets } from '@stores/widgets';
@@ -65,7 +64,7 @@ export function XfeedsWidget({ params }: { params: Widget }) {
onClick={() => toggleGroup(item)}
className="inline-flex transform items-center justify-between px-4 py-2 hover:bg-white/20"
>
<User pubkey={item} />
<User pubkey={item} variant="simple" />
{groups.includes(item) && (
<div>
<CheckCircleIcon className="h-4 w-4 text-green-400" />

View File

@@ -4,7 +4,7 @@ import { create } from 'zustand';
interface ActivitiesState {
activities: Array<NDKEvent>;
totalNewActivities: number;
setActivities: (events: NDKEvent[]) => void;
setActivities: (events: NDKEvent[], lastLogin: number) => void;
addActivity: (event: NDKEvent) => void;
clearTotalNewActivities: () => void;
}
@@ -12,13 +12,17 @@ interface ActivitiesState {
export const useActivities = create<ActivitiesState>((set) => ({
activities: null,
totalNewActivities: 0,
setActivities: (events: NDKEvent[]) => {
set(() => ({ activities: events }));
setActivities: (events: NDKEvent[], lastLogin: number) => {
const totalLatest = events.filter((ev) => ev.created_at > lastLogin)?.length ?? 0;
set(() => ({
activities: events,
totalNewActivities: totalLatest,
}));
},
addActivity: (event: NDKEvent) => {
set((state) => ({
activities: state.activities ? [event, ...state.activities] : [event],
totalNewActivities: state.totalNewActivities++,
totalNewActivities: (state.totalNewActivities += 1),
}));
},
clearTotalNewActivities: () => {

88
src/stores/resources.ts Normal file
View File

@@ -0,0 +1,88 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { Resources } from '@utils/types';
const DEFAULT_RESOURCES: Array<Resources> = [
{
title: 'The Basics (provide by nostr.com)',
data: [
{
id: 'naddr1qqxnzd3exsurgwfnxgcnjve5qgsym7p8qvs805ny3z3vausedzzwnwk27cfe67r69nrxpqe8w0urmegrqsqqqa283wgxe0',
title: 'What is Nostr?',
image: '',
},
{
id: 'naddr1qqxnzd3exsurgwf48qcnvdfcqgsym7p8qvs805ny3z3vausedzzwnwk27cfe67r69nrxpqe8w0urmegrqsqqqa28cnv0yt',
title: 'Understanding keys',
image: '',
},
{
id: 'naddr1qqxnzd3exsurgwfcxgcrzwfjqgsym7p8qvs805ny3z3vausedzzwnwk27cfe67r69nrxpqe8w0urmegrqsqqqa28uccw5e',
title: "What's a client?",
image: '',
},
{
id: 'naddr1qqxnzd3exsurgwfexqersdp5qgsym7p8qvs805ny3z3vausedzzwnwk27cfe67r69nrxpqe8w0urmegrqsqqqa28jvlesq',
title: 'What are relays?',
image: '',
},
{
id: 'naddr1qqxnzd3exsur2vpjxserjveeqgsym7p8qvs805ny3z3vausedzzwnwk27cfe67r69nrxpqe8w0urmegrqsqqqa28rqy7mx',
title: 'What is an event?',
image: '',
},
{
id: 'naddr1qqxnzd3exsur2vp5xsmnywpnqgsym7p8qvs805ny3z3vausedzzwnwk27cfe67r69nrxpqe8w0urmegrqsqqqa28hxwx4e',
title: 'How to help Nostr?',
image: '',
},
],
},
{
title: 'Lume Tutorials',
data: [],
},
];
interface ResourceState {
resources: Array<Resources>;
seens: Set<string>;
openResource: (id: string) => void;
}
export const useResources = create<ResourceState>()(
persist(
(set) => ({
resources: DEFAULT_RESOURCES,
seens: new Set(),
openResource: (id: string) => {
set((state) => ({ seens: new Set(state.seens).add(id) }));
},
}),
{
name: 'resources',
storage: {
getItem: (name) => {
const str = localStorage.getItem(name);
return {
state: {
...JSON.parse(str).state,
seens: new Set(JSON.parse(str).state.seens),
},
};
},
setItem: (name, newValue) => {
const str = JSON.stringify({
state: {
...newValue.state,
seens: Array.from(newValue.state.seens),
},
});
localStorage.setItem(name, str);
},
removeItem: (name) => localStorage.removeItem(name),
},
}
)
);

View File

@@ -4,8 +4,10 @@ import { createJSONStorage, persist } from 'zustand/middleware';
interface SidebarState {
feeds: boolean;
chats: boolean;
integrations: boolean;
toggleFeeds: () => void;
toggleChats: () => void;
toggleIntegrations: () => void;
}
export const useSidebar = create<SidebarState>()(
@@ -13,12 +15,14 @@ export const useSidebar = create<SidebarState>()(
(set) => ({
feeds: true,
chats: true,
integrations: true,
toggleFeeds: () => set((state) => ({ feeds: !state.feeds })),
toggleChats: () => set((state) => ({ chats: !state.chats })),
toggleIntegrations: () => set((state) => ({ integrations: !state.integrations })),
}),
{
name: 'sidebar',
storage: createJSONStorage(() => sessionStorage),
storage: createJSONStorage(() => localStorage),
}
)
);

View File

@@ -3,7 +3,9 @@ import { createJSONStorage, persist } from 'zustand/middleware';
interface StrongholdState {
privkey: null | string;
walletConnectURL: null | string;
setPrivkey: (privkey: string) => void;
setWalletConnectURL: (uri: string) => void;
clearPrivkey: () => void;
}
@@ -11,9 +13,13 @@ export const useStronghold = create<StrongholdState>()(
persist(
(set) => ({
privkey: null,
walletConnectURL: null,
setPrivkey: (privkey: string) => {
set({ privkey: privkey });
},
setWalletConnectURL: (uri: string) => {
set({ walletConnectURL: uri });
},
clearPrivkey: () => {
set({ privkey: null });
},

View File

@@ -32,6 +32,9 @@ export const WidgetKinds = {
trendingAccounts: 1,
trendingNotes: 2,
},
other: {
learnNostr: 90000,
},
tmp: {
list: 10000,
xfeed: 10001,
@@ -100,6 +103,16 @@ export const DefaultWidgets: Array<WidgetGroup> = [
},
],
},
{
title: 'Other',
data: [
{
kind: WidgetKinds.other.learnNostr,
title: 'Learn Nostr',
description: 'All things you need to know about Nostr',
},
],
},
];
export const useWidgets = create<WidgetState>()(

View File

@@ -1,56 +1,55 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query';
import { AddressPointer } from 'nostr-tools/lib/nip19';
import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
import { toRawEvent } from '@utils/rawEvent';
export function useEvent(id: string, embed?: string) {
export function useEvent(
id: undefined | string,
naddr?: undefined | AddressPointer,
embed?: undefined | string
) {
const { db } = useStorage();
const { ndk } = useNDK();
const { status, data } = useQuery(
['event', id],
async () => {
// return embed event (nostr.band api) or repost
// return event refer from naddr
if (naddr) {
const rEvents = await ndk.fetchEvents({
kinds: [naddr.kind],
'#d': [naddr.identifier],
authors: [naddr.pubkey],
});
const rEvent = [...rEvents].slice(-1)[0];
if (!rEvent) return Promise.reject(new Error('event not found'));
return rEvent;
}
// return embed event (nostr.band api)
if (embed) {
const event: NDKEvent = JSON.parse(embed);
return event;
}
// get event from db
const dbEvent = await db.getEventByID(id);
if (dbEvent) return dbEvent;
// get event from relay if event in db not present
const event = await ndk.fetchEvent(id);
if (!event) throw new Error(`Event not found: ${id.toString()}`);
let root: string;
let reply: string;
if (event.tags?.[0]?.[0] === 'e' && !event.tags?.[0]?.[3]) {
root = event.tags[0][1];
} else {
root = event.tags.find((el) => el[3] === 'root')?.[1];
reply = event.tags.find((el) => el[3] === 'reply')?.[1];
}
if (!event) return Promise.reject(new Error('event not found'));
const rawEvent = toRawEvent(event);
await db.createEvent(
event.id,
JSON.stringify(rawEvent),
event.pubkey,
event.kind,
root,
reply,
event.created_at
);
await db.createEvent(rawEvent);
return event;
},
{
enabled: !!ndk,
refetchOnMount: false,
refetchOnWindowFocus: false,
}
);

View File

@@ -37,14 +37,10 @@ export function useNostr() {
[]
);
const sub = async (
filter: NDKFilter,
callback: (event: NDKEvent) => void,
closeOnEose?: boolean
) => {
const sub = async (filter: NDKFilter, callback: (event: NDKEvent) => void) => {
if (!ndk) throw new Error('NDK instance not found');
const subEvent = ndk.subscribe(filter, { closeOnEose: closeOnEose ?? true });
const subEvent = ndk.subscribe(filter, { closeOnEose: false });
subManager.set(JSON.stringify(filter), subEvent);
subEvent.addListener('event', (event: NDKEvent) => {
@@ -57,6 +53,24 @@ export function useNostr() {
const follows = new Set<string>(preFollows || []);
const lruNetwork = new LRUCache<string, string, void>({ max: 300 });
// fetch user's relays
const relayEvents = await ndk.fetchEvents({
kinds: [NDKKind.RelayList],
authors: [db.account.pubkey],
});
if (relayEvents) {
const latestRelayEvent = [...relayEvents].sort(
(a, b) => b.created_at - a.created_at
)[0];
if (latestRelayEvent) {
for (const item of latestRelayEvent.tags) {
await db.createRelay(item[1], item[2]);
}
}
}
// fetch user's follows
if (!preFollows) {
const user = ndk.getUser({ hexpubkey: db.account.pubkey });
@@ -67,20 +81,22 @@ export function useNostr() {
}
// build user's network
const events = await ndk.fetchEvents({
kinds: [3],
const followEvents = await ndk.fetchEvents({
kinds: [NDKKind.Contacts],
authors: [...follows],
limit: 300,
});
events.forEach((event: NDKEvent) => {
followEvents.forEach((event: NDKEvent) => {
event.tags.forEach((tag) => {
if (tag[0] === 'p') lruNetwork.set(tag[1], tag[1]);
});
});
// get lru values
const network = [...lruNetwork.values()] as string[];
// update db
await db.updateAccount('follows', [...follows]);
await db.updateAccount('network', [...new Set([...follows, ...network])]);
@@ -126,7 +142,7 @@ export function useNostr() {
let since: number;
if (dbEventsEmpty || db.account.last_login_at === 0) {
since = nHoursAgo(24);
since = db.account.network.length > 400 ? nHoursAgo(12) : nHoursAgo(24);
} else {
since = db.account.last_login_at;
}
@@ -134,40 +150,24 @@ export function useNostr() {
console.log("prefetching events with user's network: ", db.account.network.length);
console.log('prefetching events since: ', since);
const events = await fetcher.fetchAllEvents(
const events = (await fetcher.fetchAllEvents(
relayUrls,
{
kinds: [NDKKind.Text, NDKKind.Repost, 1063, NDKKind.Article],
authors: db.account.network,
},
{ since: since }
);
)) as unknown as NDKEvent[];
// save all events to database
for (const event of events) {
let root: string;
let reply: string;
if (event.tags?.[0]?.[0] === 'e' && !event.tags?.[0]?.[3]) {
root = event.tags[0][1];
} else {
root = event.tags.find((el) => el[3] === 'root')?.[1];
reply = event.tags.find((el) => el[3] === 'reply')?.[1];
}
await db.createEvent(
event.id,
JSON.stringify(event),
event.pubkey,
event.kind,
root,
reply,
event.created_at
);
}
const promises = await Promise.all(
events.map(async (event) => await db.createEvent(event))
);
return { status: 'ok', data: [], message: 'prefetch completed' };
if (promises) return { status: 'ok', message: 'prefetch completed' };
} catch (e) {
console.error('prefetch events failed, error: ', e);
return { status: 'failed', data: [], message: e };
return { status: 'failed', message: e };
}
};

View File

@@ -1,90 +0,0 @@
import { magnetDecode } from '@ctrl/magnet-link';
import { open } from '@tauri-apps/api/dialog';
import { VoidApi } from '@void-cat/api';
import { createBlobFromFile } from '@utils/createBlobFromFile';
import { useNostr } from '@utils/hooks/useNostr';
export function useImageUploader() {
const { publish } = useNostr();
const upload = async (file: null | string, nip94?: boolean) => {
const voidcat = new VoidApi('https://void.cat');
let filepath = file;
if (!file) {
const selected = await open({
multiple: false,
filters: [
{
name: 'Image',
extensions: [
'png',
'jpeg',
'jpg',
'gif',
'mp4',
'mp3',
'webm',
'mkv',
'avi',
'mov',
],
},
],
});
if (Array.isArray(selected)) {
// user selected multiple files
} else if (selected === null) {
// user cancelled the selection
} else {
filepath = selected;
}
}
const filename = filepath.split('/').pop();
const filetype = filename.split('.').pop();
const blob = await createBlobFromFile(filepath);
const uploader = voidcat.getUploader(blob);
// upload file
const res = await uploader.upload();
if (res.ok) {
const url =
res.file?.metadata?.url ?? `https://void.cat/d/${res.file?.id}.${filetype}`;
if (nip94) {
const tags = [
['url', url],
['x', res.file?.metadata?.digest ?? ''],
['m', res.file?.metadata?.mimeType ?? 'application/octet-stream'],
['size', res.file?.metadata?.size.toString() ?? '0'],
];
if (res.file?.metadata?.magnetLink) {
tags.push(['magnet', res.file.metadata.magnetLink]);
const parsedMagnet = magnetDecode(res.file.metadata.magnetLink);
if (parsedMagnet?.infoHash) {
tags.push(['i', parsedMagnet?.infoHash]);
}
}
await publish({ content: '', kind: 1063, tags: tags });
}
return {
url: url,
error: null,
};
}
return {
url: null,
error: 'Upload failed',
};
};
return upload;
}

View File

@@ -1,5 +1,5 @@
import { nip19 } from 'nostr-tools';
import { EventPointer } from 'nostr-tools/lib/nip19';
import { EventPointer, ProfilePointer } from 'nostr-tools/lib/nip19';
import { RichContent } from '@utils/types';
@@ -58,20 +58,27 @@ export function parser(eventContent: string) {
}
// nostr account references
if (word.startsWith('nostr:npub1')) {
if (word.startsWith('nostr:npub1') || word.startsWith('npub1')) {
const npub = word.replace('nostr:', '').replace(/[^a-zA-Z0-9 ]/g, '');
return word.replace(word, `~pub-${nip19.decode(npub).data}~`);
}
// nostr profile references
if (word.startsWith('nostr:nprofile1') || word.startsWith('nprofile1')) {
const nprofile = word.replace('nostr:', '').replace(/[^a-zA-Z0-9 ]/g, '');
const decoded = nip19.decode(nprofile).data as ProfilePointer;
return word.replace(word, `~pub-${decoded.pubkey}~`);
}
// nostr account references
if (word.startsWith('nostr:note1')) {
if (word.startsWith('nostr:note1') || word.startsWith('noté')) {
const note = word.replace('nostr:', '').replace(/[^a-zA-Z0-9 ]/g, '');
content.notes.push(nip19.decode(note).data as string);
return word.replace(word, '');
}
// nostr event references
if (word.startsWith('nostr:nevent1')) {
if (word.startsWith('nostr:nevent1') || word.startsWith('nevent1')) {
const nevent = word.replace('nostr:', '').replace(/[^a-zA-Z0-9 ]/g, '');
const decoded = nip19.decode(nevent).data as EventPointer;
content.notes.push(decoded.id);

11
src/utils/types.d.ts vendored
View File

@@ -111,3 +111,14 @@ export interface NostrBuildResponse extends Response {
}>;
};
}
export interface Resource {
id: string;
title: string;
image: string;
}
export interface Resources {
title: string;
data: Array<Resource>;
}